From 8346e97140cbeffdf4a3f6ddea5967f781022c7d Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 18:55:43 +0800 Subject: [PATCH 01/41] fix(dev): make local watcher spawn cross-platform --- scripts/dev-server.mjs | 36 +++++++++++++++++++++++++++--------- scripts/dev-server.test.mjs | 10 ++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 scripts/dev-server.test.mjs diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs index 5585bda..2575214 100644 --- a/scripts/dev-server.mjs +++ b/scripts/dev-server.mjs @@ -1,9 +1,13 @@ import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; import { readdirSync, statSync, watch } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const scriptPath = fileURLToPath(import.meta.url); +export const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const require = createRequire(import.meta.url); +const tsxCliPath = require.resolve("tsx/cli"); const watchRoots = ["src"].map((entry) => join(repoRoot, entry)); const restartDelayMs = 750; const crashDelayMs = 1500; @@ -17,9 +21,17 @@ function log(message) { console.error(`[devspace:dev] ${message}`); } +export function createServerCommand() { + return { + command: process.execPath, + args: [tsxCliPath, "src/cli.ts", "serve"], + }; +} + function start() { stoppingForRestart = false; - child = spawn("npx", ["tsx", "src/cli.ts", "serve"], { + const { command, args } = createServerCommand(); + child = spawn(command, args, { cwd: repoRoot, env: process.env, stdio: "inherit", @@ -108,13 +120,19 @@ function shutdown() { setTimeout(() => process.exit(1), 3000).unref(); } -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, shutdown); -} +function main() { + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, shutdown); + } -for (const root of watchRoots) { - watchDirectory(root); + for (const root of watchRoots) { + watchDirectory(root); + } + + log("watching src; server restarts on changes and after crashes"); + start(); } -log("watching src; server restarts on changes and after crashes"); -start(); +if (resolve(process.argv[1] ?? "") === scriptPath) { + main(); +} diff --git a/scripts/dev-server.test.mjs b/scripts/dev-server.test.mjs new file mode 100644 index 0000000..8e7ecf5 --- /dev/null +++ b/scripts/dev-server.test.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { createServerCommand } from "./dev-server.mjs"; + +const require = createRequire(import.meta.url); + +assert.deepEqual(createServerCommand(), { + command: process.execPath, + args: [require.resolve("tsx/cli"), "src/cli.ts", "serve"], +}); From 1655f0d23627ce25cd1335f2b63126b5f80af6db Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 18:55:53 +0800 Subject: [PATCH 02/41] fix(oauth): persist clients and refresh token hashes across restarts --- src/oauth-provider.test.ts | 139 ++++++++++++++++++++++++++++++++ src/oauth-provider.ts | 159 ++++++++++++++++++++++++++++++++++++- 2 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 src/oauth-provider.test.ts diff --git a/src/oauth-provider.test.ts b/src/oauth-provider.test.ts new file mode 100644 index 0000000..ad5d364 --- /dev/null +++ b/src/oauth-provider.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { stat, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import { SingleUserOAuthProvider, type OAuthConfig } from "./oauth-provider.js"; +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-oauth-provider-test-")); +const statePath = join(root, "state", "oauth.json"); +const customStatePath = join(root, "custom", "oauth-state.json"); +const resourceServerUrl = new URL("https://devspace.example.com/mcp"); +const config: OAuthConfig = { + ownerToken: "owner-token-that-is-long-enough", + accessTokenTtlSeconds: 3600, + refreshTokenTtlSeconds: 2592000, + scopes: ["devspace"], + allowedRedirectHosts: ["localhost"], + statePath, +}; + +try { + const firstProvider = new SingleUserOAuthProvider(config, resourceServerUrl); + const client = firstProvider.clientsStore.registerClient({ + client_name: "test client", + redirect_uris: ["http://localhost/callback"], + scope: "devspace", + }); + const firstTokens = issueTokens(firstProvider, client.client_id, ["devspace"], resourceServerUrl); + + const savedState = JSON.parse(readFileSync(statePath, "utf8")); + assert.equal(savedState.clients.length, 1); + assert.equal(savedState.refreshTokens.length, 1); + assert.equal(savedState.refreshTokens[0].tokenHash.length > 0, true); + assert.equal(savedState.refreshTokens[0].token, undefined); + assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.refresh_token)), false); + + const stateStats = await stat(statePath); + const dirStats = await stat(join(root, "state")); + assert.equal(stateStats.mode & 0o777, 0o600); + assert.equal(dirStats.mode & 0o777, 0o700); + + const secondProvider = new SingleUserOAuthProvider(config, resourceServerUrl); + const persistedClient = secondProvider.clientsStore.getClient(client.client_id); + assert.equal(persistedClient?.client_id, client.client_id); + + await assert.rejects( + () => secondProvider.verifyAccessToken(firstTokens.access_token), + InvalidTokenError, + ); + + const secondTokens = await secondProvider.exchangeRefreshToken( + client, + assertString(firstTokens.refresh_token), + undefined, + resourceServerUrl, + ); + assert.equal(Boolean(secondTokens.refresh_token), true); + assert.notEqual(secondTokens.refresh_token, firstTokens.refresh_token); + + const rotatedState = JSON.parse(readFileSync(statePath, "utf8")); + assert.equal(rotatedState.refreshTokens.length, 1); + assert.equal(JSON.stringify(rotatedState).includes(assertString(firstTokens.refresh_token)), false); + + const expiredStatePath = join(root, "expired", "oauth.json"); + mkdirSync(join(root, "expired"), { recursive: true }); + writeFileSync( + expiredStatePath, + JSON.stringify({ + version: 1, + clients: [client], + refreshTokens: [{ + tokenHash: "expired-token-hash", + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: 1, + resource: resourceServerUrl.href, + }], + }), + ); + await chmod(expiredStatePath, 0o600); + const expiredProvider = new SingleUserOAuthProvider({ ...config, statePath: expiredStatePath }, resourceServerUrl); + await assert.rejects( + () => expiredProvider.exchangeRefreshToken(client, assertString(firstTokens.refresh_token), undefined, resourceServerUrl), + InvalidGrantError, + ); + const cleanedExpiredState = JSON.parse(readFileSync(expiredStatePath, "utf8")); + assert.equal(cleanedExpiredState.refreshTokens.length, 0); + + const corruptStatePath = join(root, "corrupt", "oauth.json"); + mkdirSync(join(root, "corrupt"), { recursive: true }); + writeFileSync(corruptStatePath, "{not valid json"); + await chmod(corruptStatePath, 0o600); + const corruptProvider = new SingleUserOAuthProvider({ ...config, statePath: corruptStatePath }, resourceServerUrl); + assert.equal(corruptProvider.clientsStore.getClient(client.client_id), undefined); + const repairedState = JSON.parse(readFileSync(corruptStatePath, "utf8")); + assert.deepEqual(repairedState, { version: 1, clients: [], refreshTokens: [] }); + + const emptyStatePath = join(root, "empty", "oauth.json"); + mkdirSync(join(root, "empty"), { recursive: true }); + writeFileSync(emptyStatePath, ""); + await chmod(emptyStatePath, 0o600); + const emptyProvider = new SingleUserOAuthProvider({ ...config, statePath: emptyStatePath }, resourceServerUrl); + assert.equal(emptyProvider.clientsStore.getClient(client.client_id), undefined); + const rewrittenEmptyState = JSON.parse(readFileSync(emptyStatePath, "utf8")); + assert.deepEqual(rewrittenEmptyState, { version: 1, clients: [], refreshTokens: [] }); + + const customProvider = new SingleUserOAuthProvider({ ...config, statePath: customStatePath }, resourceServerUrl); + customProvider.clientsStore.registerClient({ + client_name: "custom state client", + redirect_uris: ["http://localhost/custom"], + scope: "devspace", + }); + assert.equal(JSON.parse(readFileSync(customStatePath, "utf8")).clients.length, 1); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +function issueTokens( + provider: SingleUserOAuthProvider, + clientId: string, + scopes: string[], + resource?: URL, +): OAuthTokens { + const rawIssueTokens = provider["issueTokens"] as ( + currentClientId: string, + currentScopes: string[], + currentResource?: URL, + ) => OAuthTokens; + return rawIssueTokens.call(provider, clientId, scopes, resource); +} + +function assertString(value: string | undefined): string { + if (typeof value !== "string") { + throw new Error("Expected string value"); + } + return value; +} diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 9bb442f..fde3c7a 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -1,4 +1,14 @@ import { timingSafeEqual, randomBytes, randomUUID, createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; import type { Response } from "express"; import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; import type { OAuthServerProvider, AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; @@ -17,6 +27,7 @@ export interface OAuthConfig { refreshTokenTtlSeconds: number; scopes: string[]; allowedRedirectHosts: string[]; + statePath?: string; } interface AuthorizationCodeRecord { @@ -41,6 +52,20 @@ interface RefreshTokenRecord { resource?: URL; } +interface StoredRefreshTokenRecord { + tokenHash: string; + clientId: string; + scopes: string[]; + expiresAt: number; + resource?: string; +} + +interface StoredOAuthState { + version: number; + clients: OAuthClientInformationFull[]; + refreshTokens: StoredRefreshTokenRecord[]; +} + const CODE_TTL_MS = 5 * 60 * 1000; function randomToken(): string { @@ -138,10 +163,95 @@ function redirectHostAllowed(redirectUri: string, allowedHosts: string[]): boole return allowedHosts.includes(parsed.hostname); } +function emptyOAuthState(): StoredOAuthState { + return { + version: 1, + clients: [], + refreshTokens: [], + }; +} + +function parseOAuthState(raw: string): StoredOAuthState { + const parsed = JSON.parse(raw) as Partial; + return { + version: 1, + clients: Array.isArray(parsed.clients) ? parsed.clients : [], + refreshTokens: Array.isArray(parsed.refreshTokens) ? parsed.refreshTokens : [], + }; +} + +function readOAuthState(statePath: string | undefined): StoredOAuthState { + if (!statePath || !existsSync(statePath)) return emptyOAuthState(); + + try { + const raw = readFileSync(statePath, "utf8"); + if (!raw.trim()) return emptyOAuthState(); + return parseOAuthState(raw); + } catch { + return emptyOAuthState(); + } +} + +function ensurePrivateDirectory(directory: string): void { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + chmodSync(directory, 0o700); +} + +function writeOAuthState( + statePath: string | undefined, + clients: OAuthClientInformationFull[], + refreshTokens: Iterable<[string, RefreshTokenRecord]>, +): void { + if (!statePath) return; + + const directory = dirname(statePath); + ensurePrivateDirectory(directory); + + const state: StoredOAuthState = { + version: 1, + clients, + refreshTokens: Array.from(refreshTokens, ([tokenHash, record]) => ({ + tokenHash, + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: record.resource?.href, + })), + }; + const tempPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`; + + try { + writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); + chmodSync(tempPath, 0o600); + renameSync(tempPath, statePath); + chmodSync(statePath, 0o600); + } finally { + rmSync(tempPath, { force: true }); + } +} + +function parseStoredResource(resource: string | undefined): URL | undefined { + if (!resource) return undefined; + + try { + return new URL(resource); + } catch { + return undefined; + } +} + export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore { private readonly clients = new Map(); - constructor(private readonly allowedRedirectHosts: string[]) {} + constructor( + private readonly allowedRedirectHosts: string[], + initialClients: OAuthClientInformationFull[] = [], + private readonly onChange: () => void = () => {}, + ) { + for (const client of initialClients) { + this.clients.set(client.client_id, client); + } + } getClient(clientId: string): OAuthClientInformationFull | undefined { return this.clients.get(clientId); @@ -164,12 +274,17 @@ export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore { response_types: client.response_types ?? ["code"], }; this.clients.set(registered.client_id, registered); + this.onChange(); return registered; } + + dumpClients(): OAuthClientInformationFull[] { + return Array.from(this.clients.values()); + } } export class SingleUserOAuthProvider implements OAuthServerProvider { - readonly clientsStore: OAuthRegisteredClientsStore; + readonly clientsStore: InMemoryOAuthClientsStore; private readonly codes = new Map(); private readonly accessTokens = new Map(); private readonly refreshTokens = new Map(); @@ -180,7 +295,35 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { resourceServerUrl: URL, ) { this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl); - this.clientsStore = new InMemoryOAuthClientsStore(config.allowedRedirectHosts); + const state = readOAuthState(config.statePath); + this.clientsStore = new InMemoryOAuthClientsStore( + config.allowedRedirectHosts, + state.clients, + () => this.saveOAuthState(), + ); + + const now = Math.floor(Date.now() / 1000); + for (const record of state.refreshTokens) { + if ( + typeof record?.tokenHash !== "string" || + typeof record?.clientId !== "string" || + !Array.isArray(record?.scopes) || + typeof record?.expiresAt !== "number" || + record.expiresAt < now + ) { + continue; + } + + this.refreshTokens.set(record.tokenHash, { + token: record.tokenHash, + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: parseStoredResource(record.resource), + }); + } + + this.saveOAuthState(); } async authorize( @@ -305,6 +448,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { const hashed = hashToken(request.token); this.accessTokens.delete(hashed); this.refreshTokens.delete(hashed); + this.saveOAuthState(); } private validCodeRecord( @@ -339,6 +483,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { expiresAt: refreshExpiresAt, resource, }); + this.saveOAuthState(); return { access_token: accessToken, @@ -348,6 +493,14 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { scope: scopes.join(" "), }; } + + private saveOAuthState(): void { + writeOAuthState( + this.config.statePath, + this.clientsStore.dumpClients(), + this.refreshTokens.entries(), + ); + } } function authorizationFormFields( From fb60210ffc37792da888d5f84567e691cbd7fd4d Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 18:56:03 +0800 Subject: [PATCH 03/41] feat(runtime): add opt-in tunnel mode and shell execution policy --- docs/configuration.md | 30 +++++++ docs/setup.md | 20 ++++- package.json | 7 +- src/cli.ts | 32 ++++++- src/cloudflare-tunnel.test.ts | 41 +++++++++ src/cloudflare-tunnel.ts | 164 ++++++++++++++++++++++++++++++++++ src/config.test.ts | 16 +++- src/config.ts | 26 +++++- src/pi-tools.ts | 18 +--- src/server.ts | 48 ++++------ src/shell-policy.test.ts | 12 +++ src/shell-policy.ts | 124 +++++++++++++++++++++++++ src/tool-result.test.ts | 7 ++ src/tool-result.ts | 36 ++++++++ src/user-config.ts | 3 + 15 files changed, 530 insertions(+), 54 deletions(-) create mode 100644 src/cloudflare-tunnel.test.ts create mode 100644 src/cloudflare-tunnel.ts create mode 100644 src/shell-policy.test.ts create mode 100644 src/shell-policy.ts create mode 100644 src/tool-result.test.ts create mode 100644 src/tool-result.ts diff --git a/docs/configuration.md b/docs/configuration.md index 7107338..0101a3d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,7 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com | `PORT` | Local port. Defaults to `7676`. | | `DEVSPACE_ALLOWED_ROOTS` | Comma-separated local roots that workspaces may open. | | `DEVSPACE_PUBLIC_BASE_URL` | Public origin for the server, without `/mcp`. | +| `DEVSPACE_TUNNEL` | Optional automatic tunnel mode. Currently supports `cloudflare` when explicitly enabled. | | `DEVSPACE_ALLOWED_HOSTS` | Optional Host header allowlist override. | | `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | | `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | @@ -49,6 +50,11 @@ DevSpace uses a single-user OAuth approval flow. | `DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | | `DEVSPACE_OAUTH_SCOPES` | `devspace` | | `DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS` | `chatgpt.com,localhost,127.0.0.1` | +| `DEVSPACE_OAUTH_STATE_PATH` | `$DEVSPACE_STATE_DIR/oauth.json` | + +Registered OAuth clients and refresh token hashes are persisted in +`$DEVSPACE_STATE_DIR/oauth.json` by default. Access tokens and authorization +codes remain in memory only. MCP clients discover metadata from: @@ -73,6 +79,30 @@ MCP clients discover metadata from: | `minimal` | Default. Disables dedicated search and list tools. Clients use the shell tool with `rg`, `grep`, `find`, `ls`, or `tree` for inspection. | | `full` | Enables dedicated `grep`, `glob`, and `ls` tools. | +`DEVSPACE_SHELL_MODE` controls shell execution policy. + +| Value | Behavior | +| --- | --- | +| `full` | Default. Preserves the current shell behavior. | +| `read-only` | Allows only single-command inspection workflows such as `rg`, `git status`, `find`, or `ls`. Blocks shell control operators and mutating commands. | +| `off` | Disables shell execution entirely. | + +## Tunnel Modes + +DevSpace keeps the existing manual `publicBaseUrl` flow by default. Automatic +Cloudflare quick tunnel mode is opt-in only. + +Enable it explicitly with one of: + +```bash +npx @waishnav/devspace serve --tunnel +DEVSPACE_TUNNEL=cloudflare npx @waishnav/devspace serve +``` + +Or set `"tunnel": "cloudflare"` in `~/.devspace/config.json`. + +Use `--no-tunnel` to override configured tunnel mode for one run. + ## Widgets `DEVSPACE_WIDGETS` controls ChatGPT Apps iframe usage. diff --git a/docs/setup.md b/docs/setup.md index 8efbcdc..4ad2c3e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -11,8 +11,8 @@ projects through DevSpace. - Bash, including Git Bash or WSL on Windows - a public HTTPS URL that forwards to the local DevSpace server -DevSpace does not create the public tunnel for you. Use Cloudflare Tunnel, -ngrok, Pinggy, Tailscale Funnel, or your own HTTPS reverse proxy. +DevSpace does not create the public tunnel for you by default. Use Cloudflare +Tunnel, ngrok, Pinggy, Tailscale Funnel, or your own HTTPS reverse proxy. ## Install And Configure @@ -95,6 +95,22 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com npx @waishnav/devspace serve ``` +If you explicitly want DevSpace to open a Cloudflare quick tunnel for a single +run, opt in with: + +```bash +npx @waishnav/devspace serve --tunnel +``` + +Or: + +```bash +DEVSPACE_TUNNEL=cloudflare npx @waishnav/devspace serve +``` + +This mode requires an existing `cloudflared` binary and does not replace the +default manual public URL workflow. + ## Approve The Client When ChatGPT, Claude, or another MCP client connects, DevSpace shows an Owner diff --git a/package.json b/package.json index f8d3903..d111dd9 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts", + "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], @@ -33,7 +33,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.79.4", + "@earendil-works/pi-coding-agent": "^0.79.8", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", "@pierre/diffs": "^1.2.5", @@ -59,6 +59,7 @@ }, "overrides": { "protobufjs": "7.6.4", - "ws": "8.21.0" + "ws": "8.21.0", + "undici": "8.5.0" } } diff --git a/src/cli.ts b/src/cli.ts index 0e0147b..be0242a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { resolve } from "node:path"; import * as prompts from "@clack/prompts"; import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; +import { resolveTunnelMode, startQuickTunnel, type QuickTunnel } from "./cloudflare-tunnel.js"; import { loadConfig } from "./config.js"; import { generateOwnerToken, @@ -28,7 +29,7 @@ async function main(argv: string[]): Promise { switch (command) { case "serve": await ensureConfigured(); - await serve(); + await serve(args); return; case "init": await runInit({ force: args.includes("--force") }); @@ -162,7 +163,7 @@ async function runInit({ force }: { force: boolean }): Promise { } } -async function serve(): Promise { +async function serve(args: string[] = []): Promise { const sqliteStatus = checkSqliteNative(); if (sqliteStatus !== "ok") { throw new Error( @@ -176,6 +177,23 @@ async function serve(): Promise { ); } + let tunnel: QuickTunnel | undefined; + const configuredTunnel = resolveTunnelMode({ + args, + env: process.env, + configuredTunnel: loadDevspaceFiles().config.tunnel, + }); + if (configuredTunnel === "cloudflare") { + const files = loadDevspaceFiles(); + const host = process.env.HOST ?? files.config.host ?? "127.0.0.1"; + const port = Number(process.env.PORT ?? files.config.port ?? 7676); + const tunnelHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host; + const localBaseUrl = `http://${tunnelHost}:${port}`; + + tunnel = await startQuickTunnel(localBaseUrl, { quiet: true }); + process.env.DEVSPACE_PUBLIC_BASE_URL = tunnel.publicBaseUrl; + } + const { createServer } = await import("./server.js"); const config = loadConfig(); const { app } = createServer(config); @@ -187,11 +205,15 @@ async function serve(): Promise { if (config.allowedHosts.includes("*")) { console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); } + if (tunnel) { + console.log(`cloudflare tunnel: ${tunnel.publicBaseUrl}`); + } console.log("auth: Owner password approval required"); console.log(`logging: ${config.logging.level} ${config.logging.format}`); }); const shutdown = () => { + tunnel?.stop(); httpServer.close(() => process.exit(0)); }; process.once("SIGINT", shutdown); @@ -257,11 +279,17 @@ function printHelp(): void { "Usage:", " devspace Run first-time setup if needed, then start the server", " devspace serve Start the server", + " devspace serve --tunnel Start the server with an explicit Cloudflare quick tunnel", + " devspace serve --no-tunnel Disable a configured Cloudflare quick tunnel for this run", " devspace init Create or update ~/.devspace/config.json and auth.json", " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", "", + "Optional Cloudflare quick tunnel:", + " DEVSPACE_TUNNEL=cloudflare devspace serve", + " or set { \"tunnel\": \"cloudflare\" } in ~/.devspace/config.json", + "", "For temporary tunnels:", " DEVSPACE_PUBLIC_BASE_URL=https://example.trycloudflare.com devspace serve", ].join("\n"), diff --git a/src/cloudflare-tunnel.test.ts b/src/cloudflare-tunnel.test.ts new file mode 100644 index 0000000..048914f --- /dev/null +++ b/src/cloudflare-tunnel.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { + buildCloudflareTunnelCommand, + extractTryCloudflareUrl, + resolveTunnelMode, +} from "./cloudflare-tunnel.js"; + +assert.equal(resolveTunnelMode(), undefined); +assert.equal(resolveTunnelMode({ args: ["--tunnel"] }), "cloudflare"); +assert.equal(resolveTunnelMode({ args: ["--tunnel=cloudflare"] }), "cloudflare"); +assert.equal(resolveTunnelMode({ args: ["--tunnel", "--no-tunnel"] }), undefined); +assert.equal(resolveTunnelMode({ env: { DEVSPACE_TUNNEL: "cloudflare" } as NodeJS.ProcessEnv }), "cloudflare"); +assert.equal(resolveTunnelMode({ env: { DEVSPACE_TUNNEL: "off" } as NodeJS.ProcessEnv }), undefined); +assert.equal(resolveTunnelMode({ configuredTunnel: "cloudflare" }), "cloudflare"); +assert.equal( + resolveTunnelMode({ + args: ["--no-tunnel"], + env: { DEVSPACE_TUNNEL: "cloudflare" } as NodeJS.ProcessEnv, + configuredTunnel: "cloudflare", + }), + undefined, +); + +assert.equal( + extractTryCloudflareUrl("INF Requesting new quick Tunnel on trycloudflare.com...\nhttps://abc-123.trycloudflare.com"), + "https://abc-123.trycloudflare.com", +); +assert.equal( + extractTryCloudflareUrl("https://abc.trycloudflare.com and then https://def.trycloudflare.com"), + "https://abc.trycloudflare.com", +); +assert.equal(extractTryCloudflareUrl("https://example.com"), undefined); +assert.equal(extractTryCloudflareUrl("https://nottrycloudflare.example.com"), undefined); + +assert.deepEqual( + buildCloudflareTunnelCommand("cloudflared", "http://127.0.0.1:7676"), + { + command: "cloudflared", + args: ["tunnel", "--url", "http://127.0.0.1:7676", "--no-autoupdate"], + }, +); diff --git a/src/cloudflare-tunnel.ts b/src/cloudflare-tunnel.ts new file mode 100644 index 0000000..83746aa --- /dev/null +++ b/src/cloudflare-tunnel.ts @@ -0,0 +1,164 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import type { TunnelMode } from "./user-config.js"; + +const TRYCLOUDFLARE_URL_RE = /https:\/\/([a-zA-Z0-9-]+)\.trycloudflare\.com\b/g; + +export interface QuickTunnel { + publicBaseUrl: string; + child: ChildProcess; + stop: () => void; +} + +export interface StartQuickTunnelOptions { + quiet?: boolean; + timeoutMs?: number; +} + +export interface CloudflareSpawnCommand { + command: string; + args: string[]; +} + +export interface TunnelModeOptions { + args?: string[]; + env?: NodeJS.ProcessEnv; + configuredTunnel?: TunnelMode; +} + +export function resolveTunnelMode(options: TunnelModeOptions = {}): TunnelMode | undefined { + const args = options.args ?? []; + if (args.includes("--no-tunnel")) return undefined; + if (args.includes("--tunnel") || args.includes("--tunnel=cloudflare")) return "cloudflare"; + + const envTunnel = options.env?.DEVSPACE_TUNNEL?.trim().toLowerCase(); + if (envTunnel === "cloudflare") return "cloudflare"; + if (envTunnel === "none" || envTunnel === "off") return undefined; + + return options.configuredTunnel; +} + +export function extractTryCloudflareUrl(output: string): string | undefined { + const match = TRYCLOUDFLARE_URL_RE.exec(output); + TRYCLOUDFLARE_URL_RE.lastIndex = 0; + return match ? `https://${match[1]}.trycloudflare.com` : undefined; +} + +export function buildCloudflareTunnelCommand( + cloudflaredPath: string, + localBaseUrl: string, +): CloudflareSpawnCommand { + return { + command: cloudflaredPath, + args: ["tunnel", "--url", localBaseUrl, "--no-autoupdate"], + }; +} + +export function resolveCloudflaredBinary(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.CLOUDFLARED_BIN?.trim(); + if (explicit) { + if (verifyCloudflared(explicit)) return explicit; + throw new Error(`CLOUDFLARED_BIN is set to ${explicit}, but it failed --version.`); + } + + if (verifyCloudflared("cloudflared")) return "cloudflared"; + throw new Error( + "Cloudflare tunnel mode requires an installed cloudflared binary. " + + "Install cloudflared or set CLOUDFLARED_BIN to an existing executable.", + ); +} + +export async function startQuickTunnel( + localBaseUrl: string, + options: StartQuickTunnelOptions = {}, +): Promise { + const cloudflaredPath = resolveCloudflaredBinary(); + const command = buildCloudflareTunnelCommand(cloudflaredPath, localBaseUrl); + const child = spawn(command.command, command.args, { + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }); + + try { + const publicBaseUrl = await waitForCloudflareUrl(child, options.timeoutMs ?? 45_000); + if (!options.quiet) { + console.log(`devspace: Cloudflare quick tunnel ready at ${publicBaseUrl}`); + } + + return { + publicBaseUrl, + child, + stop: () => stopChildProcess(child), + }; + } catch (error) { + stopChildProcess(child); + throw error; + } +} + +function verifyCloudflared(binaryPath: string): boolean { + if (binaryPath !== "cloudflared" && !existsSync(binaryPath)) return false; + + const result = spawnSync(binaryPath, ["--version"], { + stdio: "ignore", + shell: false, + timeout: 15_000, + }); + return result.status === 0; +} + +function waitForCloudflareUrl(child: ChildProcess, timeoutMs: number): Promise { + let output = ""; + + return new Promise((resolve, reject) => { + const cleanup = () => { + child.stdout?.off("data", onData); + child.stderr?.off("data", onData); + child.off("exit", onExit); + clearTimeout(timer); + }; + + const onData = (chunk: Buffer | string) => { + output += String(chunk); + const publicBaseUrl = extractTryCloudflareUrl(output); + if (!publicBaseUrl) return; + + cleanup(); + resolve(publicBaseUrl); + }; + + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject(new Error(`cloudflared exited before publishing a tunnel URL (code=${code}, signal=${signal ?? "none"}).`)); + }; + + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for cloudflared to publish a trycloudflare URL.")); + }, timeoutMs); + timer.unref?.(); + + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + child.on("exit", onExit); + }); +} + +function stopChildProcess(child: ChildProcess): void { + if (child.killed) return; + + try { + child.kill("SIGTERM"); + } catch { + return; + } + + setTimeout(() => { + if (child.killed) return; + try { + child.kill("SIGKILL"); + } catch { + // ignore cleanup failures + } + }, 1_500).unref?.(); +} diff --git a/src/config.test.ts b/src/config.test.ts index 4f29c4e..492fb66 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { loadConfig } from "./config.js"; const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); @@ -23,6 +24,9 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).minimalTo assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).minimalTools, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).minimalTools, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).minimalTools, true); +assert.equal(loadConfig(baseEnv).shellMode, "full"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL_MODE: "read-only" }).shellMode, "read-only"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL_MODE: "off" }).shellMode, "off"); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); @@ -43,6 +47,10 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), /Invalid DEVSPACE_TOOL_MODE: invalid/, ); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_SHELL_MODE: "invalid" }), + /Invalid DEVSPACE_SHELL_MODE: invalid/, +); assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "invalid" }), /Invalid DEVSPACE_TOOL_NAMING: invalid/, @@ -84,6 +92,7 @@ assert.throws( ); assert.equal(loadConfig(baseEnv).oauth.ownerToken, "test-owner-token-that-is-long-enough"); +assert.match(loadConfig(baseEnv).oauth.statePath ?? "", /oauth\.json$/); assert.deepEqual(loadConfig(baseEnv).oauth.scopes, ["devspace"]); assert.deepEqual(loadConfig(baseEnv).oauth.allowedRedirectHosts, [ "chatgpt.com", @@ -112,6 +121,10 @@ assert.equal( .refreshTokenTtlSeconds, 240, ); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_OAUTH_STATE_PATH: "~/custom-devspace-oauth.json" }).oauth.statePath, + resolve(homedir(), "custom-devspace-oauth.json"), +); assert.throws( () => loadConfig({ DEVSPACE_CONFIG_DIR: emptyConfigDir, DEVSPACE_ALLOWED_ROOTS: process.cwd() }), @@ -161,6 +174,7 @@ writeFileSync( const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); assert.equal(fileConfig.port, 8787); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); +assert.match(fileConfig.oauth.statePath ?? "", /oauth\.json$/); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", diff --git a/src/config.ts b/src/config.ts index bb0526c..136c32c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ import { loadDevspaceFiles } from "./user-config.js"; export type ToolNamingMode = "legacy" | "short"; export type WidgetMode = "off" | "changes" | "full"; +export type ShellMode = "full" | "read-only" | "off"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; @@ -18,6 +19,7 @@ export interface ServerConfig { allowedHosts: string[]; publicBaseUrl: string; minimalTools: boolean; + shellMode: ShellMode; toolNaming: ToolNamingMode; widgets: WidgetMode; stateDir: string; @@ -89,6 +91,13 @@ function parseMinimalTools(env: NodeJS.ProcessEnv): boolean { return true; } +function parseShellMode(value: string | undefined): ShellMode { + if (!value || value === "full") return "full"; + if (value === "read-only" || value === "off") return value; + + throw new Error(`Invalid DEVSPACE_SHELL_MODE: ${value}`); +} + function parseLogLevel(value: string | undefined): LogLevel { if (!value || value === "info") return "info"; if (["silent", "error", "warn", "debug"].includes(value)) return value as LogLevel; @@ -170,7 +179,15 @@ function parseRequiredSecret(value: string | undefined, name: string): string { return secret; } -function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined): OAuthConfig { +function defaultOAuthStatePath(stateDir: string): string { + return join(stateDir, "oauth.json"); +} + +function parseOAuthConfig( + env: NodeJS.ProcessEnv, + ownerToken: string | undefined, + stateDir: string, +): OAuthConfig { return { ownerToken: parseRequiredSecret(env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken, "DEVSPACE_OAUTH_OWNER_TOKEN"), accessTokenTtlSeconds: parsePositiveInteger( @@ -189,6 +206,7 @@ function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined "localhost", "127.0.0.1", ]), + statePath: resolve(expandHomePath(env.DEVSPACE_OAUTH_STATE_PATH ?? defaultOAuthStatePath(stateDir))), }; } @@ -208,6 +226,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const files = loadDevspaceFiles(env); const host = env.HOST ?? files.config.host ?? "127.0.0.1"; const port = parsePort(env.PORT ?? files.config.port); + const stateDir = resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())); const publicBaseUrl = parsePublicBaseUrl( env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), ); @@ -223,14 +242,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { return { host, port, - oauth: parseOAuthConfig(env, files.auth.ownerToken), + oauth: parseOAuthConfig(env, files.auth.ownerToken, stateDir), allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, minimalTools: parseMinimalTools(env), + shellMode: parseShellMode(env.DEVSPACE_SHELL_MODE), toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), - stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), + stateDir, worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), diff --git a/src/pi-tools.ts b/src/pi-tools.ts index 238b9c5..21f9da7 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -17,13 +17,7 @@ import { type AgentToolResult, } from "@earendil-works/pi-coding-agent"; import { resolveAllowedPath } from "./roots.js"; - -type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; -export type ToolResponse = { - content: McpContent[]; - details?: TDetails; - isError?: boolean; -}; +import { toolError, type ToolContent, type ToolResponse } from "./tool-result.js"; interface ToolContext { cwd: string; @@ -31,7 +25,7 @@ interface ToolContext { readRoots?: string[]; } -function toMcpContent(result: AgentToolResult): McpContent[] { +function toMcpContent(result: AgentToolResult): ToolContent[] { return result.content.map((content) => { if (content.type === "text") { return { type: "text", text: content.text }; @@ -45,11 +39,6 @@ function toMcpContent(result: AgentToolResult): McpContent[] { }); } -function formatToolError(error: unknown): McpContent[] { - const message = error instanceof Error ? error.message : String(error); - return [{ type: "text", text: message }]; -} - async function runTool( execute: (input: TInput) => Promise>, input: TInput, @@ -62,7 +51,8 @@ async function runTool( details: result.details, }; } catch (error) { - return { content: formatToolError(error), isError: true }; + const message = error instanceof Error ? error.message : String(error); + return toolError(message); } } diff --git a/src/server.ts b/src/server.ts index 9c554dc..e886a0e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,7 +36,9 @@ import { } from "./pi-tools.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; +import { validateShellCommand } from "./shell-policy.js"; import { formatPathForPrompt } from "./skills.js"; +import { contentStats, contentText, toolError, type ToolContent } from "./tool-result.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; @@ -67,10 +69,6 @@ interface RunningServer { config: ServerConfig; } -type ToolContent = - | { type: "text"; text: string } - | { type: "image"; data: string; mimeType: string }; - interface WorkspaceAppManifestEntry { file: string; css?: string[]; @@ -275,15 +273,6 @@ function logToolCall(config: ServerConfig, fields: ToolLogFields): void { }); } -function contentText(content: ToolContent[]): string { - return content - .filter( - (item): item is { type: "text"; text: string } => item.type === "text", - ) - .map((item) => item.text) - .join("\n"); -} - function toolErrorPreview(content: ToolContent[]): string | undefined { const text = contentText(content).replace(/\s+/g, " ").trim(); if (!text) return undefined; @@ -308,17 +297,6 @@ function textBlock(text: string): ToolContent { return { type: "text", text }; } -function textSummary(content: ToolContent[]): { - lines: number; - characters: number; -} { - const text = contentText(content); - return { - lines: text.length === 0 ? 0 : text.split("\n").length, - characters: text.length, - }; -} - function contentLineCount(content: string): number { if (content.length === 0) return 0; return content.endsWith("\n") @@ -702,7 +680,7 @@ function createMcpServer( workspaces.markReadPathLoaded(workspace, readPath); const summary = { - ...textSummary(response.content), + ...contentStats(response.content), offset: input.offset ?? 1, limited: input.limit !== undefined, }; @@ -1006,7 +984,7 @@ function createMcpServer( const summary = { pattern: input.pattern, scope: input.path ?? ".", - ...textSummary(response.content), + ...contentStats(response.content), }; logToolCall(config, { tool: toolNames.grep, @@ -1076,7 +1054,7 @@ function createMcpServer( const summary = { pattern: input.pattern, scope: input.path ?? ".", - ...textSummary(response.content), + ...contentStats(response.content), }; logToolCall(config, { tool: toolNames.glob, @@ -1143,7 +1121,7 @@ function createMcpServer( return response; } - const summary = textSummary(response.content); + const summary = contentStats(response.content); logToolCall(config, { tool: toolNames.ls, workspaceId, @@ -1212,6 +1190,18 @@ function createMcpServer( workspace, workingDirectory, ); + const shellPolicy = validateShellCommand(config.shellMode, input.command); + if (!shellPolicy.allowed) { + const response = toolError(shellPolicy.reason ?? "Shell command blocked."); + logFailedToolResponse(config, { + tool: toolNames.shell, + workspaceId, + workingDirectory: workingDirectory ?? ".", + command: input.command, + commandLength: input.command.length, + }, response.content, startedAt); + return response; + } const response = await runShellTool(input, { cwd, root: workspace.root, @@ -1231,7 +1221,7 @@ function createMcpServer( const summary = { command: input.command, workingDirectory: workingDirectory ?? ".", - ...textSummary(response.content), + ...contentStats(response.content), }; logToolCall(config, { tool: toolNames.shell, diff --git a/src/shell-policy.test.ts b/src/shell-policy.test.ts new file mode 100644 index 0000000..e931e2d --- /dev/null +++ b/src/shell-policy.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { validateShellCommand } from "./shell-policy.js"; + +assert.equal(validateShellCommand("full", "npm test").allowed, true); +assert.equal(validateShellCommand("off", "pwd").allowed, false); +assert.equal(validateShellCommand("read-only", "rg devspace src").allowed, true); +assert.equal(validateShellCommand("read-only", "git status --short").allowed, true); +assert.equal(validateShellCommand("read-only", "find . -name '*.ts'").allowed, true); +assert.equal(validateShellCommand("read-only", "find . -delete").allowed, false); +assert.equal(validateShellCommand("read-only", "npm test").allowed, false); +assert.equal(validateShellCommand("read-only", "git commit -m nope").allowed, false); +assert.equal(validateShellCommand("read-only", "rg devspace src | head").allowed, false); diff --git a/src/shell-policy.ts b/src/shell-policy.ts new file mode 100644 index 0000000..d9c02dd --- /dev/null +++ b/src/shell-policy.ts @@ -0,0 +1,124 @@ +import type { ShellMode } from "./config.js"; + +export interface ShellPolicyDecision { + allowed: boolean; + mode: ShellMode; + reason?: string; +} + +const READ_ONLY_COMMANDS = new Set([ + "cat", + "df", + "du", + "file", + "find", + "git", + "grep", + "head", + "ls", + "pwd", + "rg", + "stat", + "tail", + "wc", +]); + +const READ_ONLY_GIT_SUBCOMMANDS = new Set([ + "branch", + "diff", + "grep", + "log", + "ls-files", + "remote", + "rev-parse", + "show", + "status", +]); + +const SHELL_CONTROL_PATTERNS = [/&&/, /\|\|/, /;/, /\|/, />/, / DESTRUCTIVE_FIND_FLAGS.has(word)); + if (destructiveFlag) { + return deny(mode, `DEVSPACE_SHELL_MODE=read-only blocked find flag '${destructiveFlag}'.`); + } + + return allow(mode); +} + +function hasShellControlOperator(command: string): boolean { + return SHELL_CONTROL_PATTERNS.some((pattern) => pattern.test(command)); +} + +function basename(command: string): string { + return (command.split(/[\\/]/).pop() ?? command).toLowerCase(); +} + +function allow(mode: ShellMode): ShellPolicyDecision { + return { allowed: true, mode }; +} + +function deny(mode: ShellMode, reason: string): ShellPolicyDecision { + return { allowed: false, mode, reason }; +} diff --git a/src/tool-result.test.ts b/src/tool-result.test.ts new file mode 100644 index 0000000..3250535 --- /dev/null +++ b/src/tool-result.test.ts @@ -0,0 +1,7 @@ +import assert from "node:assert/strict"; +import { contentStats, contentText, textContent, toolError } from "./tool-result.js"; + +assert.deepEqual(textContent("hello"), [{ type: "text", text: "hello" }]); +assert.equal(contentText([{ type: "text", text: "hello" }, { type: "text", text: "world" }]), "hello\nworld"); +assert.deepEqual(contentStats([{ type: "text", text: "hello\nworld" }]), { lines: 2, characters: 11 }); +assert.equal(toolError("nope").isError, true); diff --git a/src/tool-result.ts b/src/tool-result.ts new file mode 100644 index 0000000..e4c220c --- /dev/null +++ b/src/tool-result.ts @@ -0,0 +1,36 @@ +export type ToolContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +export interface ToolResponse { + [key: string]: unknown; + content: ToolContent[]; + details?: TDetails; + isError?: boolean; +} + +export function textContent(text: string): ToolContent[] { + return [{ type: "text", text }]; +} + +export function toolError(message: string): ToolResponse { + return { + content: textContent(message), + isError: true, + }; +} + +export function contentText(content: ToolContent[]): string { + return content + .filter((item): item is { type: "text"; text: string } => item.type === "text") + .map((item) => item.text) + .join("\n"); +} + +export function contentStats(content: ToolContent[]): { lines: number; characters: number } { + const text = contentText(content); + return { + lines: text.length === 0 ? 0 : text.split("\n").length, + characters: text.length, + }; +} diff --git a/src/user-config.ts b/src/user-config.ts index 0b79c51..dad8405 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -9,6 +9,8 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; +export type TunnelMode = "cloudflare"; + export interface DevspaceUserConfig { host?: string; port?: number; @@ -18,6 +20,7 @@ export interface DevspaceUserConfig { stateDir?: string; worktreeRoot?: string; agentDir?: string; + tunnel?: TunnelMode; } export interface DevspaceAuthConfig { From 9081f2a5401f5c256e7f34a1335eee2836e4ebd9 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 18:56:14 +0800 Subject: [PATCH 04/41] chore(deps): refresh audited runtime dependency locks --- package-lock.json | 126 +++++++++++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index c112ebc..0b240d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.79.4", + "@earendil-works/pi-coding-agent": "^0.79.8", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", "@pierre/diffs": "^1.2.5", @@ -70,15 +70,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.4.tgz", - "integrity": "sha512-PthzVzM5m4XH/hrU+2fVjuwuH5M4eMFWbd0NCRScH14XKpwlPc8/Fh6JDz0jQb5kTBT9oQT183YLTHVVulFL9A==", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.8.tgz", + "integrity": "sha512-wr9oTS/yrwURDXnYrONQgFgV7QDlwslXL/rvKU5X7TRtrGxIhippsRApXqYlRwSeMjb2YzgHMfZ/kAhOqrzoFQ==", "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.4", - "@earendil-works/pi-ai": "^0.79.4", - "@earendil-works/pi-tui": "^0.79.4", + "@earendil-works/pi-agent-core": "^0.79.8", + "@earendil-works/pi-ai": "^0.79.8", + "@earendil-works/pi-tui": "^0.79.8", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -92,7 +92,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "bin": { @@ -541,11 +541,11 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.4.tgz", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.8.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.4", + "@earendil-works/pi-ai": "^0.79.8", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -555,14 +555,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.4.tgz", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.8.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -578,12 +579,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.4.tgz", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.8.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -687,6 +688,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -703,6 +707,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -719,6 +726,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -735,6 +745,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -751,6 +764,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -793,14 +809,23 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { @@ -815,6 +840,24 @@ ], "license": "MIT" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -834,9 +877,9 @@ "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { @@ -854,12 +897,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -1451,15 +1488,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { @@ -1637,24 +1674,23 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -1759,9 +1795,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -1798,9 +1834,9 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" From 9e9db2bccecf5cac70b0b3ce4eab417205a93ede Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 18:56:22 +0800 Subject: [PATCH 05/41] chore(git): ignore local WORKING scratch directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 397a63f..5e4384b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ releases/ .env *.log +WORKING/ From eee8b033d8505a5c09a3989a7e82c004ae5f5558 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 19:57:11 +0800 Subject: [PATCH 06/41] fix(oauth): expose openid discovery compatibility alias --- src/server.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index e886a0e..7d8471a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,11 @@ import { access, realpath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; -import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js"; +import { + createOAuthMetadata, + mcpAuthRouter, + getOAuthProtectedResourceMetadataUrl, +} from "@modelcontextprotocol/sdk/server/auth/router.js"; import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; @@ -1313,6 +1317,15 @@ export function createServer(config = loadConfig()): RunningServer { }), ); + app.get("/.well-known/openid-configuration", (_req, res) => { + res.json(createOAuthMetadata({ + provider: oauthProvider, + issuerUrl: new URL(config.publicBaseUrl), + baseUrl: new URL(config.publicBaseUrl), + scopesSupported: config.oauth.scopes, + })); + }); + app.options("/mcp-app-assets/{*asset}", (_req, res) => { setAssetHeaders(res); res.sendStatus(204); From 751c29095bffc8c7f03ca2f17243c973a5e3b528 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 19:58:32 +0800 Subject: [PATCH 07/41] fix(oauth): register openid discovery before auth router --- src/server.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/server.ts b/src/server.ts index 7d8471a..4937e6e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1306,6 +1306,15 @@ export function createServer(config = loadConfig()): RunningServer { next(); }); + app.get("/.well-known/openid-configuration", (_req, res) => { + res.json(createOAuthMetadata({ + provider: oauthProvider, + issuerUrl: new URL(config.publicBaseUrl), + baseUrl: new URL(config.publicBaseUrl), + scopesSupported: config.oauth.scopes, + })); + }); + app.use( mcpAuthRouter({ provider: oauthProvider, @@ -1317,15 +1326,6 @@ export function createServer(config = loadConfig()): RunningServer { }), ); - app.get("/.well-known/openid-configuration", (_req, res) => { - res.json(createOAuthMetadata({ - provider: oauthProvider, - issuerUrl: new URL(config.publicBaseUrl), - baseUrl: new URL(config.publicBaseUrl), - scopesSupported: config.oauth.scopes, - })); - }); - app.options("/mcp-app-assets/{*asset}", (_req, res) => { setAssetHeaders(res); res.sendStatus(204); From efb01e984b8b769b4d5a3b01a57209b1da782d62 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 20:04:56 +0800 Subject: [PATCH 08/41] fix(oauth): add cors header for openid discovery --- src/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server.ts b/src/server.ts index 4937e6e..7a825dc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1307,6 +1307,7 @@ export function createServer(config = loadConfig()): RunningServer { }); app.get("/.well-known/openid-configuration", (_req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); res.json(createOAuthMetadata({ provider: oauthProvider, issuerUrl: new URL(config.publicBaseUrl), From bea9c1f9b9e43ccf91a601f4ac59dbf9f6e5385e Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 20:28:17 +0800 Subject: [PATCH 09/41] chore(logging): preserve original request paths in logs --- src/logger.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/logger.ts b/src/logger.ts index c183ff6..85617ea 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -65,6 +65,8 @@ export function requestIp(req: Request, trustProxy: boolean): string | undefined } export function requestPath(req: Request): string { + const originalUrl = req.originalUrl?.split("?")[0]; + if (originalUrl) return originalUrl; return req.path || req.url.split("?")[0] || req.url; } From 04a828716d00d8b96c2a6376ab228f31d7921c1f Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 20:59:09 +0800 Subject: [PATCH 10/41] fix(apps): expose OpenAI widget template metadata --- src/server.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/server.ts b/src/server.ts index 7a825dc..7a628ab 100644 --- a/src/server.ts +++ b/src/server.ts @@ -101,6 +101,8 @@ interface ToolDefinitionMeta extends Record { resourceUri: string; visibility: ["model"]; }; + "ui/resourceUri": string; + "openai/outputTemplate": string; } type EmptyToolDefinitionMeta = Record & { @@ -134,6 +136,8 @@ function toolWidgetDescriptorMeta( resourceUri: WORKSPACE_APP_URI, visibility: ["model"], }, + "ui/resourceUri": WORKSPACE_APP_URI, + "openai/outputTemplate": WORKSPACE_APP_URI, }, }; } @@ -411,6 +415,17 @@ function appCsp(config: ServerConfig): { }; } +function openAiWidgetCsp(config: ServerConfig): { + resource_domains: string[]; + connect_domains: string[]; +} { + const csp = appCsp(config); + return { + resource_domains: csp.resourceDomains, + connect_domains: csp.connectDomains, + }; +} + function uiBuildDirectory(): string { return fileURLToPath(new URL("../dist/ui", import.meta.url)); } @@ -462,6 +477,9 @@ function createMcpServer( ui: { csp: appCsp(config), }, + "openai/widgetDescription": "Interactive DevSpace workspace and file-change view.", + "openai/widgetPrefersBorder": true, + "openai/widgetCSP": openAiWidgetCsp(config), }, }, async () => { @@ -476,6 +494,9 @@ function createMcpServer( ui: { csp: appCsp(config), }, + "openai/widgetDescription": "Interactive DevSpace workspace and file-change view.", + "openai/widgetPrefersBorder": true, + "openai/widgetCSP": openAiWidgetCsp(config), }, }, ], From 69335f97a6654af5915f4956226ca665e139d8bc Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 21:49:37 +0800 Subject: [PATCH 11/41] fix(oauth): persist access token hashes across restarts --- src/oauth-provider.test.ts | 65 ++++++++++++++++++++++++++++++++++---- src/oauth-provider.ts | 54 +++++++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/src/oauth-provider.test.ts b/src/oauth-provider.test.ts index ad5d364..d303c83 100644 --- a/src/oauth-provider.test.ts +++ b/src/oauth-provider.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { stat, chmod } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -31,9 +32,13 @@ try { const savedState = JSON.parse(readFileSync(statePath, "utf8")); assert.equal(savedState.clients.length, 1); + assert.equal(savedState.accessTokens.length, 1); + assert.equal(savedState.accessTokens[0].tokenHash.length > 0, true); + assert.equal(savedState.accessTokens[0].token, undefined); assert.equal(savedState.refreshTokens.length, 1); assert.equal(savedState.refreshTokens[0].tokenHash.length > 0, true); assert.equal(savedState.refreshTokens[0].token, undefined); + assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.access_token)), false); assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.refresh_token)), false); const stateStats = await stat(statePath); @@ -45,10 +50,10 @@ try { const persistedClient = secondProvider.clientsStore.getClient(client.client_id); assert.equal(persistedClient?.client_id, client.client_id); - await assert.rejects( - () => secondProvider.verifyAccessToken(firstTokens.access_token), - InvalidTokenError, - ); + const persistedAccess = await secondProvider.verifyAccessToken(assertString(firstTokens.access_token)); + assert.equal(persistedAccess.clientId, client.client_id); + assert.deepEqual(persistedAccess.scopes, ["devspace"]); + assert.equal(persistedAccess.resource?.href, resourceServerUrl.href); const secondTokens = await secondProvider.exchangeRefreshToken( client, @@ -61,7 +66,13 @@ try { const rotatedState = JSON.parse(readFileSync(statePath, "utf8")); assert.equal(rotatedState.refreshTokens.length, 1); + assert.equal(rotatedState.accessTokens.length, 2); + assert.equal(JSON.stringify(rotatedState).includes(assertString(firstTokens.access_token)), false); assert.equal(JSON.stringify(rotatedState).includes(assertString(firstTokens.refresh_token)), false); + await assert.rejects( + () => secondProvider.exchangeRefreshToken(client, assertString(firstTokens.refresh_token), undefined, resourceServerUrl), + InvalidGrantError, + ); const expiredStatePath = join(root, "expired", "oauth.json"); mkdirSync(join(root, "expired"), { recursive: true }); @@ -70,6 +81,13 @@ try { JSON.stringify({ version: 1, clients: [client], + accessTokens: [{ + tokenHash: "expired-access-token-hash", + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: 1, + resource: resourceServerUrl.href, + }], refreshTokens: [{ tokenHash: "expired-token-hash", clientId: client.client_id, @@ -86,6 +104,7 @@ try { InvalidGrantError, ); const cleanedExpiredState = JSON.parse(readFileSync(expiredStatePath, "utf8")); + assert.equal(cleanedExpiredState.accessTokens.length, 0); assert.equal(cleanedExpiredState.refreshTokens.length, 0); const corruptStatePath = join(root, "corrupt", "oauth.json"); @@ -95,7 +114,7 @@ try { const corruptProvider = new SingleUserOAuthProvider({ ...config, statePath: corruptStatePath }, resourceServerUrl); assert.equal(corruptProvider.clientsStore.getClient(client.client_id), undefined); const repairedState = JSON.parse(readFileSync(corruptStatePath, "utf8")); - assert.deepEqual(repairedState, { version: 1, clients: [], refreshTokens: [] }); + assert.deepEqual(repairedState, { version: 1, clients: [], accessTokens: [], refreshTokens: [] }); const emptyStatePath = join(root, "empty", "oauth.json"); mkdirSync(join(root, "empty"), { recursive: true }); @@ -104,7 +123,7 @@ try { const emptyProvider = new SingleUserOAuthProvider({ ...config, statePath: emptyStatePath }, resourceServerUrl); assert.equal(emptyProvider.clientsStore.getClient(client.client_id), undefined); const rewrittenEmptyState = JSON.parse(readFileSync(emptyStatePath, "utf8")); - assert.deepEqual(rewrittenEmptyState, { version: 1, clients: [], refreshTokens: [] }); + assert.deepEqual(rewrittenEmptyState, { version: 1, clients: [], accessTokens: [], refreshTokens: [] }); const customProvider = new SingleUserOAuthProvider({ ...config, statePath: customStatePath }, resourceServerUrl); customProvider.clientsStore.registerClient({ @@ -113,6 +132,36 @@ try { scope: "devspace", }); assert.equal(JSON.parse(readFileSync(customStatePath, "utf8")).clients.length, 1); + + const expiredAccessStatePath = join(root, "expired-access", "oauth.json"); + mkdirSync(join(root, "expired-access"), { recursive: true }); + const expiredAccessTokens = issueTokens(firstProvider, client.client_id, ["devspace"], resourceServerUrl); + writeFileSync( + expiredAccessStatePath, + JSON.stringify({ + version: 1, + clients: [client], + accessTokens: [{ + tokenHash: hashTestToken(assertString(expiredAccessTokens.access_token)), + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: 1, + resource: resourceServerUrl.href, + }], + refreshTokens: [], + }), + ); + await chmod(expiredAccessStatePath, 0o600); + const expiredAccessProvider = new SingleUserOAuthProvider( + { ...config, statePath: expiredAccessStatePath }, + resourceServerUrl, + ); + await assert.rejects( + () => expiredAccessProvider.verifyAccessToken(assertString(expiredAccessTokens.access_token)), + InvalidTokenError, + ); + const cleanedExpiredAccessState = JSON.parse(readFileSync(expiredAccessStatePath, "utf8")); + assert.equal(cleanedExpiredAccessState.accessTokens.length, 0); } finally { rmSync(root, { recursive: true, force: true }); } @@ -137,3 +186,7 @@ function assertString(value: string | undefined): string { } return value; } + +function hashTestToken(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index fde3c7a..c5a0729 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -60,9 +60,18 @@ interface StoredRefreshTokenRecord { resource?: string; } +interface StoredAccessTokenRecord { + tokenHash: string; + clientId: string; + scopes: string[]; + expiresAt: number; + resource?: string; +} + interface StoredOAuthState { version: number; clients: OAuthClientInformationFull[]; + accessTokens: StoredAccessTokenRecord[]; refreshTokens: StoredRefreshTokenRecord[]; } @@ -167,6 +176,7 @@ function emptyOAuthState(): StoredOAuthState { return { version: 1, clients: [], + accessTokens: [], refreshTokens: [], }; } @@ -176,6 +186,7 @@ function parseOAuthState(raw: string): StoredOAuthState { return { version: 1, clients: Array.isArray(parsed.clients) ? parsed.clients : [], + accessTokens: Array.isArray(parsed.accessTokens) ? parsed.accessTokens : [], refreshTokens: Array.isArray(parsed.refreshTokens) ? parsed.refreshTokens : [], }; } @@ -200,6 +211,7 @@ function ensurePrivateDirectory(directory: string): void { function writeOAuthState( statePath: string | undefined, clients: OAuthClientInformationFull[], + accessTokens: Iterable<[string, AccessTokenRecord]>, refreshTokens: Iterable<[string, RefreshTokenRecord]>, ): void { if (!statePath) return; @@ -210,6 +222,13 @@ function writeOAuthState( const state: StoredOAuthState = { version: 1, clients, + accessTokens: Array.from(accessTokens, ([tokenHash, record]) => ({ + tokenHash, + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: record.resource?.href, + })), refreshTokens: Array.from(refreshTokens, ([tokenHash, record]) => ({ tokenHash, clientId: record.clientId, @@ -303,6 +322,26 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { ); const now = Math.floor(Date.now() / 1000); + for (const record of state.accessTokens) { + if ( + typeof record?.tokenHash !== "string" || + typeof record?.clientId !== "string" || + !Array.isArray(record?.scopes) || + typeof record?.expiresAt !== "number" || + record.expiresAt < now + ) { + continue; + } + + this.accessTokens.set(record.tokenHash, { + token: record.tokenHash, + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: parseStoredResource(record.resource), + }); + } + for (const record of state.refreshTokens) { if ( typeof record?.tokenHash !== "string" || @@ -414,6 +453,10 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { ): Promise { const record = this.refreshTokens.get(hashToken(refreshToken)); if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) { + if (record) { + this.refreshTokens.delete(hashToken(refreshToken)); + this.saveOAuthState(); + } throw new InvalidGrantError("Invalid refresh token"); } if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) { @@ -430,8 +473,14 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { } async verifyAccessToken(token: string): Promise { - const record = this.accessTokens.get(hashToken(token)); - if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) { + const hashed = hashToken(token); + const record = this.accessTokens.get(hashed); + if (!record) { + throw new InvalidTokenError("Invalid or expired access token"); + } + if (record.expiresAt < Math.floor(Date.now() / 1000)) { + this.accessTokens.delete(hashed); + this.saveOAuthState(); throw new InvalidTokenError("Invalid or expired access token"); } @@ -498,6 +547,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { writeOAuthState( this.config.statePath, this.clientsStore.dumpClients(), + this.accessTokens.entries(), this.refreshTokens.entries(), ); } From 2ae24fb539dc1e70ca1d625598e15f73f26c8f70 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 22:04:18 +0800 Subject: [PATCH 12/41] fix(oauth): persist client consent approvals --- src/oauth-provider.test.ts | 171 ++++++++++++++++++++++++++++++++++++- src/oauth-provider.ts | 99 ++++++++++++++++++++- 2 files changed, 265 insertions(+), 5 deletions(-) diff --git a/src/oauth-provider.test.ts b/src/oauth-provider.test.ts index d303c83..dd75949 100644 --- a/src/oauth-provider.test.ts +++ b/src/oauth-provider.test.ts @@ -6,7 +6,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import { SingleUserOAuthProvider, type OAuthConfig } from "./oauth-provider.js"; -import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; const root = mkdtempSync(join(tmpdir(), "devspace-oauth-provider-test-")); const statePath = join(root, "state", "oauth.json"); @@ -32,6 +33,7 @@ try { const savedState = JSON.parse(readFileSync(statePath, "utf8")); assert.equal(savedState.clients.length, 1); + assert.deepEqual(savedState.approvedConsents, []); assert.equal(savedState.accessTokens.length, 1); assert.equal(savedState.accessTokens[0].tokenHash.length > 0, true); assert.equal(savedState.accessTokens[0].token, undefined); @@ -114,7 +116,7 @@ try { const corruptProvider = new SingleUserOAuthProvider({ ...config, statePath: corruptStatePath }, resourceServerUrl); assert.equal(corruptProvider.clientsStore.getClient(client.client_id), undefined); const repairedState = JSON.parse(readFileSync(corruptStatePath, "utf8")); - assert.deepEqual(repairedState, { version: 1, clients: [], accessTokens: [], refreshTokens: [] }); + assert.deepEqual(repairedState, { version: 1, clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); const emptyStatePath = join(root, "empty", "oauth.json"); mkdirSync(join(root, "empty"), { recursive: true }); @@ -123,7 +125,7 @@ try { const emptyProvider = new SingleUserOAuthProvider({ ...config, statePath: emptyStatePath }, resourceServerUrl); assert.equal(emptyProvider.clientsStore.getClient(client.client_id), undefined); const rewrittenEmptyState = JSON.parse(readFileSync(emptyStatePath, "utf8")); - assert.deepEqual(rewrittenEmptyState, { version: 1, clients: [], accessTokens: [], refreshTokens: [] }); + assert.deepEqual(rewrittenEmptyState, { version: 1, clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); const customProvider = new SingleUserOAuthProvider({ ...config, statePath: customStatePath }, resourceServerUrl); customProvider.clientsStore.registerClient({ @@ -162,6 +164,98 @@ try { ); const cleanedExpiredAccessState = JSON.parse(readFileSync(expiredAccessStatePath, "utf8")); assert.equal(cleanedExpiredAccessState.accessTokens.length, 0); + + const consentStatePath = join(root, "consent", "oauth.json"); + const consentProvider = new SingleUserOAuthProvider({ ...config, statePath: consentStatePath }, resourceServerUrl); + const consentClient = consentProvider.clientsStore.registerClient({ + client_name: "consent client", + redirect_uris: ["http://localhost/consent", "http://localhost/other"], + scope: "devspace", + }); + const consentParams = authorizationParams("http://localhost/consent", resourceServerUrl, ["devspace"], "state-1"); + + const firstConsentGet = mockResponse("GET"); + await consentProvider.authorize(consentClient, consentParams, firstConsentGet.res); + assert.equal(firstConsentGet.statusCode, 200); + assert.match(assertString(firstConsentGet.body), /Owner password/); + + const firstConsentPost = mockResponse("POST", { owner_token: config.ownerToken }); + await consentProvider.authorize(consentClient, consentParams, firstConsentPost.res); + assert.equal(firstConsentPost.redirectStatus, 302); + assert.equal(firstConsentPost.redirectUrl?.searchParams.get("state"), "state-1"); + assert.match(assertPresentString(firstConsentPost.redirectUrl?.searchParams.get("code")), /^code-/); + + const consentSavedState = JSON.parse(readFileSync(consentStatePath, "utf8")); + assert.equal(consentSavedState.approvedConsents.length, 1); + assert.equal(consentSavedState.approvedConsents[0].clientId, consentClient.client_id); + assert.equal(consentSavedState.approvedConsents[0].redirectUri, "http://localhost/consent"); + assert.equal(consentSavedState.approvedConsents[0].resource, resourceServerUrl.href); + assert.deepEqual(consentSavedState.approvedConsents[0].scopes, ["devspace"]); + assert.equal(JSON.stringify(consentSavedState).includes(config.ownerToken), false); + + const secondConsentGet = mockResponse("GET"); + await consentProvider.authorize(consentClient, consentParams, secondConsentGet.res); + assert.equal(secondConsentGet.redirectStatus, 302); + assert.equal(assertUrl(secondConsentGet.redirectUrl).origin + assertUrl(secondConsentGet.redirectUrl).pathname, "http://localhost/consent"); + assert.equal(secondConsentGet.redirectUrl?.searchParams.get("state"), "state-1"); + assert.match(assertPresentString(secondConsentGet.redirectUrl?.searchParams.get("code")), /^code-/); + assert.notEqual(secondConsentGet.redirectUrl?.searchParams.get("code"), firstConsentPost.redirectUrl?.searchParams.get("code")); + assert.equal(secondConsentGet.body, undefined); + + const changedRedirectGet = mockResponse("GET"); + await consentProvider.authorize( + consentClient, + authorizationParams("http://localhost/other", resourceServerUrl, ["devspace"], "state-redirect"), + changedRedirectGet.res, + ); + assert.equal(changedRedirectGet.statusCode, 200); + assert.match(assertString(changedRedirectGet.body), /Owner password/); + + const changedResourceGet = mockResponse("GET"); + await consentProvider.authorize( + consentClient, + authorizationParams("http://localhost/consent", new URL("https://devspace.example.com/mcp/"), ["devspace"], "state-resource"), + changedResourceGet.res, + ); + assert.equal(changedResourceGet.statusCode, 200); + assert.match(assertString(changedResourceGet.body), /Owner password/); + + const expandedScopeStatePath = join(root, "expanded-scope", "oauth.json"); + const expandedScopeProvider = new SingleUserOAuthProvider( + { ...config, scopes: ["devspace", "admin"], statePath: expandedScopeStatePath }, + resourceServerUrl, + ); + const expandedScopeClient = expandedScopeProvider.clientsStore.registerClient({ + client_name: "expanded scope client", + redirect_uris: ["http://localhost/expanded"], + scope: "devspace admin", + }); + await expandedScopeProvider.authorize( + expandedScopeClient, + authorizationParams("http://localhost/expanded", resourceServerUrl, ["devspace"], "state-scope-1"), + mockResponse("POST", { owner_token: config.ownerToken }).res, + ); + const expandedScopeGet = mockResponse("GET"); + await expandedScopeProvider.authorize( + expandedScopeClient, + authorizationParams("http://localhost/expanded", resourceServerUrl, ["devspace", "admin"], "state-scope-2"), + expandedScopeGet.res, + ); + assert.equal(expandedScopeGet.statusCode, 200); + assert.match(assertString(expandedScopeGet.body), /Owner password/); + + const restartedConsentProvider = new SingleUserOAuthProvider({ ...config, statePath: consentStatePath }, resourceServerUrl); + const restartedConsentClient = restartedConsentProvider.clientsStore.getClient(consentClient.client_id); + assert.equal(Boolean(restartedConsentClient), true); + const restartedConsentGet = mockResponse("GET"); + await restartedConsentProvider.authorize(assertClient(restartedConsentClient), consentParams, restartedConsentGet.res); + assert.equal(restartedConsentGet.redirectStatus, 302); + assert.equal(assertUrl(restartedConsentGet.redirectUrl).origin + assertUrl(restartedConsentGet.redirectUrl).pathname, "http://localhost/consent"); + + const finalConsentState = JSON.parse(readFileSync(consentStatePath, "utf8")); + assert.equal(JSON.stringify(finalConsentState).includes(config.ownerToken), false); + assert.equal(JSON.stringify(finalConsentState).includes(assertString(firstTokens.access_token)), false); + assert.equal(JSON.stringify(finalConsentState).includes(assertString(firstTokens.refresh_token)), false); } finally { rmSync(root, { recursive: true, force: true }); } @@ -187,6 +281,77 @@ function assertString(value: string | undefined): string { return value; } +function assertPresentString(value: string | null | undefined): string { + if (typeof value !== "string") { + throw new Error("Expected string value"); + } + return value; +} + function hashTestToken(token: string): string { return createHash("sha256").update(token).digest("base64url"); } + +function authorizationParams( + redirectUri: string, + resource: URL, + scopes: string[], + state: string, +): AuthorizationParams { + return { + redirectUri, + codeChallenge: "challenge", + scopes, + state, + resource, + }; +} + +function mockResponse(method: "GET" | "POST", body: Record = {}) { + const result: { + statusCode?: number; + headers: Record; + body?: string; + redirectStatus?: number; + redirectUrl?: URL; + res: any; + } = { + headers: {}, + res: undefined, + }; + result.res = { + req: { method, body }, + status(code: number) { + result.statusCode = code; + return this; + }, + setHeader(name: string, value: string) { + result.headers[name] = value; + return this; + }, + send(bodyValue: string) { + result.body = bodyValue; + return this; + }, + redirect(code: number, url: string) { + result.redirectStatus = code; + result.redirectUrl = new URL(url); + return this; + }, + }; + return result; +} + +function assertClient(client: OAuthClientInformationFull | undefined): OAuthClientInformationFull { + if (!client) { + throw new Error("Expected OAuth client"); + } + return client; +} + +function assertUrl(url: URL | undefined): URL { + if (!url) { + throw new Error("Expected URL"); + } + return url; +} diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index c5a0729..50f4b6c 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -68,11 +68,20 @@ interface StoredAccessTokenRecord { resource?: string; } +interface StoredOAuthConsentRecord { + clientId: string; + redirectUri: string; + resource: string; + scopes: string[]; + approvedAt: number; +} + interface StoredOAuthState { version: number; clients: OAuthClientInformationFull[]; accessTokens: StoredAccessTokenRecord[]; refreshTokens: StoredRefreshTokenRecord[]; + approvedConsents: StoredOAuthConsentRecord[]; } const CODE_TTL_MS = 5 * 60 * 1000; @@ -178,6 +187,7 @@ function emptyOAuthState(): StoredOAuthState { clients: [], accessTokens: [], refreshTokens: [], + approvedConsents: [], }; } @@ -188,6 +198,7 @@ function parseOAuthState(raw: string): StoredOAuthState { clients: Array.isArray(parsed.clients) ? parsed.clients : [], accessTokens: Array.isArray(parsed.accessTokens) ? parsed.accessTokens : [], refreshTokens: Array.isArray(parsed.refreshTokens) ? parsed.refreshTokens : [], + approvedConsents: Array.isArray(parsed.approvedConsents) ? parsed.approvedConsents : [], }; } @@ -213,6 +224,7 @@ function writeOAuthState( clients: OAuthClientInformationFull[], accessTokens: Iterable<[string, AccessTokenRecord]>, refreshTokens: Iterable<[string, RefreshTokenRecord]>, + approvedConsents: Iterable, ): void { if (!statePath) return; @@ -236,6 +248,13 @@ function writeOAuthState( expiresAt: record.expiresAt, resource: record.resource?.href, })), + approvedConsents: Array.from(approvedConsents, (record) => ({ + clientId: record.clientId, + redirectUri: record.redirectUri, + resource: record.resource, + scopes: record.scopes, + approvedAt: record.approvedAt, + })), }; const tempPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`; @@ -307,6 +326,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { private readonly codes = new Map(); private readonly accessTokens = new Map(); private readonly refreshTokens = new Map(); + private readonly approvedConsents = new Map(); private readonly resourceServerUrl: URL; constructor( @@ -362,6 +382,31 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { }); } + for (const record of state.approvedConsents) { + if ( + typeof record?.clientId !== "string" || + typeof record?.redirectUri !== "string" || + typeof record?.resource !== "string" || + !Array.isArray(record?.scopes) || + typeof record?.approvedAt !== "number" + ) { + continue; + } + + const client = this.clientsStore.getClient(record.clientId); + if (!client || !client.redirect_uris.includes(record.redirectUri)) { + continue; + } + + this.approvedConsents.set(consentKey(record.clientId, record.redirectUri, record.resource, record.scopes), { + clientId: record.clientId, + redirectUri: record.redirectUri, + resource: record.resource, + scopes: normalizeScopes(record.scopes), + approvedAt: record.approvedAt, + }); + } + this.saveOAuthState(); } @@ -370,19 +415,34 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response, ): Promise { + const registeredClient = this.clientsStore.getClient(client.client_id); + if (!registeredClient) { + throw new InvalidRequestError("OAuth client is not registered"); + } if (!params.resource || !checkResourceAllowed({ requestedResource: params.resource, configuredResource: this.resourceServerUrl })) { throw new InvalidRequestError("Invalid or missing OAuth resource"); } if (!requestedScopesAllowed(params.scopes ?? [], this.config.scopes)) { throw new InvalidRequestError("Requested scope is not supported"); } + if (!registeredClient.redirect_uris.includes(params.redirectUri)) { + throw new InvalidRequestError("redirect_uri is not registered for this client"); + } + + const scopes = normalizeScopes(params.scopes ?? this.config.scopes); + const currentConsentKey = consentKey(client.client_id, params.redirectUri, params.resource.href, scopes); if (res.req.method !== "POST") { + if (this.approvedConsents.has(currentConsentKey)) { + this.redirectWithAuthorizationCode(client, params, res); + return; + } + res.status(200).setHeader("Content-Type", "text/html; charset=utf-8"); res.send( formHtml({ clientName: client.client_name ?? client.client_id, - scopes: params.scopes ?? this.config.scopes, + scopes, resource: params.resource, fields: authorizationFormFields(client, params), }), @@ -397,7 +457,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { formHtml({ error: "The Owner password was not accepted.", clientName: client.client_name ?? client.client_id, - scopes: params.scopes ?? this.config.scopes, + scopes, resource: params.resource, fields: authorizationFormFields(client, params), }), @@ -405,6 +465,32 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { return; } + this.approvedConsents.set(currentConsentKey, { + clientId: client.client_id, + redirectUri: params.redirectUri, + resource: params.resource.href, + scopes, + approvedAt: Math.floor(Date.now() / 1000), + }); + this.saveOAuthState(); + this.redirectWithAuthorizationCode(client, params, res); + } + + revokeClientConsent(clientId: string): void { + let changed = false; + for (const [key, record] of this.approvedConsents.entries()) { + if (record.clientId !== clientId) continue; + this.approvedConsents.delete(key); + changed = true; + } + if (changed) this.saveOAuthState(); + } + + private redirectWithAuthorizationCode( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response, + ): void { const code = `code-${randomUUID()}`; this.codes.set(code, { clientId: client.client_id, @@ -549,6 +635,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { this.clientsStore.dumpClients(), this.accessTokens.entries(), this.refreshTokens.entries(), + this.approvedConsents.values(), ); } } @@ -572,3 +659,11 @@ function authorizationFormFields( function hashToken(token: string): string { return createHash("sha256").update(token).digest("base64url"); } + +function normalizeScopes(scopes: string[]): string[] { + return [...scopes].sort(); +} + +function consentKey(clientId: string, redirectUri: string, resource: string, scopes: string[]): string { + return `${clientId}\n${redirectUri}\n${resource}\n${normalizeScopes(scopes).join(" ")}`; +} From 00bcca8391937596bd473f553681b0d71439dbb8 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sat, 20 Jun 2026 22:43:58 +0800 Subject: [PATCH 13/41] Add user input answer loop for planning workflows --- src/db/schema.ts | 67 +++ src/prompting.ts | 49 ++ src/server.ts | 950 +++++++++++++++++++++++++++++++++- src/ui/card-types.ts | 34 ++ src/ui/user-input-payload.tsx | 141 +++++ src/ui/workspace-app.css | 86 +++ src/ui/workspace-app.tsx | 63 +++ src/workspace-store.ts | 639 +++++++++++++++++++++++ src/workspaces.test.ts | 73 +++ 9 files changed, 2077 insertions(+), 25 deletions(-) create mode 100644 src/prompting.ts create mode 100644 src/ui/user-input-payload.tsx diff --git a/src/db/schema.ts b/src/db/schema.ts index ed5d292..5db2c86 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,7 +38,74 @@ export const loadedAgentFiles = sqliteTable( ], ); +export const workspacePlans = sqliteTable( + "workspace_plans", + { + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + explanation: text("explanation"), + stepsJson: text("steps_json").notNull(), + updatedAt: text("updated_at").notNull(), + }, +); + +export const workspaceGoals = sqliteTable( + "workspace_goals", + { + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + objective: text("objective").notNull(), + status: text("status").notNull().default("active"), + tokenBudget: text("token_budget"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + activeSeconds: text("active_seconds").notNull().default("0"), + completedAt: text("completed_at"), + blockedAt: text("blocked_at"), + }, + (table) => [ + index("workspace_goals_status_idx").on(table.status, table.updatedAt), + ], +); + +export const workspaceModes = sqliteTable( + "workspace_modes", + { + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + mode: text("mode").notNull().default("default"), + updatedAt: text("updated_at").notNull(), + }, +); + +export const workspaceUserInputs = sqliteTable( + "workspace_user_inputs", + { + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + promptJson: text("prompt_json").notNull(), + status: text("status").notNull().default("pending"), + deliveryMode: text("delivery_mode"), + responseJson: text("response_json"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + answeredAt: text("answered_at"), + }, +); + export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; +export type WorkspacePlanRow = typeof workspacePlans.$inferSelect; +export type NewWorkspacePlanRow = typeof workspacePlans.$inferInsert; +export type WorkspaceGoalRow = typeof workspaceGoals.$inferSelect; +export type NewWorkspaceGoalRow = typeof workspaceGoals.$inferInsert; +export type WorkspaceModeRow = typeof workspaceModes.$inferSelect; +export type NewWorkspaceModeRow = typeof workspaceModes.$inferInsert; +export type WorkspaceUserInputRow = typeof workspaceUserInputs.$inferSelect; +export type NewWorkspaceUserInputRow = typeof workspaceUserInputs.$inferInsert; diff --git a/src/prompting.ts b/src/prompting.ts new file mode 100644 index 0000000..0b9e27b --- /dev/null +++ b/src/prompting.ts @@ -0,0 +1,49 @@ +import type { ToolNames } from "./server.js"; + +export type CollaborationMode = "default" | "plan"; + +export interface PromptingContext { + minimalTools: boolean; + skillsEnabled: boolean; + widgetsChangesOnly: boolean; +} + +export function serverInstructions( + context: PromptingContext, + toolNames: ToolNames, +): string { + const inspection = context.minimalTools + ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` + : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; + + const skills = context.skillsEnabled + ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` + : ""; + + const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; + + const showChanges = context.widgetsChangesOnly + ? " After creating, editing, or overwriting files, call show_changes once after the related file changes are complete so the user can see the aggregate diff." + : ""; + + const planning = + " Use get_collaboration_mode to inspect the workspace collaboration mode. Use set_collaboration_mode to switch between default execution and plan mode. In default mode, use update_plan for a concise execution checklist when helpful. In plan mode, prefer request_user_input, repository exploration, and concrete specification work; do not use update_plan while plan mode is active. When the user asks to pursue a concrete objective across multiple turns, use create_goal to start one goal for that workspace, get_goal to inspect its status, and update_goal to mark it complete or blocked."; + + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, shell, plan, and goal tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}${planning} Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; +} + +export function workspaceInstruction( + mode: CollaborationMode, + skillsEnabled: boolean, +): string { + const base = skillsEnabled + ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." + : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + + if (mode === "plan") { + return `${base} This workspace is currently in plan mode: explore first, ask clarifying questions with request_user_input when needed, and produce a concrete implementation plan before execution. Do not use update_plan while plan mode is active.`; + } + + return `${base} This workspace is currently in default mode: you may execute work normally, and use update_plan when a concise execution checklist would help.`; +} + diff --git a/src/server.ts b/src/server.ts index 7a628ab..b909e33 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,6 +45,14 @@ import { formatPathForPrompt } from "./skills.js"; import { contentStats, contentText, toolError, type ToolContent } from "./tool-result.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; +import { serverInstructions as buildServerInstructions, workspaceInstruction } from "./prompting.js"; +import type { + WorkspaceStore, + WorkspacePlanStep, + WorkspaceQuestion, + WorkspaceUserInputAnswer, + WorkspaceUserInputRecord, +} from "./workspace-store.js"; type Transport = StreamableHTTPServerTransport; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; @@ -88,6 +96,8 @@ interface DiffStats { type ToolWidgetKind = | "workspace" + | "plan" + | "goal" | "read" | "write" | "edit" @@ -142,7 +152,7 @@ function toolWidgetDescriptorMeta( }; } -interface ToolNames { +export interface ToolNames { openWorkspace: "open_workspace"; read: "read_file" | "read"; write: "write_file" | "write"; @@ -189,24 +199,6 @@ function toolNamesFor(config: ServerConfig): ToolNames { }; } -function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { - const inspection = config.minimalTools - ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` - : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; - - const skills = config.skillsEnabled - ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` - : ""; - - const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - - const showChanges = - config.widgets === "changes" - ? " After creating, editing, or overwriting files, call show_changes once after the related file changes are complete so the user can see the aggregate diff." - : ""; - - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; -} function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { result: z @@ -233,6 +225,41 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); +const userInputAnswerOutputSchema = z.object({ + questionId: z.string(), + label: z.string(), +}); + +const userInputPromptOutputSchema = z.object({ + questions: z.array( + z.object({ + header: z.string(), + id: z.string(), + question: z.string(), + options: z.array( + z.object({ + label: z.string(), + description: z.string(), + }), + ), + }), + ), + autoResolutionMs: z.number().int().min(60000).max(240000).optional(), + status: z.enum(["pending", "completed", "declined", "cancelled"]), + deliveryMode: z.enum(["elicitation", "tool", "ui"]).optional(), + createdAt: z.string(), + updatedAt: z.string(), + answeredAt: z.string().optional(), + response: z + .object({ + answers: z.array(userInputAnswerOutputSchema), + summary: z.string(), + source: z.enum(["elicitation", "tool", "ui"]), + action: z.enum(["accept", "decline", "cancel"]), + }) + .optional(), +}); + const reviewFileOutputSchema = z.object({ path: z.string(), previousPath: z.string().optional(), @@ -452,6 +479,7 @@ function createMcpServer( config: ServerConfig, workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, + workspaceStore: WorkspaceStore, ): McpServer { const toolNames = toolNamesFor(config); const server = new McpServer( @@ -463,7 +491,14 @@ function createMcpServer( "Secure local coding workspace for MCP clients. Provides workspace-scoped file, search, edit, write, and shell tools.", }, { - instructions: serverInstructions(config, toolNames), + instructions: buildServerInstructions( + { + minimalTools: config.minimalTools, + skillsEnabled: config.skillsEnabled, + widgetsChangesOnly: config.widgets === "changes", + }, + toolNames, + ), }, ); @@ -548,6 +583,7 @@ function createMcpServer( skills: z.array(workspaceSkillOutputSchema), skillDiagnostics: z.array(z.unknown()), instruction: z.string(), + collaborationMode: z.enum(["default", "plan"]), }, ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, @@ -575,9 +611,8 @@ function createMcpServer( const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); - const instruction = config.skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + const collaboration = workspaceStore.getCollaborationMode(workspace.id); + const instruction = workspaceInstruction(collaboration.mode, config.skillsEnabled); const resultContent: ToolContent[] = [ { type: "text" as const, @@ -633,6 +668,678 @@ function createMcpServer( skills: visibleSkills, skillDiagnostics: workspace.skillDiagnostics, instruction, + collaborationMode: collaboration.mode, + }, + }; + }, + ); + + registerAppTool( + server, + "get_collaboration_mode", + { + title: "Get collaboration mode", + description: + "Get the workspace collaboration mode. Use this to tell whether the workspace is in default execution mode or plan mode.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + mode: z.enum(["default", "plan"]), + updatedAt: z.string().optional(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const collaboration = workspaceStore.getCollaborationMode(workspaceId); + const content = [textBlock(`Workspace collaboration mode: ${collaboration.mode}`)]; + + logToolCall(config, { + tool: "get_collaboration_mode", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + mode: collaboration.mode, + updatedAt: collaboration.updatedAt || undefined, + }, + }; + }, + ); + + registerAppTool( + server, + "set_collaboration_mode", + { + title: "Set collaboration mode", + description: + "Set the workspace collaboration mode. Use plan mode when the task should stay in exploration and specification until the plan is complete.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + mode: z.enum(["default", "plan"]), + }, + outputSchema: { + result: z.string(), + mode: z.enum(["default", "plan"]), + updatedAt: z.string(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, mode }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const collaboration = workspaceStore.setCollaborationMode({ + workspaceSessionId: workspaceId, + mode, + }); + const content = [textBlock(`Workspace collaboration mode set to ${collaboration.mode}.`)]; + + logToolCall(config, { + tool: "set_collaboration_mode", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + mode: collaboration.mode, + updatedAt: collaboration.updatedAt, + }, + }; + }, + ); + + registerAppTool( + server, + "request_user_input", + { + title: "Request user input", + description: + "Store a structured user-input request for the current workspace. Use this primarily in plan mode when an implementation choice or product preference materially affects the plan.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + autoResolutionMs: z.number().int().min(60000).max(240000).optional(), + questions: z + .array( + z.object({ + header: z.string(), + id: z.string(), + question: z.string(), + options: z + .array( + z.object({ + label: z.string(), + description: z.string(), + }), + ) + .min(2) + .max(3), + }), + ) + .min(1) + .max(3), + }, + outputSchema: { + result: z.string(), + status: z.enum(["pending", "completed", "declined", "cancelled"]), + delivery: z.enum([ + "elicitation_completed", + "elicitation_declined", + "elicitation_cancelled", + "pending_fallback", + ]), + prompt: userInputPromptOutputSchema, + response: z + .object({ + answers: z.array(userInputAnswerOutputSchema), + summary: z.string(), + source: z.enum(["elicitation", "tool", "ui"]), + action: z.enum(["accept", "decline", "cancel"]), + }) + .optional(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, questions, autoResolutionMs }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + validateQuestions(questions); + const requested = workspaceStore.createUserInputRequest({ + workspaceSessionId: workspaceId, + questions, + autoResolutionMs, + }); + + const capabilities = server.server.getClientCapabilities(); + const supportsElicitation = Boolean(capabilities?.elicitation?.form); + + let record = requested; + let delivery: + | "elicitation_completed" + | "elicitation_declined" + | "elicitation_cancelled" + | "pending_fallback" = "pending_fallback"; + + if (supportsElicitation) { + try { + const elicitation = await server.server.elicitInput({ + mode: "form", + message: "Please answer the following questions to continue.", + requestedSchema: toElicitationSchema(questions), + }); + + if (elicitation.action === "accept" && elicitation.content) { + record = workspaceStore.completeUserInput({ + workspaceSessionId: workspaceId, + answers: answersFromElicitation(questions, elicitation.content), + summary: summarizeAnswers(questions, elicitation.content), + source: "elicitation", + }); + delivery = "elicitation_completed"; + } else if (elicitation.action === "decline") { + record = workspaceStore.cancelOrDeclineUserInput({ + workspaceSessionId: workspaceId, + action: "decline", + source: "elicitation", + }); + delivery = "elicitation_declined"; + } else { + record = workspaceStore.cancelOrDeclineUserInput({ + workspaceSessionId: workspaceId, + action: "cancel", + source: "elicitation", + }); + delivery = "elicitation_cancelled"; + } + } catch { + record = requested; + delivery = "pending_fallback"; + } + } + + const content = [ + textBlock( + delivery === "pending_fallback" + ? `${formatUserInputPrompt(record.questions, record.autoResolutionMs)}\nSubmit answers with answer_user_input or the inline card.` + : formatUserInputRecordResult(record), + ), + ]; + + logToolCall(config, { + tool: "request_user_input", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + status: record.status, + delivery, + prompt: toStructuredUserInputRecord(record), + response: record.response, + }, + }; + }, + ); + + registerAppTool( + server, + "get_pending_user_input", + { + title: "Get pending user input", + description: + "Get the currently pending user-input request for a workspace, if one exists.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + prompt: userInputPromptOutputSchema.nullable(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const pending = workspaceStore.getPendingUserInput(workspaceId); + const content = [ + textBlock( + pending + ? formatUserInputPrompt(pending.questions, pending.autoResolutionMs) + : "No pending user-input request for this workspace.", + ), + ]; + + logToolCall(config, { + tool: "get_pending_user_input", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + prompt: pending ? toStructuredUserInputRecord(pending) : null, + }, + }; + }, + ); + + registerAppTool( + server, + "answer_user_input", + { + title: "Answer user input", + description: + "Answer the currently pending user-input request for a workspace and complete the request lifecycle.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + source: z.enum(["tool", "ui"]).optional(), + answers: z.array( + z.object({ + questionId: z.string(), + label: z.string(), + }), + ).min(1), + }, + outputSchema: { + result: z.string(), + prompt: userInputPromptOutputSchema, + response: z.object({ + answers: z.array(userInputAnswerOutputSchema), + summary: z.string(), + source: z.enum(["elicitation", "tool", "ui"]), + action: z.enum(["accept", "decline", "cancel"]), + }), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, answers, source }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const pending = workspaceStore.getPendingUserInput(workspaceId); + if (!pending) { + const response = toolError("No pending user-input request exists for this workspace."); + logFailedToolResponse(config, { + tool: "answer_user_input", + workspaceId, + }, response.content, startedAt); + return response; + } + + validateSubmittedAnswers(pending, answers); + const summary = summarizeSubmittedAnswers(pending, answers); + const completed = workspaceStore.completeUserInput({ + workspaceSessionId: workspaceId, + answers, + summary, + source: source ?? "tool", + }); + const content = [textBlock(formatUserInputRecordResult(completed))]; + + logToolCall(config, { + tool: "answer_user_input", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "answer_user_input", + card: { + workspaceId, + status: completed.status, + summary: { + answered: completed.response?.answers.length ?? 0, + }, + payload: { + content, + }, + userInput: toStructuredUserInputRecord(completed), + }, + }, + structuredContent: { + result: contentText(content), + prompt: toStructuredUserInputRecord(completed), + response: completed.response, + }, + }; + }, + ); + + registerAppTool( + server, + "list_user_input_history", + { + title: "List user input history", + description: + "List recent user-input requests and answers for a workspace.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + limit: z.number().int().positive().max(20).optional(), + }, + outputSchema: { + result: z.string(), + history: z.array(userInputPromptOutputSchema), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, limit }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const history = workspaceStore.listUserInputHistory(workspaceId, limit); + const content = [textBlock(history.length === 0 ? "No user-input history for this workspace." : history.map(formatUserInputRecordResult).join("\n\n"))]; + + logToolCall(config, { + tool: "list_user_input_history", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + history: history.map(toStructuredUserInputRecord), + }, + }; + }, + ); + + registerAppTool( + server, + "update_plan", + { + title: "Update plan", + description: + "Store or replace a workspace-scoped execution plan. Use this when the task benefits from a short checklist with pending, in-progress, and completed steps.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + explanation: z + .string() + .optional() + .describe("Optional short explanation for this plan update."), + plan: z + .array( + z.object({ + step: z.string().describe("Concrete plan step."), + status: z.enum(["pending", "in_progress", "completed"]), + }), + ) + .min(1) + .describe("Current workspace plan. At most one step may be in_progress."), + }, + outputSchema: { + result: z.string(), + explanation: z.string().optional(), + plan: z.array( + z.object({ + step: z.string(), + status: z.enum(["pending", "in_progress", "completed"]), + }), + ), + updatedAt: z.string(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, explanation, plan }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const collaboration = workspaceStore.getCollaborationMode(workspaceId); + if (collaboration.mode === "plan") { + const response = toolError("update_plan is unavailable while the workspace is in plan mode. Use request_user_input, repository exploration, and concrete planning instead."); + logFailedToolResponse(config, { + tool: "update_plan", + workspaceId, + }, response.content, startedAt); + return response; + } + + validatePlanSteps(plan); + const saved = workspaceStore.savePlan({ + workspaceSessionId: workspaceId, + explanation, + steps: plan, + }); + const content = [textBlock(formatPlanResult(saved.steps, saved.explanation))]; + + logToolCall(config, { + tool: "update_plan", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + explanation: saved.explanation, + plan: saved.steps, + updatedAt: saved.updatedAt, + }, + }; + }, + ); + + registerAppTool( + server, + "get_goal", + { + title: "Get goal", + description: + "Get the current workspace-scoped goal if one exists, including objective, status, and timestamps.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + goal: z + .object({ + objective: z.string(), + status: z.enum(["active", "complete", "blocked"]), + tokenBudget: z.number().int().positive().optional(), + createdAt: z.string(), + updatedAt: z.string(), + timeUsedSeconds: z.number().int().nonnegative(), + completedAt: z.string().optional(), + blockedAt: z.string().optional(), + }) + .nullable(), + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = workspaceStore.getGoal(workspaceId); + const content = [textBlock(goal ? formatGoalResult(goal) : "No active or historical goal for this workspace.")]; + + logToolCall(config, { + tool: "get_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + goal: goal + ? { + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + timeUsedSeconds: goal.timeUsedSeconds, + completedAt: goal.completedAt, + blockedAt: goal.blockedAt, + } + : null, + }, + }; + }, + ); + + registerAppTool( + server, + "create_goal", + { + title: "Create goal", + description: + "Create a new workspace-scoped goal. Fails if an active goal already exists for that workspace.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + objective: z.string().describe("Concrete objective to pursue."), + tokenBudget: z + .number() + .int() + .positive() + .optional() + .describe("Optional positive token budget for the goal."), + }, + outputSchema: { + result: z.string(), + goal: z.object({ + objective: z.string(), + status: z.literal("active"), + tokenBudget: z.number().int().positive().optional(), + createdAt: z.string(), + updatedAt: z.string(), + timeUsedSeconds: z.number().int().nonnegative(), + }), + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, objective, tokenBudget }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = workspaceStore.saveGoal({ + workspaceSessionId: workspaceId, + objective, + tokenBudget, + }); + const content = [textBlock(formatGoalResult(goal))]; + + logToolCall(config, { + tool: "create_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + goal: { + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + timeUsedSeconds: goal.timeUsedSeconds, + }, + }, + }; + }, + ); + + registerAppTool( + server, + "update_goal", + { + title: "Update goal", + description: + "Mark the current workspace-scoped goal complete or blocked.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + status: z.enum(["complete", "blocked"]), + }, + outputSchema: { + result: z.string(), + goal: z.object({ + objective: z.string(), + status: z.enum(["complete", "blocked"]), + tokenBudget: z.number().int().positive().optional(), + createdAt: z.string(), + updatedAt: z.string(), + timeUsedSeconds: z.number().int().nonnegative(), + completedAt: z.string().optional(), + blockedAt: z.string().optional(), + }), + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, status }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = workspaceStore.updateGoalStatus({ + workspaceSessionId: workspaceId, + status, + }); + const content = [textBlock(formatGoalResult(goal, status === "complete"))]; + + logToolCall(config, { + tool: "update_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + goal: { + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + timeUsedSeconds: goal.timeUsedSeconds, + completedAt: goal.completedAt, + blockedAt: goal.blockedAt, + }, }, }; }, @@ -1301,7 +2008,9 @@ export function createServer(config = loadConfig()): RunningServer { const reviewCheckpoints = createReviewCheckpointManager(); if (config.logging.trustProxy) { - app.set("trust proxy", true); + // DevSpace sits behind exactly one local reverse proxy: Nginx. + // Do not trust arbitrary forwarded chains from public clients. + app.set("trust proxy", 1); } app.use((req, res, next) => { @@ -1432,7 +2141,7 @@ export function createServer(config = loadConfig()): RunningServer { } }; - const server = createMcpServer(config, workspaces, reviewCheckpoints); + const server = createMcpServer(config, workspaces, reviewCheckpoints, workspaceStore); await server.connect(transport); } else { sendJsonRpcError(res, 400, -32000, "No valid MCP session"); @@ -1454,6 +2163,197 @@ export function createServer(config = loadConfig()): RunningServer { return { app, config }; } +function validatePlanSteps(steps: WorkspacePlanStep[]): void { + const inProgressCount = steps.filter((step) => step.status === "in_progress").length; + if (inProgressCount > 1) { + throw new Error("A plan may have at most one in_progress step."); + } +} + +function validateQuestions(questions: WorkspaceQuestion[]): void { + for (const question of questions) { + if (question.options.length < 2 || question.options.length > 3) { + throw new Error("Each question must have 2 or 3 options."); + } + } +} + +function validateSubmittedAnswers( + pending: WorkspaceUserInputRecord, + answers: WorkspaceUserInputAnswer[], +): void { + const answerMap = new Map(answers.map((answer) => [answer.questionId, answer.label])); + if (answerMap.size !== pending.questions.length) { + throw new Error("Each pending question must have exactly one submitted answer."); + } + + for (const question of pending.questions) { + const selected = answerMap.get(question.id); + if (!selected) { + throw new Error(`Missing answer for question ${question.id}.`); + } + if (!question.options.some((option) => option.label === selected)) { + throw new Error(`Invalid answer label for question ${question.id}: ${selected}`); + } + } +} + +function formatPlanResult(steps: WorkspacePlanStep[], explanation: string | undefined): string { + const summary = steps + .map((step) => `${step.status === "completed" ? "[done]" : step.status === "in_progress" ? "[doing]" : "[todo]"} ${step.step}`) + .join("\n"); + return explanation ? `${explanation}\n${summary}` : summary; +} + +function toElicitationSchema(questions: WorkspaceQuestion[]): { + type: "object"; + properties: Record< + string, + { + type: "string"; + title: string; + description: string; + oneOf: Array<{ + const: string; + title: string; + description: string; + }>; + } + >; + required: string[]; +} { + return { + type: "object", + properties: Object.fromEntries( + questions.map((question) => [ + question.id, + { + type: "string", + title: question.header, + description: question.question, + oneOf: question.options.map((option) => ({ + const: option.label, + title: option.label, + description: option.description, + })), + }, + ]), + ), + required: questions.map((question) => question.id), + }; +} + +function answersFromElicitation( + questions: WorkspaceQuestion[], + content: Record, +): WorkspaceUserInputAnswer[] { + return questions.map((question) => ({ + questionId: question.id, + label: String(content[question.id] ?? ""), + })); +} + +function summarizeAnswers( + questions: WorkspaceQuestion[], + content: Record, +): string { + return questions + .map((question) => `${question.header}: ${String(content[question.id] ?? "")}`) + .join("\n"); +} + +function summarizeSubmittedAnswers( + pending: WorkspaceUserInputRecord, + answers: WorkspaceUserInputAnswer[], +): string { + const answerMap = new Map(answers.map((answer) => [answer.questionId, answer.label])); + return pending.questions + .map((question) => `${question.header}: ${answerMap.get(question.id) ?? ""}`) + .join("\n"); +} + +function formatGoalResult(goal: { + objective: string; + status: "active" | "complete" | "blocked"; + tokenBudget?: number; + createdAt: string; + updatedAt: string; + timeUsedSeconds: number; + completedAt?: string; + blockedAt?: string; +}, includeCompletionNote = false): string { + const lines = [ + `Goal: ${goal.objective}`, + `Status: ${goal.status}`, + goal.tokenBudget !== undefined ? `Token budget: ${goal.tokenBudget}` : undefined, + `Created: ${goal.createdAt}`, + `Updated: ${goal.updatedAt}`, + `Time used seconds: ${goal.timeUsedSeconds}`, + goal.completedAt ? `Completed: ${goal.completedAt}` : undefined, + goal.blockedAt ? `Blocked: ${goal.blockedAt}` : undefined, + includeCompletionNote ? "Report the final budget and usage summary back to the user if the host tracks it." : undefined, + ]; + + return lines.filter(Boolean).join("\n"); +} + +function formatUserInputRecordResult(record: WorkspaceUserInputRecord): string { + const lines = [ + `Status: ${record.status}`, + record.response?.summary, + record.deliveryMode ? `Delivery: ${record.deliveryMode}` : undefined, + record.answeredAt ? `Answered: ${record.answeredAt}` : undefined, + ]; + + if (record.status === "pending") { + lines.unshift(formatUserInputPrompt(record.questions, record.autoResolutionMs)); + } + + return lines.filter(Boolean).join("\n"); +} + +function formatUserInputPrompt( + questions: WorkspaceQuestion[], + autoResolutionMs: number | undefined, +): string { + const lines = questions.flatMap((question) => [ + `${question.header}: ${question.question}`, + ...question.options.map((option) => `- ${option.label}: ${option.description}`), + ]); + if (autoResolutionMs !== undefined) { + lines.push(`Auto resolution: ${autoResolutionMs}ms`); + } + + return lines.join("\n"); +} + +function toStructuredUserInputRecord(record: WorkspaceUserInputRecord): { + questions: WorkspaceQuestion[]; + autoResolutionMs?: number; + status: "pending" | "completed" | "declined" | "cancelled"; + deliveryMode?: "elicitation" | "tool" | "ui"; + createdAt: string; + updatedAt: string; + answeredAt?: string; + response?: { + answers: WorkspaceUserInputAnswer[]; + summary: string; + source: "elicitation" | "tool" | "ui"; + action: "accept" | "decline" | "cancel"; + }; +} { + return { + questions: record.questions, + autoResolutionMs: record.autoResolutionMs, + status: record.status, + deliveryMode: record.deliveryMode, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + answeredAt: record.answeredAt, + response: record.response, + }; +} + async function isMainModule(): Promise { if (!process.argv[1]) return false; diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 89ec3fe..03585c9 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -2,6 +2,10 @@ import type { App } from "@modelcontextprotocol/ext-apps"; export type ToolName = | "open_workspace" + | "request_user_input" + | "get_pending_user_input" + | "answer_user_input" + | "list_user_input_history" | "read_file" | "write_file" | "edit_file" @@ -49,6 +53,32 @@ export interface ToolResultCard { }>; skillDiagnostics?: unknown[]; instruction?: string; + userInput?: { + questions?: Array<{ + header?: string; + id?: string; + question?: string; + options?: Array<{ + label?: string; + description?: string; + }>; + }>; + autoResolutionMs?: number; + status?: string; + deliveryMode?: string; + createdAt?: string; + updatedAt?: string; + answeredAt?: string; + response?: { + answers?: Array<{ + questionId?: string; + label?: string; + }>; + summary?: string; + source?: string; + action?: string; + }; + }; } export interface ToolContent { @@ -67,6 +97,10 @@ export interface ToolPayload { export function isToolName(value: unknown): value is ToolName { return ( value === "open_workspace" || + value === "request_user_input" || + value === "get_pending_user_input" || + value === "answer_user_input" || + value === "list_user_input_history" || value === "read_file" || value === "write_file" || value === "edit_file" || diff --git a/src/ui/user-input-payload.tsx b/src/ui/user-input-payload.tsx new file mode 100644 index 0000000..ece5a76 --- /dev/null +++ b/src/ui/user-input-payload.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { createRoot } from "react-dom/client"; +import type { HostContext, ToolResultCard } from "./card-types.js"; + +interface PayloadRendererOptions { + card: ToolResultCard; + hostContext?: HostContext; + errorMessage?: string | null; + submitAnswers?: (input: { + workspaceId: string; + answers: Array<{ questionId: string; label: string }>; + }) => Promise; +} + +interface MountedPayload { + update(options: PayloadRendererOptions): void; + unmount(): void; +} + +export function mountUserInputPayload( + container: HTMLElement, + options: PayloadRendererOptions, +): MountedPayload { + const root = createRoot(container); + root.render(); + + return { + update(nextOptions) { + root.render(); + }, + unmount() { + root.unmount(); + }, + }; +} + +function UserInputPayload({ + card, + errorMessage = null, + submitAnswers, +}: PayloadRendererOptions) { + const userInput = card.userInput; + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [selected, setSelected] = useState>({}); + + if (errorMessage) return ; + if (!userInput) return ; + + const isPending = userInput.status === "pending"; + + return ( +
+ {(userInput.questions ?? []).map((question) => ( +
+
{question.header}
+
{question.question}
+
+ {(question.options ?? []).map((option) => { + const isSelected = selected[question.id ?? ""] === option.label; + return ( + + ); + })} +
+
+ ))} + + {userInput.response?.summary ? ( +
{userInput.response.summary}
+ ) : null} + {submitError ? : null} + + {isPending ? ( +
+ +
+ ) : null} +
+ ); +} + +function canSubmit( + questions: Array<{ id?: string }>, + selected: Record, +): boolean { + return questions.every((question) => { + if (!question.id) return false; + return typeof selected[question.id] === "string" && selected[question.id].length > 0; + }); +} + +function StatusLine({ + message, + tone = "muted", +}: { + message: string; + tone?: "muted" | "error"; +}) { + return
{message}
; +} diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 7a3fe06..0d4121b 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -349,6 +349,92 @@ body { background: var(--color-background-primary, #101114); } +.user-input-card { + display: grid; + gap: 14px; + padding: 14px; +} + +.user-input-question { + display: grid; + gap: 8px; +} + +.user-input-header { + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-sm-size, 12px); + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.user-input-text { + color: var(--color-text-primary, #f5f5f6); + font-size: var(--font-text-sm-size, 14px); +} + +.user-input-options { + display: grid; + gap: 8px; +} + +.user-input-option { + display: grid; + gap: 4px; + width: 100%; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 80%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-background-primary, #17181c) 42%, transparent); + color: inherit; + cursor: pointer; + text-align: left; +} + +.user-input-option:hover:not(:disabled), +.user-input-option.selected { + border-color: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 28%, var(--color-border-primary, #3a3a40)); + background: color-mix(in srgb, var(--color-background-tertiary, #333338) 72%, transparent); +} + +.user-input-option:disabled { + cursor: default; + opacity: 0.8; +} + +.user-input-option-label { + color: var(--color-text-primary, #f5f5f6); + font-size: var(--font-text-sm-size, 13px); + font-weight: 600; +} + +.user-input-option-description, +.user-input-summary { + color: var(--color-text-secondary, #d6d6dc); + font-size: var(--font-text-sm-size, 13px); +} + +.user-input-actions { + display: flex; + justify-content: flex-end; +} + +.user-input-submit { + min-height: 34px; + padding: 0 12px; + border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 80%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-background-tertiary, #333338) 88%, transparent); + color: var(--color-text-primary, #f5f5f6); + cursor: pointer; + font: inherit; +} + +.user-input-submit:disabled { + cursor: default; + opacity: 0.6; +} + @media (max-width: 520px) { .tool-header { grid-template-columns: 32px minmax(0, 1fr) auto 18px; diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 8acb70a..ca4a14d 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -230,6 +230,36 @@ async function renderPayloadIfNeeded(): Promise { return; } + if (shouldUseUserInputPayload(card)) { + if (currentPayload) { + currentPayload.update({ card, hostContext, errorMessage }); + return; + } + + renderStatus(target, "Loading question flow..."); + + const { mountUserInputPayload } = await import("./user-input-payload.js"); + if (target !== currentPayloadContainer || !expanded || !card) return; + + currentPayload = mountUserInputPayload(target, { + card, + hostContext, + errorMessage, + submitAnswers: async ({ workspaceId, answers }) => { + if (!app) throw new Error("Host app is not connected."); + await app.callServerTool({ + name: "answer_user_input", + arguments: { + workspaceId, + source: "ui", + answers, + }, + }); + }, + }); + return; + } + if (shouldUseHeavyPayload(card)) { if (currentPayload) { currentPayload.update({ card, hostContext, errorMessage }); @@ -286,6 +316,16 @@ function shouldUseHeavyPayload(card: ToolResultCard): boolean { return isReadTool(card.tool) || isEditTool(card.tool) || isWriteTool(card.tool); } +function shouldUseUserInputPayload(card: ToolResultCard): boolean { + return ( + (card.tool === "request_user_input" || + card.tool === "get_pending_user_input" || + card.tool === "answer_user_input" || + card.tool === "list_user_input_history") && + Boolean(card.userInput) + ); +} + function unmountPayload(): void { unmountCurrentPayload(); currentPayload = null; @@ -364,6 +404,10 @@ function renderSummaryBadge(card: ToolResultCard): HTMLElement { return element("span", { className: "badge", text: `${String(summary.lines ?? 0)} lines` }); } + if (card.userInput?.status) { + return element("span", { className: "badge", text: card.userInput.status }); + } + return element("span", { className: "badge", text: `${String(summary.lines ?? 0)} lines` }); } @@ -466,6 +510,14 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { switch (card.tool) { case "open_workspace": return { icon: folderIcon(), title: "Workspace", label, tone: "workspace" }; + case "request_user_input": + return { icon: questionIcon(), title: "Request User Input", label, tone: "directory" }; + case "get_pending_user_input": + return { icon: questionIcon(), title: "Pending User Input", label, tone: "directory" }; + case "answer_user_input": + return { icon: answeredIcon(), title: "Answered User Input", label, tone: "directory" }; + case "list_user_input_history": + return { icon: filesIcon(), title: "User Input History", label, tone: "directory" }; case "read_file": case "read": return { icon: fileIcon(), title: "Read File", label, tone: "read" }; @@ -505,6 +557,9 @@ function getToolLabel(card: ToolResultCard): string { if (isSearchTool(card.tool)) { return String(card.summary?.pattern ?? card.tool); } + if (card.userInput?.status) { + return `status: ${card.userInput.status}`; + } return card.tool; } @@ -582,6 +637,14 @@ function checkCircleIcon(): string { return ''; } +function questionIcon(): string { + return iconSvg(''); +} + +function answeredIcon(): string { + return iconSvg(''); +} + function listIcon(): string { return iconSvg(''); } diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 5fcb99a..57e9c46 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -2,10 +2,21 @@ import { eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { workspaceSessions, + workspacePlans, + workspaceGoals, + workspaceModes, + workspaceUserInputs, type WorkspaceSessionRow, + type WorkspacePlanRow, + type WorkspaceGoalRow, + type WorkspaceModeRow, + type WorkspaceUserInputRow, } from "./db/schema.js"; export type WorkspaceMode = "checkout" | "worktree"; +export type CollaborationMode = "default" | "plan"; +export type UserInputStatus = "pending" | "completed" | "declined" | "cancelled"; +export type UserInputDeliveryMode = "elicitation" | "tool" | "ui"; export interface WorkspaceSession { id: string; @@ -20,6 +31,66 @@ export interface WorkspaceSession { lastUsedAt: string; } +export interface WorkspacePlanStep { + step: string; + status: "pending" | "in_progress" | "completed"; +} + +export interface WorkspacePlan { + workspaceSessionId: string; + explanation?: string; + steps: WorkspacePlanStep[]; + updatedAt: string; +} + +export interface WorkspaceGoal { + workspaceSessionId: string; + objective: string; + status: "active" | "complete" | "blocked"; + tokenBudget?: number; + createdAt: string; + updatedAt: string; + timeUsedSeconds: number; + completedAt?: string; + blockedAt?: string; +} + +export interface WorkspaceQuestionOption { + label: string; + description: string; +} + +export interface WorkspaceQuestion { + header: string; + id: string; + question: string; + options: WorkspaceQuestionOption[]; +} + +export interface WorkspaceUserInputAnswer { + questionId: string; + label: string; +} + +export interface WorkspaceUserInputResponse { + answers: WorkspaceUserInputAnswer[]; + summary: string; + source: UserInputDeliveryMode; + action: "accept" | "decline" | "cancel"; +} + +export interface WorkspaceUserInputRecord { + workspaceSessionId: string; + questions: WorkspaceQuestion[]; + autoResolutionMs?: number; + status: UserInputStatus; + deliveryMode?: UserInputDeliveryMode; + response?: WorkspaceUserInputResponse; + createdAt: string; + updatedAt: string; + answeredAt?: string; +} + export interface WorkspaceStore { createSession(input: { id: string; @@ -32,6 +103,54 @@ export interface WorkspaceStore { }): WorkspaceSession; getSession(id: string): WorkspaceSession | undefined; touchSession(id: string): void; + savePlan(input: { + workspaceSessionId: string; + explanation?: string; + steps: WorkspacePlanStep[]; + }): WorkspacePlan; + getPlan(workspaceSessionId: string): WorkspacePlan | undefined; + saveGoal(input: { + workspaceSessionId: string; + objective: string; + tokenBudget?: number; + }): WorkspaceGoal; + getGoal(workspaceSessionId: string): WorkspaceGoal | undefined; + updateGoalStatus(input: { + workspaceSessionId: string; + status: "complete" | "blocked"; + }): WorkspaceGoal; + setCollaborationMode(input: { + workspaceSessionId: string; + mode: CollaborationMode; + }): { + workspaceSessionId: string; + mode: CollaborationMode; + updatedAt: string; + }; + getCollaborationMode(workspaceSessionId: string): { + workspaceSessionId: string; + mode: CollaborationMode; + updatedAt: string; + }; + createUserInputRequest(input: { + workspaceSessionId: string; + questions: WorkspaceQuestion[]; + autoResolutionMs?: number; + }): WorkspaceUserInputRecord; + completeUserInput(input: { + workspaceSessionId: string; + answers: WorkspaceUserInputAnswer[]; + summary: string; + source: UserInputDeliveryMode; + }): WorkspaceUserInputRecord; + cancelOrDeclineUserInput(input: { + workspaceSessionId: string; + action: "decline" | "cancel"; + source?: UserInputDeliveryMode; + }): WorkspaceUserInputRecord; + getPendingUserInput(workspaceSessionId: string): WorkspaceUserInputRecord | undefined; + getLatestUserInput(workspaceSessionId: string): WorkspaceUserInputRecord | undefined; + listUserInputHistory(workspaceSessionId: string, limit?: number): WorkspaceUserInputRecord[]; close?(): void; } @@ -103,10 +222,340 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + savePlan(input: { + workspaceSessionId: string; + explanation?: string; + steps: WorkspacePlanStep[]; + }): WorkspacePlan { + const updatedAt = new Date().toISOString(); + const plan: WorkspacePlan = { + workspaceSessionId: input.workspaceSessionId, + explanation: input.explanation, + steps: input.steps, + updatedAt, + }; + + this.database.db + .insert(workspacePlans) + .values({ + workspaceSessionId: plan.workspaceSessionId, + explanation: plan.explanation ?? null, + stepsJson: JSON.stringify(plan.steps), + updatedAt: plan.updatedAt, + }) + .onConflictDoUpdate({ + target: workspacePlans.workspaceSessionId, + set: { + explanation: plan.explanation ?? null, + stepsJson: JSON.stringify(plan.steps), + updatedAt: plan.updatedAt, + }, + }) + .run(); + + return plan; + } + + getPlan(workspaceSessionId: string): WorkspacePlan | undefined { + const row = this.database.db + .select() + .from(workspacePlans) + .where(eq(workspacePlans.workspaceSessionId, workspaceSessionId)) + .get(); + + return row ? rowToWorkspacePlan(row) : undefined; + } + + saveGoal(input: { + workspaceSessionId: string; + objective: string; + tokenBudget?: number; + }): WorkspaceGoal { + const existing = this.getGoal(input.workspaceSessionId); + if (existing && existing.status === "active") { + throw new Error("An active goal already exists for this workspace."); + } + + const now = new Date().toISOString(); + const goal: WorkspaceGoal = { + workspaceSessionId: input.workspaceSessionId, + objective: input.objective, + status: "active", + tokenBudget: input.tokenBudget, + createdAt: now, + updatedAt: now, + timeUsedSeconds: 0, + }; + + this.database.db + .insert(workspaceGoals) + .values({ + workspaceSessionId: goal.workspaceSessionId, + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget === undefined ? null : String(goal.tokenBudget), + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + activeSeconds: "0", + completedAt: null, + blockedAt: null, + }) + .onConflictDoUpdate({ + target: workspaceGoals.workspaceSessionId, + set: { + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget === undefined ? null : String(goal.tokenBudget), + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + activeSeconds: "0", + completedAt: null, + blockedAt: null, + }, + }) + .run(); + + return goal; + } + + getGoal(workspaceSessionId: string): WorkspaceGoal | undefined { + const row = this.database.db + .select() + .from(workspaceGoals) + .where(eq(workspaceGoals.workspaceSessionId, workspaceSessionId)) + .get(); + + return row ? rowToWorkspaceGoal(row) : undefined; + } + + updateGoalStatus(input: { + workspaceSessionId: string; + status: "complete" | "blocked"; + }): WorkspaceGoal { + const existing = this.getGoal(input.workspaceSessionId); + if (!existing) { + throw new Error("No goal exists for this workspace."); + } + if (existing.status !== "active") { + throw new Error(`Goal is already ${existing.status}. Create a new goal to continue.`); + } + + const updatedAt = new Date().toISOString(); + const completedAt = input.status === "complete" ? updatedAt : null; + const blockedAt = input.status === "blocked" ? updatedAt : null; + const activeSeconds = calculateGoalActiveSeconds(existing, updatedAt); + + this.database.db + .update(workspaceGoals) + .set({ + status: input.status, + updatedAt, + activeSeconds: String(activeSeconds), + completedAt, + blockedAt, + }) + .where(eq(workspaceGoals.workspaceSessionId, input.workspaceSessionId)) + .run(); + + const updated = this.getGoal(input.workspaceSessionId); + if (!updated) { + throw new Error("Failed to reload goal after update."); + } + + return updated; + } + + setCollaborationMode(input: { + workspaceSessionId: string; + mode: CollaborationMode; + }): { + workspaceSessionId: string; + mode: CollaborationMode; + updatedAt: string; + } { + const updatedAt = new Date().toISOString(); + + this.database.db + .insert(workspaceModes) + .values({ + workspaceSessionId: input.workspaceSessionId, + mode: input.mode, + updatedAt, + }) + .onConflictDoUpdate({ + target: workspaceModes.workspaceSessionId, + set: { + mode: input.mode, + updatedAt, + }, + }) + .run(); + + return { + workspaceSessionId: input.workspaceSessionId, + mode: input.mode, + updatedAt, + }; + } + + getCollaborationMode(workspaceSessionId: string): { + workspaceSessionId: string; + mode: CollaborationMode; + updatedAt: string; + } { + const row = this.database.db + .select() + .from(workspaceModes) + .where(eq(workspaceModes.workspaceSessionId, workspaceSessionId)) + .get(); + + return row + ? rowToWorkspaceMode(row) + : { + workspaceSessionId, + mode: "default", + updatedAt: "", + }; + } + + createUserInputRequest(input: { + workspaceSessionId: string; + questions: WorkspaceQuestion[]; + autoResolutionMs?: number; + }): WorkspaceUserInputRecord { + const existing = this.getPendingUserInput(input.workspaceSessionId); + if (existing) { + throw new Error("A pending user-input request already exists for this workspace."); + } + + const now = new Date().toISOString(); + const record: WorkspaceUserInputRecord = { + workspaceSessionId: input.workspaceSessionId, + questions: input.questions, + autoResolutionMs: input.autoResolutionMs, + status: "pending", + createdAt: now, + updatedAt: now, + }; + + return this.persistUserInputRecord(record); + } + + completeUserInput(input: { + workspaceSessionId: string; + answers: WorkspaceUserInputAnswer[]; + summary: string; + source: UserInputDeliveryMode; + }): WorkspaceUserInputRecord { + const existing = this.getPendingUserInput(input.workspaceSessionId); + if (!existing) { + throw new Error("No pending user-input request exists for this workspace."); + } + + const now = new Date().toISOString(); + return this.persistUserInputRecord({ + ...existing, + status: "completed", + deliveryMode: input.source, + response: { + answers: input.answers, + summary: input.summary, + source: input.source, + action: "accept", + }, + updatedAt: now, + answeredAt: now, + }); + } + + cancelOrDeclineUserInput(input: { + workspaceSessionId: string; + action: "decline" | "cancel"; + source?: UserInputDeliveryMode; + }): WorkspaceUserInputRecord { + const existing = this.getPendingUserInput(input.workspaceSessionId); + if (!existing) { + throw new Error("No pending user-input request exists for this workspace."); + } + + const now = new Date().toISOString(); + const status: UserInputStatus = input.action === "decline" ? "declined" : "cancelled"; + + return this.persistUserInputRecord({ + ...existing, + status, + deliveryMode: input.source, + response: { + answers: [], + summary: input.action === "decline" ? "User declined to answer." : "User cancelled the request.", + source: input.source ?? "elicitation", + action: input.action, + }, + updatedAt: now, + answeredAt: now, + }); + } + + getPendingUserInput(workspaceSessionId: string): WorkspaceUserInputRecord | undefined { + const record = this.getLatestUserInput(workspaceSessionId); + return record?.status === "pending" ? record : undefined; + } + + getLatestUserInput(workspaceSessionId: string): WorkspaceUserInputRecord | undefined { + const row = this.database.db + .select() + .from(workspaceUserInputs) + .where(eq(workspaceUserInputs.workspaceSessionId, workspaceSessionId)) + .get(); + + return row ? rowToWorkspaceUserInput(row) : undefined; + } + + listUserInputHistory(workspaceSessionId: string, limit = 5): WorkspaceUserInputRecord[] { + const record = this.getLatestUserInput(workspaceSessionId); + if (!record) return []; + return [record].slice(0, Math.max(1, limit)); + } + close(): void { this.database.close(); } + private persistUserInputRecord(record: WorkspaceUserInputRecord): WorkspaceUserInputRecord { + this.database.db + .insert(workspaceUserInputs) + .values({ + workspaceSessionId: record.workspaceSessionId, + promptJson: JSON.stringify({ + questions: record.questions, + autoResolutionMs: record.autoResolutionMs, + }), + status: record.status, + deliveryMode: record.deliveryMode ?? null, + responseJson: record.response ? JSON.stringify(record.response) : null, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + answeredAt: record.answeredAt ?? null, + }) + .onConflictDoUpdate({ + target: workspaceUserInputs.workspaceSessionId, + set: { + promptJson: JSON.stringify({ + questions: record.questions, + autoResolutionMs: record.autoResolutionMs, + }), + status: record.status, + deliveryMode: record.deliveryMode ?? null, + responseJson: record.response ? JSON.stringify(record.response) : null, + updatedAt: record.updatedAt, + answeredAt: record.answeredAt ?? null, + }, + }) + .run(); + + return record; + } + private migrate(): void { this.database.sqlite.exec(` create table if not exists workspace_sessions ( @@ -143,6 +592,57 @@ export class SqliteWorkspaceStore implements WorkspaceStore { create index if not exists loaded_agent_files_path_idx on loaded_agent_files(path); + + create table if not exists workspace_plans ( + workspace_session_id text primary key, + explanation text, + steps_json text not null, + updated_at text not null, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create table if not exists workspace_goals ( + workspace_session_id text primary key, + objective text not null, + status text not null default 'active', + token_budget text, + created_at text not null, + updated_at text not null, + active_seconds text not null default '0', + completed_at text, + blocked_at text, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create index if not exists workspace_goals_status_idx + on workspace_goals(status, updated_at desc); + + create table if not exists workspace_modes ( + workspace_session_id text primary key, + mode text not null default 'default', + updated_at text not null, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create table if not exists workspace_user_inputs ( + workspace_session_id text primary key, + prompt_json text not null, + status text not null default 'pending', + delivery_mode text, + response_json text, + created_at text not null, + updated_at text not null, + answered_at text, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); `); this.addColumnIfMissing("workspace_sessions", "mode", "text not null default 'checkout'"); @@ -150,6 +650,10 @@ export class SqliteWorkspaceStore implements WorkspaceStore { this.addColumnIfMissing("workspace_sessions", "base_ref", "text"); this.addColumnIfMissing("workspace_sessions", "base_sha", "text"); this.addColumnIfMissing("workspace_sessions", "managed", "text not null default 'false'"); + this.addColumnIfMissing("workspace_goals", "active_seconds", "text not null default '0'"); + this.addColumnIfMissing("workspace_user_inputs", "delivery_mode", "text"); + this.addColumnIfMissing("workspace_user_inputs", "response_json", "text"); + this.addColumnIfMissing("workspace_user_inputs", "answered_at", "text"); } private addColumnIfMissing(table: string, column: string, definition: string): void { @@ -180,3 +684,138 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { lastUsedAt: row.lastUsedAt, }; } + +function rowToWorkspacePlan(row: WorkspacePlanRow): WorkspacePlan { + return { + workspaceSessionId: row.workspaceSessionId, + explanation: row.explanation ?? undefined, + steps: parsePlanSteps(row.stepsJson), + updatedAt: row.updatedAt, + }; +} + +function rowToWorkspaceGoal(row: WorkspaceGoalRow): WorkspaceGoal { + return { + workspaceSessionId: row.workspaceSessionId, + objective: row.objective, + status: + row.status === "complete" ? "complete" : row.status === "blocked" ? "blocked" : "active", + tokenBudget: row.tokenBudget === null ? undefined : Number(row.tokenBudget), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + timeUsedSeconds: computePersistedGoalTimeUsedSeconds( + row.createdAt, + row.updatedAt, + row.activeSeconds, + row.status, + ), + completedAt: row.completedAt ?? undefined, + blockedAt: row.blockedAt ?? undefined, + }; +} + +function rowToWorkspaceMode(row: WorkspaceModeRow): { + workspaceSessionId: string; + mode: CollaborationMode; + updatedAt: string; +} { + return { + workspaceSessionId: row.workspaceSessionId, + mode: row.mode === "plan" ? "plan" : "default", + updatedAt: row.updatedAt, + }; +} + +function rowToWorkspaceUserInput(row: WorkspaceUserInputRow): WorkspaceUserInputRecord { + const parsedPrompt = JSON.parse(row.promptJson) as { + questions?: WorkspaceQuestion[]; + autoResolutionMs?: number; + }; + const parsedResponse = row.responseJson + ? (JSON.parse(row.responseJson) as WorkspaceUserInputResponse) + : undefined; + + return { + workspaceSessionId: row.workspaceSessionId, + questions: Array.isArray(parsedPrompt.questions) ? parsedPrompt.questions : [], + autoResolutionMs: + typeof parsedPrompt.autoResolutionMs === "number" + ? parsedPrompt.autoResolutionMs + : undefined, + status: normalizeUserInputStatus(row.status), + deliveryMode: normalizeUserInputDeliveryMode(row.deliveryMode), + response: parsedResponse, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + answeredAt: row.answeredAt ?? undefined, + }; +} + +function parsePlanSteps(value: string): WorkspacePlanStep[] { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) return []; + + return parsed.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const step = "step" in item && typeof item.step === "string" ? item.step : undefined; + const status = + "status" in item && typeof item.status === "string" ? item.status : undefined; + if (!step) return []; + if (status !== "pending" && status !== "in_progress" && status !== "completed") return []; + + return [{ step, status }]; + }); +} + +function calculateGoalActiveSeconds(existing: WorkspaceGoal, updatedAt: string): number { + const createdAtMs = Date.parse(existing.createdAt); + const updatedAtMs = Date.parse(updatedAt); + if (!Number.isFinite(createdAtMs) || !Number.isFinite(updatedAtMs)) { + return existing.timeUsedSeconds; + } + + return Math.max(existing.timeUsedSeconds, Math.floor((updatedAtMs - createdAtMs) / 1000)); +} + +function computePersistedGoalTimeUsedSeconds( + createdAt: string, + updatedAt: string, + activeSeconds: string | null, + status: string, +): number { + const persisted = activeSeconds === null ? NaN : Number(activeSeconds); + if (Number.isFinite(persisted)) { + if (status === "active") { + const createdAtMs = Date.parse(createdAt); + const updatedAtMs = Date.now(); + if (Number.isFinite(createdAtMs) && Number.isFinite(updatedAtMs)) { + return Math.max(persisted, Math.floor((updatedAtMs - createdAtMs) / 1000)); + } + } + + return persisted; + } + + const createdAtMs = Date.parse(createdAt); + const endMs = status === "active" ? Date.now() : Date.parse(updatedAt); + if (!Number.isFinite(createdAtMs) || !Number.isFinite(endMs)) return 0; + return Math.max(0, Math.floor((endMs - createdAtMs) / 1000)); +} + +function normalizeUserInputStatus(value: string): UserInputStatus { + if (value === "completed" || value === "declined" || value === "cancelled") { + return value; + } + + return "pending"; +} + +function normalizeUserInputDeliveryMode( + value: string | null, +): UserInputDeliveryMode | undefined { + if (value === "elicitation" || value === "tool" || value === "ui") { + return value; + } + + return undefined; +} diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 554a3da..423a24a 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -90,6 +90,53 @@ try { path: gitRoot, mode: "worktree", }); + const savedPlan = firstStore.savePlan({ + workspaceSessionId: persistentWorkspace.workspace.id, + explanation: "Track work in small steps", + steps: [ + { step: "Inspect repo", status: "completed" }, + { step: "Implement plan tools", status: "in_progress" }, + { step: "Run tests", status: "pending" }, + ], + }); + assert.equal(savedPlan.steps.length, 3); + const savedMode = firstStore.setCollaborationMode({ + workspaceSessionId: persistentWorkspace.workspace.id, + mode: "plan", + }); + assert.equal(savedMode.mode, "plan"); + const savedPrompt = firstStore.createUserInputRequest({ + workspaceSessionId: persistentWorkspace.workspace.id, + questions: [ + { + header: "Mode", + id: "mode_choice", + question: "Which implementation mode should we use?", + options: [ + { label: "Strict", description: "Closer to Codex semantics" }, + { label: "Loose", description: "More permissive for compatibility" }, + ], + }, + ], + autoResolutionMs: 60000, + }); + assert.equal(savedPrompt.status, "pending"); + const savedGoal = firstStore.saveGoal({ + workspaceSessionId: persistentWorkspace.workspace.id, + objective: "Ship Codex-style planning support", + tokenBudget: 1200, + }); + assert.equal(savedGoal.status, "active"); + const blockedGoal = firstStore.updateGoalStatus({ + workspaceSessionId: persistentWorkspace.workspace.id, + status: "blocked", + }); + assert.equal(blockedGoal.status, "blocked"); + const restartedGoal = firstStore.saveGoal({ + workspaceSessionId: persistentWorkspace.workspace.id, + objective: "Retry Codex-style planning support", + }); + assert.equal(restartedGoal.status, "active"); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); @@ -97,6 +144,32 @@ try { const restoredWorkspace = restoredRegistry.getWorkspace(persistentWorkspace.workspace.id); assert.equal(restoredWorkspace.root, root); assert.equal(restoredWorkspace.mode, "checkout"); + const restoredPlan = secondStore.getPlan(persistentWorkspace.workspace.id); + assert.equal(restoredPlan?.explanation, "Track work in small steps"); + assert.equal(restoredPlan?.steps[1]?.status, "in_progress"); + const restoredMode = secondStore.getCollaborationMode(persistentWorkspace.workspace.id); + assert.equal(restoredMode.mode, "plan"); + const restoredPrompt = secondStore.getPendingUserInput(persistentWorkspace.workspace.id); + assert.equal(restoredPrompt?.questions[0]?.id, "mode_choice"); + assert.equal(restoredPrompt?.autoResolutionMs, 60000); + const restoredGoal = secondStore.getGoal(persistentWorkspace.workspace.id); + assert.equal(restoredGoal?.objective, "Retry Codex-style planning support"); + assert.equal(restoredGoal?.status, "active"); + assert.equal(restoredGoal?.tokenBudget, undefined); + assert.equal(typeof restoredGoal?.timeUsedSeconds, "number"); + assert.throws( + () => + secondStore.saveGoal({ + workspaceSessionId: persistentWorkspace.workspace.id, + objective: "Should fail while active goal exists", + }), + /An active goal already exists/, + ); + const completedGoal = secondStore.updateGoalStatus({ + workspaceSessionId: persistentWorkspace.workspace.id, + status: "complete", + }); + assert.equal(completedGoal.status, "complete"); const restoredWorktree = restoredRegistry.getWorkspace(persistentWorktree.workspace.id); assert.equal(restoredWorktree.mode, "worktree"); From 92c923681e3d6007d3487b9b14097b5390725e1f Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 00:10:14 +0800 Subject: [PATCH 14/41] Add bundled skills and workflow command handling --- README.md | 3 + docs/chatgpt-coding-workflow.md | 7 +- package.json | 2 +- skills/core/devspace-workflow/SKILL.md | 67 ++++++ .../devspace-workflow/references/commands.md | 48 +++++ .../devspace-workflow/references/examples.md | 42 ++++ .../devspace-workflow/references/style.md | 22 ++ skills/core/senior-architect-lite/SKILL.md | 44 ++++ .../references/decision-guide.md | 14 ++ .../senior-architect-lite/references/style.md | 6 + skills/core/skill-authoring-lite/SKILL.md | 27 +++ .../references/structure-checklist.md | 10 + src/prompting.test.ts | 39 ++++ src/prompting.ts | 13 +- src/server.ts | 198 +++++++++++++++++- src/skills.test.ts | 83 +++++++- src/skills.ts | 98 ++++++++- src/workspace-commands.test.ts | 52 +++++ src/workspace-commands.ts | 136 ++++++++++++ 19 files changed, 888 insertions(+), 23 deletions(-) create mode 100644 skills/core/devspace-workflow/SKILL.md create mode 100644 skills/core/devspace-workflow/references/commands.md create mode 100644 skills/core/devspace-workflow/references/examples.md create mode 100644 skills/core/devspace-workflow/references/style.md create mode 100644 skills/core/senior-architect-lite/SKILL.md create mode 100644 skills/core/senior-architect-lite/references/decision-guide.md create mode 100644 skills/core/senior-architect-lite/references/style.md create mode 100644 skills/core/skill-authoring-lite/SKILL.md create mode 100644 skills/core/skill-authoring-lite/references/structure-checklist.md create mode 100644 src/prompting.test.ts create mode 100644 src/workspace-commands.test.ts create mode 100644 src/workspace-commands.ts diff --git a/README.md b/README.md index 6b45147..9ffd296 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ DevSpace gives ChatGPT tools to: - discover local agent skills from your skill folders - show tool cards and optional change summaries in ChatGPT Apps-compatible hosts +DevSpace also bundles a small set of built-in workflow and engineering skills in `skills/core/`. +Their structure is inspired by [alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills), which is released under the MIT license. + ## Mental Model DevSpace is remote access to selected local folders. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index c5efc66..c740984 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -81,8 +81,11 @@ Skills are enabled by default for coding-agent workflows. DevSpace discovers skills from: +- built-in DevSpace skills in `skills/core` +- workspace-local skills in `skills/local` +- workspace-installed skills in `skills/installed` - `DEVSPACE_AGENT_DIR`, which defaults to `~/.codex` -- project `.pi/skills` +- legacy project `.pi/skills` - optional paths from `DEVSPACE_SKILL_PATHS` When `open_workspace` returns matching skills, the model should read the @@ -95,6 +98,8 @@ Skill paths may be outside the workspace. DevSpace only permits reading: Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. +The built-in skill layout is inspired by [alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills) under the MIT license, with DevSpace-specific adaptations for MCP workflow commands and local coding tasks. + ## Tool Names Short names are the default: diff --git a/package.json b/package.json index d111dd9..05497e8 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts", + "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/skills/core/devspace-workflow/SKILL.md b/skills/core/devspace-workflow/SKILL.md new file mode 100644 index 0000000..f7eb289 --- /dev/null +++ b/skills/core/devspace-workflow/SKILL.md @@ -0,0 +1,67 @@ +--- +name: devspace-workflow +description: Run concise DevSpace planning, goal, and answer workflows with minimal narration. +license: MIT +metadata: + version: 1.0.0 + author: DevSpace + category: workflow + updated: 2026-06-20 +--- + +# DevSpace Workflow + +## What This Skill Does + +Use this skill when the user drives DevSpace with concise workflow messages such as `/plan`, `/goal`, or compact answers to pending questions. + +## Before Starting + +1. Confirm you already have a `workspaceId` for the active project. +2. If there is no open workspace, call `open_workspace` first. +3. Reuse the same `workspaceId`; do not reopen the same folder unless it stops working or the user explicitly asks. +4. Keep replies short and operational unless the user asks for explanation. + +## Workflow Modes + +### Plan Workflow + +Trigger on messages like: + +- `@dev /plan ...` +- `/plan ...` + +Use `handle_workspace_command` first, then continue the planning workflow in `plan` mode. + +### Goal Workflow + +Trigger on messages like: + +- `@dev /goal ...` +- `/goal ...` + +Use `handle_workspace_command` first, then continue with `create_goal`, `get_goal`, and `update_goal` as needed. + +### Compact Answers + +Trigger when there is pending `request_user_input` state and the user replies with compact text such as `1B,2A`, `1B, 2A`, or `1b 2a`. + +Prefer passing the raw reply through `handle_workspace_command` or `answer_user_input(text)` instead of paraphrasing it. + +## Response Standard + +- Bottom line first. +- Prefer action over explanation. +- For simple workflow steps, return a short status. +- Do not explain slash semantics or MCP mechanics unless the user asks. + +## References + +- [Command Mapping](references/commands.md) +- [Response Style](references/style.md) +- [Examples](references/examples.md) + +## Related Skills + +- `senior-architect-lite` for architecture decisions before or during `/plan` +- `skill-authoring-lite` for creating or refactoring DevSpace skills with the same structure diff --git a/skills/core/devspace-workflow/references/commands.md b/skills/core/devspace-workflow/references/commands.md new file mode 100644 index 0000000..fafd32c --- /dev/null +++ b/skills/core/devspace-workflow/references/commands.md @@ -0,0 +1,48 @@ +# Command Mapping + +## `/plan` + +Inputs: + +- `@dev /plan ...` +- `/plan ...` + +Expected behavior: + +1. Call `handle_workspace_command`. +2. Ensure the workspace moves to `plan` mode. +3. Continue with planning tools and repository exploration. +4. Keep replies short unless the user explicitly asks for detail. + +## `/goal` + +Inputs: + +- `@dev /goal ...` +- `/goal ...` + +Expected behavior: + +1. Call `handle_workspace_command`. +2. Create or continue the workspace goal. +3. Use `get_goal` and `update_goal` for lifecycle management. + +## Compact Answers + +Inputs: + +- `1B,2A` +- `1B, 2A` +- `1b 2a` + +Expected behavior: + +1. Check that a pending `request_user_input` exists. +2. Pass the raw text to `handle_workspace_command` or `answer_user_input(text)`. +3. Do not rewrite the user's answer into prose before submitting it. + +## Failure Handling + +- If there is no open workspace, open one first. +- If there is no pending user input, do not pretend the answer was accepted. +- If the compact answer is incomplete or invalid, return the specific validation error. diff --git a/skills/core/devspace-workflow/references/examples.md b/skills/core/devspace-workflow/references/examples.md new file mode 100644 index 0000000..60e21ea --- /dev/null +++ b/skills/core/devspace-workflow/references/examples.md @@ -0,0 +1,42 @@ +# Examples + +## Planning + +User: + +```text +@dev /plan 修复国家列表节点数量显示 +``` + +Expected behavior: + +- enter plan mode +- continue planning +- keep the immediate status brief + +## Goal + +User: + +```text +@dev /goal 修复国家列表节点数量显示 +``` + +Expected behavior: + +- create or continue the goal +- report a short status + +## Compact Answer + +User: + +```text +1B,2A +``` + +Expected behavior: + +- treat the reply as answer payload +- complete pending user input if valid +- return a short status diff --git a/skills/core/devspace-workflow/references/style.md b/skills/core/devspace-workflow/references/style.md new file mode 100644 index 0000000..e61dfcd --- /dev/null +++ b/skills/core/devspace-workflow/references/style.md @@ -0,0 +1,22 @@ +# Response Style + +## Core Rules + +- Bottom line first. +- Prefer action over explanation. +- Use short status messages for straightforward workflow steps. +- Avoid long tutorials, architecture essays, or repeated background. + +## Good Status Examples + +- `Plan mode on` +- `Goal created` +- `Answer recorded` +- `No workflow command recognized` + +## Avoid + +- Re-explaining what `/plan` means after already acting on it +- Explaining MCP mechanics unless the user asks +- Repeating already-confirmed choices +- Turning one-line results into long summaries diff --git a/skills/core/senior-architect-lite/SKILL.md b/skills/core/senior-architect-lite/SKILL.md new file mode 100644 index 0000000..c33102e --- /dev/null +++ b/skills/core/senior-architect-lite/SKILL.md @@ -0,0 +1,44 @@ +--- +name: senior-architect-lite +description: Evaluate architecture options, tradeoffs, and implementation direction for coding tasks inside DevSpace. +license: MIT +metadata: + version: 1.0.0 + author: DevSpace + category: engineering + updated: 2026-06-20 +--- + +# Senior Architect Lite + +## What This Skill Does + +Use this skill when the task needs architecture guidance, solution framing, design tradeoff analysis, or implementation direction before code changes. + +## Before Starting + +1. Read the relevant code, types, configs, and entrypoints first. +2. Ground recommendations in the current repository, not generic best practices. +3. Keep conclusions concise and decision-oriented. + +## Workflow + +1. Identify the current architecture and constraints. +2. Compare only the viable options. +3. Recommend one approach with clear reasoning. +4. Surface the main risks, compatibility concerns, and validation needs. +5. If the task is still ambiguous, use `request_user_input` for the missing product or tradeoff decision. + +## Deliverable + +Return: + +- recommended approach +- why it fits this codebase +- key implementation implications +- tests or checks needed to validate it + +## References + +- [Decision Guide](references/decision-guide.md) +- [Response Style](references/style.md) diff --git a/skills/core/senior-architect-lite/references/decision-guide.md b/skills/core/senior-architect-lite/references/decision-guide.md new file mode 100644 index 0000000..8039c32 --- /dev/null +++ b/skills/core/senior-architect-lite/references/decision-guide.md @@ -0,0 +1,14 @@ +# Decision Guide + +When comparing options, prefer: + +- the smallest change that still fully satisfies the requirement +- compatibility with existing project patterns +- explicit interfaces and testability +- approaches that reduce follow-up ambiguity for the implementer + +Always name: + +- the recommended option +- the main rejected option +- the key risk or migration concern diff --git a/skills/core/senior-architect-lite/references/style.md b/skills/core/senior-architect-lite/references/style.md new file mode 100644 index 0000000..70b9dec --- /dev/null +++ b/skills/core/senior-architect-lite/references/style.md @@ -0,0 +1,6 @@ +# Response Style + +- Lead with the recommendation. +- Keep tradeoff discussion focused on the actual repo. +- Avoid long framework lectures. +- Do not list speculative future architecture unless asked. diff --git a/skills/core/skill-authoring-lite/SKILL.md b/skills/core/skill-authoring-lite/SKILL.md new file mode 100644 index 0000000..9117015 --- /dev/null +++ b/skills/core/skill-authoring-lite/SKILL.md @@ -0,0 +1,27 @@ +--- +name: skill-authoring-lite +description: Create or refactor DevSpace skills using a structured SKILL.md plus references layout. +license: MIT +metadata: + version: 1.0.0 + author: DevSpace + category: meta + updated: 2026-06-20 +--- + +# Skill Authoring Lite + +## What This Skill Does + +Use this skill when creating or refactoring skills for DevSpace so they stay structured, concise, and reference-driven. + +## Workflow + +1. Keep `SKILL.md` focused on workflow and decisions. +2. Move detail-heavy material into `references/`. +3. Prefer examples and checklists over long prose. +4. Keep the response standard explicit. + +## References + +- [Structure Checklist](references/structure-checklist.md) diff --git a/skills/core/skill-authoring-lite/references/structure-checklist.md b/skills/core/skill-authoring-lite/references/structure-checklist.md new file mode 100644 index 0000000..b2e3977 --- /dev/null +++ b/skills/core/skill-authoring-lite/references/structure-checklist.md @@ -0,0 +1,10 @@ +# Structure Checklist + +A DevSpace skill should usually include: + +- frontmatter with `name`, `description`, `license`, and `metadata` +- a short purpose section +- `Before Starting` +- a compact workflow +- response style guidance +- `references/` for detailed rules and examples diff --git a/src/prompting.test.ts b/src/prompting.test.ts new file mode 100644 index 0000000..d65c41c --- /dev/null +++ b/src/prompting.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { serverInstructions, workspaceInstruction } from "./prompting.js"; +import type { ToolNames } from "./server.js"; + +const toolNames: ToolNames = { + openWorkspace: "open_workspace", + read: "read_file", + write: "write_file", + edit: "edit_file", + grep: "grep_files", + glob: "find_files", + ls: "list_directory", + shell: "run_shell", +}; + +const instructions = serverInstructions( + { + minimalTools: false, + skillsEnabled: false, + widgetsChangesOnly: false, + }, + toolNames, +); + +assert.match(instructions, /Prefer action over explanation\./); +assert.match(instructions, /Keep responses terse and operational\./); +assert.match(instructions, /Do not add long design discussion, repeated background, or speculative future improvements unless the user explicitly asks for them\./); +assert.match(instructions, /When the user sends a short reply such as '1B, 2A', treat it as workflow input and continue instead of explaining the mechanism back to them\./); +assert.match(instructions, /When available skills include a matching workflow skill, read that skill before handling slash-style workspace commands or compact user-input replies\./); +assert.match(instructions, /For concise workflow commands and compact pending-input replies, prefer handle_workspace_command or answer_user_input\(text\) over paraphrasing the user's message\./); + +const planInstruction = workspaceInstruction("plan", false); +assert.match(planInstruction, /ask clarifying questions with request_user_input only when they materially affect the plan/); +assert.match(planInstruction, /Keep the plan decision complete but compact\./); +assert.match(planInstruction, /Do not repeat already-confirmed choices, do not add long design essays/); + +const defaultInstruction = workspaceInstruction("default", false); +assert.match(defaultInstruction, /execute work directly, keep status updates brief/); +assert.match(defaultInstruction, /Do not add unnecessary explanation for straightforward actions or results\./); diff --git a/src/prompting.ts b/src/prompting.ts index 0b9e27b..c6a2e6a 100644 --- a/src/prompting.ts +++ b/src/prompting.ts @@ -29,7 +29,13 @@ export function serverInstructions( const planning = " Use get_collaboration_mode to inspect the workspace collaboration mode. Use set_collaboration_mode to switch between default execution and plan mode. In default mode, use update_plan for a concise execution checklist when helpful. In plan mode, prefer request_user_input, repository exploration, and concrete specification work; do not use update_plan while plan mode is active. When the user asks to pursue a concrete objective across multiple turns, use create_goal to start one goal for that workspace, get_goal to inspect its status, and update_goal to mark it complete or blocked."; - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, shell, plan, and goal tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}${planning} Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; + const style = + " Prefer action over explanation. Keep responses terse and operational. For mode switches, goal updates, confirmations, cancellations, pending answers, and other straightforward workflow steps, return only the necessary status or next action. Do not add long design discussion, repeated background, or speculative future improvements unless the user explicitly asks for them. When the user sends a short reply such as '1B, 2A', treat it as workflow input and continue instead of explaining the mechanism back to them."; + + const commands = + " When available skills include a matching workflow skill, read that skill before handling slash-style workspace commands or compact user-input replies. For concise workflow commands and compact pending-input replies, prefer handle_workspace_command or answer_user_input(text) over paraphrasing the user's message."; + + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, shell, plan, and goal tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}${planning}${style}${commands} Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; } export function workspaceInstruction( @@ -41,9 +47,8 @@ export function workspaceInstruction( : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; if (mode === "plan") { - return `${base} This workspace is currently in plan mode: explore first, ask clarifying questions with request_user_input when needed, and produce a concrete implementation plan before execution. Do not use update_plan while plan mode is active.`; + return `${base} This workspace is currently in plan mode: explore first, ask clarifying questions with request_user_input only when they materially affect the plan, and produce a concrete implementation plan before execution. Keep the plan decision complete but compact. Do not repeat already-confirmed choices, do not add long design essays, and do not use update_plan while plan mode is active.`; } - return `${base} This workspace is currently in default mode: you may execute work normally, and use update_plan when a concise execution checklist would help.`; + return `${base} This workspace is currently in default mode: execute work directly, keep status updates brief, and use update_plan only when a concise execution checklist would help. Do not add unnecessary explanation for straightforward actions or results.`; } - diff --git a/src/server.ts b/src/server.ts index b909e33..b1adebd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -46,6 +46,7 @@ import { contentStats, contentText, toolError, type ToolContent } from "./tool-r import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { serverInstructions as buildServerInstructions, workspaceInstruction } from "./prompting.js"; +import { parseAnswerTextOrThrow, parseWorkspaceCommand } from "./workspace-commands.js"; import type { WorkspaceStore, WorkspacePlanStep, @@ -762,6 +763,187 @@ function createMcpServer( }, ); + registerAppTool( + server, + "handle_workspace_command", + { + title: "Handle workspace command", + description: + "Interpret concise workflow messages such as /plan, /goal, and compact answers for the current workspace.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + message: z.string().describe("Raw user message, such as /plan fix this, /goal ship this, or 1B, 2A."), + }, + outputSchema: { + result: z.string(), + recognized: z.boolean(), + command: z.enum(["plan", "goal", "answer", "none"]), + mode: z.enum(["default", "plan"]).optional(), + goal: z + .object({ + objective: z.string(), + status: z.enum(["active", "complete", "blocked"]), + tokenBudget: z.number().int().positive().optional(), + createdAt: z.string(), + updatedAt: z.string(), + timeUsedSeconds: z.number().int().nonnegative(), + completedAt: z.string().optional(), + blockedAt: z.string().optional(), + }) + .optional(), + prompt: userInputPromptOutputSchema.optional(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, message }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const pending = workspaceStore.getPendingUserInput(workspaceId); + const parsed = parseWorkspaceCommand(message, pending); + + if (!parsed.recognized || parsed.kind === "none") { + const content = [textBlock("No workflow command recognized.")]; + logToolCall(config, { + tool: "handle_workspace_command", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + recognized: false, + command: "none" as const, + }, + }; + } + + if (parsed.kind === "plan") { + const collaboration = workspaceStore.setCollaborationMode({ + workspaceSessionId: workspaceId, + mode: "plan", + }); + const content = [textBlock(parsed.argument ? `Plan mode on\n${parsed.argument}` : "Plan mode on")]; + logToolCall(config, { + tool: "handle_workspace_command", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + recognized: true, + command: "plan" as const, + mode: collaboration.mode, + }, + }; + } + + if (parsed.kind === "goal") { + if (!parsed.argument) { + const response = toolError("Goal command is missing an objective."); + logFailedToolResponse(config, { + tool: "handle_workspace_command", + workspaceId, + }, response.content, startedAt); + return response; + } + + const goal = workspaceStore.saveGoal({ + workspaceSessionId: workspaceId, + objective: parsed.argument, + }); + const content = [textBlock("Goal created")]; + logToolCall(config, { + tool: "handle_workspace_command", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + recognized: true, + command: "goal" as const, + goal: { + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + timeUsedSeconds: goal.timeUsedSeconds, + completedAt: goal.completedAt, + blockedAt: goal.blockedAt, + }, + }, + }; + } + + if (!pending) { + const response = toolError("No pending user-input request exists for this workspace."); + logFailedToolResponse(config, { + tool: "handle_workspace_command", + workspaceId, + }, response.content, startedAt); + return response; + } + + if (parsed.error) { + const response = toolError(parsed.error); + logFailedToolResponse(config, { + tool: "handle_workspace_command", + workspaceId, + }, response.content, startedAt); + return response; + } + + const answers = parsed.answers ?? []; + validateSubmittedAnswers(pending, answers); + const summary = summarizeSubmittedAnswers(pending, answers); + const completed = workspaceStore.completeUserInput({ + workspaceSessionId: workspaceId, + answers, + summary, + source: "tool", + }); + const content = [textBlock("Answer recorded")]; + + logToolCall(config, { + tool: "handle_workspace_command", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "answer_user_input", + card: { + workspaceId, + status: completed.status, + summary: { + answered: completed.response?.answers.length ?? 0, + }, + payload: { content }, + userInput: toStructuredUserInputRecord(completed), + }, + }, + structuredContent: { + result: contentText(content), + recognized: true, + command: "answer" as const, + prompt: toStructuredUserInputRecord(completed), + }, + }; + }, + ); + registerAppTool( server, "request_user_input", @@ -874,7 +1056,7 @@ function createMcpServer( const content = [ textBlock( delivery === "pending_fallback" - ? `${formatUserInputPrompt(record.questions, record.autoResolutionMs)}\nSubmit answers with answer_user_input or the inline card.` + ? `${formatUserInputPrompt(record.questions, record.autoResolutionMs)}\nReply with answers or use the card.` : formatUserInputRecordResult(record), ), ]; @@ -955,6 +1137,7 @@ function createMcpServer( inputSchema: { workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), source: z.enum(["tool", "ui"]).optional(), + text: z.string().optional(), answers: z.array( z.object({ questionId: z.string(), @@ -975,7 +1158,7 @@ function createMcpServer( ...toolWidgetDescriptorMeta(config, "plan"), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, - async ({ workspaceId, answers, source }) => { + async ({ workspaceId, answers, text, source }) => { const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); const pending = workspaceStore.getPendingUserInput(workspaceId); @@ -988,15 +1171,16 @@ function createMcpServer( return response; } - validateSubmittedAnswers(pending, answers); - const summary = summarizeSubmittedAnswers(pending, answers); + const submittedAnswers = text ? parseAnswerTextOrThrow(pending, text) : answers; + validateSubmittedAnswers(pending, submittedAnswers); + const summary = summarizeSubmittedAnswers(pending, submittedAnswers); const completed = workspaceStore.completeUserInput({ workspaceSessionId: workspaceId, - answers, + answers: submittedAnswers, summary, source: source ?? "tool", }); - const content = [textBlock(formatUserInputRecordResult(completed))]; + const content = [textBlock("Answer recorded")]; logToolCall(config, { tool: "answer_user_input", @@ -2286,8 +2470,6 @@ function formatGoalResult(goal: { `Goal: ${goal.objective}`, `Status: ${goal.status}`, goal.tokenBudget !== undefined ? `Token budget: ${goal.tokenBudget}` : undefined, - `Created: ${goal.createdAt}`, - `Updated: ${goal.updatedAt}`, `Time used seconds: ${goal.timeUsedSeconds}`, goal.completedAt ? `Completed: ${goal.completedAt}` : undefined, goal.blockedAt ? `Blocked: ${goal.blockedAt}` : undefined, diff --git a/src/skills.test.ts b/src/skills.test.ts index 1b0ebae..23fce1d 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -15,13 +15,15 @@ try { const projectRoot = join(root, "project"); const agentDir = join(root, "agent"); const explicitSkills = join(root, "explicit-skills"); - await mkdir(join(projectRoot, ".pi", "skills", "project-skill"), { recursive: true }); + await mkdir(join(projectRoot, "skills", "local", "project-skill"), { recursive: true }); + await mkdir(join(projectRoot, "skills", "installed", "installed-skill"), { recursive: true }); + await mkdir(join(projectRoot, ".pi", "skills", "legacy-skill"), { recursive: true }); await mkdir(join(agentDir, "skills", "global-skill"), { recursive: true }); await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); await mkdir(join(explicitSkills, "disabled"), { recursive: true }); await writeFile( - join(projectRoot, ".pi", "skills", "project-skill", "SKILL.md"), + join(projectRoot, "skills", "local", "project-skill", "SKILL.md"), [ "---", "name: project-skill", @@ -31,6 +33,28 @@ try { "# Project Skill", ].join("\n"), ); + await writeFile( + join(projectRoot, "skills", "installed", "installed-skill", "SKILL.md"), + [ + "---", + "name: installed-skill", + "description: Installed skill description.", + "---", + "", + "# Installed Skill", + ].join("\n"), + ); + await writeFile( + join(projectRoot, ".pi", "skills", "legacy-skill", "SKILL.md"), + [ + "---", + "name: legacy-skill", + "description: Legacy skill description.", + "---", + "", + "# Legacy Skill", + ].join("\n"), + ); await writeFile( join(agentDir, "skills", "global-skill", "SKILL.md"), [ @@ -53,6 +77,42 @@ try { "# Duplicate Skill", ].join("\n"), ); + await mkdir(join(projectRoot, "skills", "local", "duplicate-local"), { recursive: true }); + await mkdir(join(projectRoot, "skills", "installed", "duplicate-installed"), { recursive: true }); + await mkdir(join(projectRoot, ".pi", "skills", "duplicate-legacy"), { recursive: true }); + await writeFile( + join(projectRoot, "skills", "local", "duplicate-local", "SKILL.md"), + [ + "---", + "name: duplicate-priority-skill", + "description: Local wins.", + "---", + "", + "# Duplicate Local", + ].join("\n"), + ); + await writeFile( + join(projectRoot, "skills", "installed", "duplicate-installed", "SKILL.md"), + [ + "---", + "name: duplicate-priority-skill", + "description: Installed loses to local.", + "---", + "", + "# Duplicate Installed", + ].join("\n"), + ); + await writeFile( + join(projectRoot, ".pi", "skills", "duplicate-legacy", "SKILL.md"), + [ + "---", + "name: duplicate-priority-skill", + "description: Legacy loses to local and installed.", + "---", + "", + "# Duplicate Legacy", + ].join("\n"), + ); await writeFile( join(explicitSkills, "disabled", "SKILL.md"), [ @@ -85,7 +145,16 @@ try { }); const loaded = loadWorkspaceSkills(config, projectRoot); assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "installed-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "legacy-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "devspace-workflow"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "senior-architect-lite"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "skill-authoring-lite"), true); assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-skill").length, 1); + assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-priority-skill").length, 1); + const duplicatePrioritySkill = loaded.skills.find((skill) => skill.name === "duplicate-priority-skill"); + assert.ok(duplicatePrioritySkill); + assert.match(duplicatePrioritySkill.filePath, /skills\/local\/duplicate-local\/SKILL\.md$/); assert.equal(loaded.skills.some((skill) => skill.name === "hidden-skill"), true); assert.equal(loaded.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true); @@ -105,6 +174,16 @@ try { ?.isSkillFile, false, ); + + const bundledWorkflowSkill = loaded.skills.find((skill) => skill.name === "devspace-workflow"); + assert.ok(bundledWorkflowSkill); + const bundledReferencePath = join(bundledWorkflowSkill.baseDir, "references", "commands.md"); + assert.equal(resolveSkillReadPath(loaded.skills, new Set(), bundledReferencePath), undefined); + assert.equal( + resolveSkillReadPath(loaded.skills, new Set([bundledWorkflowSkill.baseDir]), bundledReferencePath) + ?.isSkillFile, + false, + ); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/src/skills.ts b/src/skills.ts index 20a3520..f36be2d 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; -import { resolve, sep } from "node:path"; +import { dirname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; import { loadSkills, type Skill, @@ -22,12 +23,46 @@ export interface SkillReadResolution { export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSkills { if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; - return loadSkills({ - cwd, - agentDir: config.agentDir, - skillPaths: config.skillPaths, - includeDefaults: true, - }); + const batches = [ + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: [workspaceLocalSkillPath(cwd)], + includeDefaults: false, + }), + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: [workspaceInstalledSkillPath(cwd)], + includeDefaults: false, + }), + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: [bundledSkillPath()], + includeDefaults: false, + }), + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: [workspaceLegacySkillPath(cwd)], + includeDefaults: false, + }), + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: [], + includeDefaults: true, + }), + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: config.skillPaths, + includeDefaults: false, + }), + ]; + + return mergeLoadedSkills(batches); } export function resolveSkillReadPath( @@ -73,3 +108,52 @@ export function formatPathForPrompt(path: string): string { return resolvedPath.split(sep).join("/"); } + +function bundledSkillPath(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", "core"); +} + +function workspaceLocalSkillPath(cwd: string): string { + return resolve(cwd, "skills", "local"); +} + +function workspaceInstalledSkillPath(cwd: string): string { + return resolve(cwd, "skills", "installed"); +} + +function workspaceLegacySkillPath(cwd: string): string { + return resolve(cwd, ".pi", "skills"); +} + +function mergeLoadedSkills(batches: LoadedSkills[]): LoadedSkills { + const winners = new Map(); + const diagnostics: LoadSkillsResult["diagnostics"] = []; + + for (const batch of batches) { + diagnostics.push(...batch.diagnostics); + for (const skill of batch.skills) { + const existing = winners.get(skill.name); + if (!existing) { + winners.set(skill.name, skill); + continue; + } + + diagnostics.push({ + type: "collision", + message: `name "${skill.name}" collision`, + path: skill.filePath, + collision: { + resourceType: "skill", + name: skill.name, + winnerPath: existing.filePath, + loserPath: skill.filePath, + }, + }); + } + } + + return { + skills: Array.from(winners.values()), + diagnostics, + }; +} diff --git a/src/workspace-commands.test.ts b/src/workspace-commands.test.ts new file mode 100644 index 0000000..858dfbc --- /dev/null +++ b/src/workspace-commands.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { + normalizeWorkspaceCommandMessage, + parseAnswerTextOrThrow, + parseWorkspaceCommand, +} from "./workspace-commands.js"; +import type { WorkspaceUserInputRecord } from "./workspace-store.js"; + +const pending: WorkspaceUserInputRecord = { + workspaceSessionId: "ws_test", + questions: [ + { + header: "Count", + id: "count_mode", + question: "How should count work?", + options: [ + { label: "Visible", description: "Visible only" }, + { label: "All", description: "All nodes" }, + ], + }, + { + header: "Placement", + id: "placement", + question: "Where should it show?", + options: [ + { label: "Inline", description: "After name" }, + { label: "Column", description: "Separate column" }, + ], + }, + ], + status: "pending", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +assert.equal(normalizeWorkspaceCommandMessage("@dev /plan fix this"), "/plan fix this"); +assert.equal(parseWorkspaceCommand("/plan fix this").kind, "plan"); +assert.equal(parseWorkspaceCommand("@dev /goal ship this").kind, "goal"); + +const parsedAnswer = parseWorkspaceCommand("1B,2A", pending); +assert.equal(parsedAnswer.kind, "answer"); +assert.equal(parsedAnswer.answers?.[0]?.label, "All"); +assert.equal(parsedAnswer.answers?.[1]?.label, "Inline"); + +const directAnswer = parseAnswerTextOrThrow(pending, "1b 2b"); +assert.deepEqual(directAnswer, [ + { questionId: "count_mode", label: "All" }, + { questionId: "placement", label: "Column" }, +]); + +assert.throws(() => parseAnswerTextOrThrow(pending, "1B"), /Missing answers for question 2/); +assert.throws(() => parseAnswerTextOrThrow(pending, "1C 2A"), /Option C is invalid/); diff --git a/src/workspace-commands.ts b/src/workspace-commands.ts new file mode 100644 index 0000000..4c87886 --- /dev/null +++ b/src/workspace-commands.ts @@ -0,0 +1,136 @@ +import type { + WorkspaceUserInputAnswer, + WorkspaceUserInputRecord, +} from "./workspace-store.js"; + +export type WorkspaceCommandKind = "plan" | "goal" | "answer" | "none"; + +export interface ParsedWorkspaceCommand { + kind: WorkspaceCommandKind; + recognized: boolean; + argument?: string; + answers?: WorkspaceUserInputAnswer[]; + error?: string; +} + +export function normalizeWorkspaceCommandMessage(message: string): string { + return message.trim().replace(/^@\S+\s+/, "").trim(); +} + +export function parseWorkspaceCommand( + message: string, + pending?: WorkspaceUserInputRecord, +): ParsedWorkspaceCommand { + const normalized = normalizeWorkspaceCommandMessage(message); + + const planMatch = normalized.match(/^\/plan(?:\s+([\s\S]+))?$/i); + if (planMatch) { + return { + kind: "plan", + recognized: true, + argument: planMatch[1]?.trim() || undefined, + }; + } + + const goalMatch = normalized.match(/^\/goal(?:\s+([\s\S]+))?$/i); + if (goalMatch) { + return { + kind: "goal", + recognized: true, + argument: goalMatch[1]?.trim() || undefined, + }; + } + + if (pending) { + const parsedAnswers = parseCompactAnswerText(pending, normalized); + if (parsedAnswers.matched) { + return { + kind: "answer", + recognized: true, + answers: parsedAnswers.answers, + error: parsedAnswers.error, + }; + } + } + + return { kind: "none", recognized: false }; +} + +export function parseAnswerTextOrThrow( + pending: WorkspaceUserInputRecord, + text: string, +): WorkspaceUserInputAnswer[] { + const parsed = parseCompactAnswerText(pending, text); + if (parsed.error) { + throw new Error(parsed.error); + } + if (!parsed.matched || !parsed.answers) { + throw new Error("Could not parse the reply as answers for the pending questions."); + } + + return parsed.answers; +} + +export function parseCompactAnswerText( + pending: WorkspaceUserInputRecord, + text: string, +): { + matched: boolean; + answers?: WorkspaceUserInputAnswer[]; + error?: string; +} { + const normalized = normalizeWorkspaceCommandMessage(text).replace(/[,、;]/g, ","); + if (!/\d/.test(normalized)) return { matched: false }; + + const tokens = normalized.split(/[\s,]+/).filter(Boolean); + if (tokens.length === 0) return { matched: false }; + + const parsed = tokens.map((token) => token.match(/^(\d+)([A-Za-z])$/)); + if (parsed.some((match) => !match)) return { matched: false }; + + const seen = new Set(); + const answerMap = new Map(); + + for (const match of parsed) { + if (!match) continue; + const questionNumber = Number(match[1]); + const optionLetter = match[2]?.toUpperCase() ?? ""; + const question = pending.questions[questionNumber - 1]; + if (!question) { + return { matched: true, error: `Question ${questionNumber} does not exist.` }; + } + if (seen.has(questionNumber)) { + return { matched: true, error: `Question ${questionNumber} was answered more than once.` }; + } + + const optionIndex = optionLetter.charCodeAt(0) - 65; + const option = question.options[optionIndex]; + if (!option) { + return { + matched: true, + error: `Option ${optionLetter} is invalid for question ${questionNumber}.`, + }; + } + + seen.add(questionNumber); + answerMap.set(question.id, option.label); + } + + if (seen.size !== pending.questions.length) { + const missing = pending.questions + .map((_, index) => index + 1) + .filter((index) => !seen.has(index)); + return { + matched: true, + error: `Missing answers for question ${missing.join(", ")}.`, + }; + } + + return { + matched: true, + answers: pending.questions.map((question) => ({ + questionId: question.id, + label: answerMap.get(question.id) ?? "", + })), + }; +} From b6ae2d251b5591ae448ad44fae45ce6e68f9b830 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 00:20:46 +0800 Subject: [PATCH 15/41] feat(workspace): add structured patch and push tools --- package.json | 2 +- skills/core/devspace-workflow/SKILL.md | 8 + .../devspace-workflow/references/commands.md | 29 ++++ src/prompting.ts | 2 +- src/server.ts | 155 ++++++++++++++++++ src/workspace-operations.test.ts | 58 +++++++ src/workspace-operations.ts | 145 ++++++++++++++++ 7 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 src/workspace-operations.test.ts create mode 100644 src/workspace-operations.ts diff --git a/package.json b/package.json index 05497e8..d7465e3 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts", + "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspace-operations.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/skills/core/devspace-workflow/SKILL.md b/skills/core/devspace-workflow/SKILL.md index f7eb289..e1f583c 100644 --- a/skills/core/devspace-workflow/SKILL.md +++ b/skills/core/devspace-workflow/SKILL.md @@ -48,6 +48,14 @@ Trigger when there is pending `request_user_input` state and the user replies wi Prefer passing the raw reply through `handle_workspace_command` or `answer_user_input(text)` instead of paraphrasing it. +### Batch File Changes + +When the user asks for broad or multi-file modifications, prefer `apply_workspace_patch` with a unified diff patch instead of shell redirection, heredocs, generated scripts, or ad-hoc write commands. + +### Git Push + +When the user explicitly asks to push commits, prefer `git_push` with structured `remote`, `branch`, and `setUpstream` arguments instead of `bash` with a raw `git push` command. + ## Response Standard - Bottom line first. diff --git a/skills/core/devspace-workflow/references/commands.md b/skills/core/devspace-workflow/references/commands.md index fafd32c..61ba4f4 100644 --- a/skills/core/devspace-workflow/references/commands.md +++ b/skills/core/devspace-workflow/references/commands.md @@ -46,3 +46,32 @@ Expected behavior: - If there is no open workspace, open one first. - If there is no pending user input, do not pretend the answer was accepted. - If the compact answer is incomplete or invalid, return the specific validation error. + +## Batch File Changes + +Inputs: + +- "Modify these files ..." +- "Apply this patch ..." +- "Do the same change across the project ..." + +Expected behavior: + +1. Inspect the files first. +2. Use `apply_workspace_patch` for coordinated multi-file changes. +3. Avoid `bash` redirection, heredocs, `sed -i`, `perl -i`, or generated scripts for project writes. +4. Call `show_changes` after the related change set when available. + +## Git Push + +Inputs: + +- "Push this branch" +- "git push" +- "Push origin main" + +Expected behavior: + +1. Use `git status` or the git inspection tools to verify what will be pushed. +2. Use `git_push` with structured arguments. +3. Do not use generic `bash` for raw `git push` unless `git_push` is unavailable. diff --git a/src/prompting.ts b/src/prompting.ts index c6a2e6a..1484b57 100644 --- a/src/prompting.ts +++ b/src/prompting.ts @@ -35,7 +35,7 @@ export function serverInstructions( const commands = " When available skills include a matching workflow skill, read that skill before handling slash-style workspace commands or compact user-input replies. For concise workflow commands and compact pending-input replies, prefer handle_workspace_command or answer_user_input(text) over paraphrasing the user's message."; - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, shell, plan, and goal tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}${planning}${style}${commands} Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, shell, plan, and goal tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}${planning}${style}${commands} Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, apply_workspace_patch for coordinated multi-file patches, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Use git_push for explicit push requests instead of raw git push through ${toolNames.shell}. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; } export function workspaceInstruction( diff --git a/src/server.ts b/src/server.ts index b1adebd..b13b84c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -47,6 +47,7 @@ import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { serverInstructions as buildServerInstructions, workspaceInstruction } from "./prompting.js"; import { parseAnswerTextOrThrow, parseWorkspaceCommand } from "./workspace-commands.js"; +import { applyWorkspacePatch, gitPush } from "./workspace-operations.js"; import type { WorkspaceStore, WorkspacePlanStep, @@ -105,6 +106,7 @@ type ToolWidgetKind = | "search" | "directory" | "shell" + | "safe_operation" | "show_changes"; interface ToolDefinitionMeta extends Record { @@ -1790,6 +1792,159 @@ function createMcpServer( }, ); + registerAppTool( + server, + "apply_workspace_patch", + { + title: "Apply workspace patch", + description: + `Apply a unified diff patch inside an open workspace. Use this for multi-file or batch file modifications instead of ${toolNames.shell}, shell redirection, heredocs, generated scripts, or ad-hoc write commands. All changed paths must stay inside the workspace root. Call open_workspace first and pass workspaceId.`, + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + patch: z + .string() + .describe("Unified diff patch containing diff --git file headers."), + }, + outputSchema: resultOutputSchema({ + status: z.literal("applied"), + files: z.array(z.string()), + }), + ...toolWidgetDescriptorMeta(config, "safe_operation"), + annotations: WRITE_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, patch }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + + try { + const result = await applyWorkspacePatch({ patch }, { root: workspace.root }); + const stats = countDiffStats(patch); + const message = `Applied patch to ${result.files.length} file${result.files.length === 1 ? "" : "s"} (+${stats.additions} -${stats.removals}).`; + const content = [textBlock(message)]; + + logToolCall(config, { + tool: "apply_workspace_patch", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "apply_workspace_patch", + card: { + workspaceId, + summary: { + files: result.files.length, + ...stats, + }, + payload: { + patch, + stdout: result.stdout, + stderr: result.stderr, + }, + }, + }, + structuredContent: { + status: "applied" as const, + files: result.files, + result: contentText(content), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "apply_workspace_patch", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "git_push", + { + title: "Git push", + description: + "Push the current workspace git branch using structured arguments. Use this instead of running git push through the generic shell tool when the user explicitly asks to push.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + remote: z + .string() + .optional() + .describe("Git remote name. Defaults to origin."), + branch: z + .string() + .optional() + .describe("Branch or refspec to push. Omit to use git's configured default push target."), + setUpstream: z + .boolean() + .optional() + .describe("When true, pass -u to set upstream for the branch."), + }, + outputSchema: resultOutputSchema({ + remote: z.string(), + branch: z.string().optional(), + }), + ...toolWidgetDescriptorMeta(config, "safe_operation"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, + async ({ workspaceId, remote, branch, setUpstream }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + + try { + const result = await gitPush({ remote, branch, setUpstream }, { root: workspace.root }); + const text = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + const content = [textBlock(text || `Pushed to ${result.remote}${result.branch ? ` ${result.branch}` : ""}.`)]; + + logToolCall(config, { + tool: "git_push", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "git_push", + card: { + workspaceId, + summary: { + remote: result.remote, + branch: result.branch, + }, + payload: { + stdout: result.stdout, + stderr: result.stderr, + }, + }, + }, + structuredContent: { + remote: result.remote, + branch: result.branch, + result: contentText(content), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "git_push", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + if (config.widgets === "changes") { registerAppTool( server, diff --git a/src/workspace-operations.test.ts b/src/workspace-operations.test.ts new file mode 100644 index 0000000..c0d2443 --- /dev/null +++ b/src/workspace-operations.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { applyWorkspacePatch, extractPatchPaths, gitPush } from "./workspace-operations.js"; + +const execFileAsync = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "devspace-workspace-ops-test-")); + +try { + await git(root, ["init"]); + await git(root, ["config", "user.email", "devspace@example.com"]); + await git(root, ["config", "user.name", "DevSpace Test"]); + await writeFile(join(root, "README.md"), "hello\n"); + await git(root, ["add", "."]); + await git(root, ["commit", "-m", "Initial commit"]); + + const patch = [ + "diff --git a/README.md b/README.md", + "index ce01362..94954ab 100644", + "--- a/README.md", + "+++ b/README.md", + "@@ -1 +1,2 @@", + " hello", + "+world", + "", + ].join("\n"); + assert.deepEqual(extractPatchPaths(patch), ["README.md"]); + const result = await applyWorkspacePatch({ patch }, { root }); + assert.deepEqual(result.files, ["README.md"]); + assert.equal(await readFile(join(root, "README.md"), "utf8"), "hello\nworld\n"); + + const escapingPatch = [ + "diff --git a/../escape.txt b/../escape.txt", + "--- a/../escape.txt", + "+++ b/../escape.txt", + "@@ -0,0 +1 @@", + "+bad", + "", + ].join("\n"); + await assert.rejects( + () => applyWorkspacePatch({ patch: escapingPatch }, { root }), + /Path is outside allowed roots/, + ); + + await assert.rejects( + () => gitPush({ remote: "--upload-pack=bad" }, { root }), + /Invalid git remote/, + ); +} finally { + await rm(root, { recursive: true, force: true }); +} + +async function git(cwd: string, args: string[]): Promise { + await execFileAsync("git", args, { cwd }); +} diff --git a/src/workspace-operations.ts b/src/workspace-operations.ts new file mode 100644 index 0000000..0f27252 --- /dev/null +++ b/src/workspace-operations.ts @@ -0,0 +1,145 @@ +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { resolveAllowedPath } from "./roots.js"; + +const execFileAsync = promisify(execFile); + +export interface ApplyWorkspacePatchInput { + patch: string; +} + +export interface ApplyWorkspacePatchResult { + stdout: string; + stderr: string; + files: string[]; +} + +export interface GitPushInput { + remote?: string; + branch?: string; + setUpstream?: boolean; +} + +export interface GitPushResult { + stdout: string; + stderr: string; + remote: string; + branch?: string; +} + +export async function applyWorkspacePatch( + input: ApplyWorkspacePatchInput, + context: { root: string }, +): Promise { + const files = extractPatchPaths(input.patch); + if (files.length === 0) { + throw new Error("Patch does not contain any file paths."); + } + + for (const file of files) { + resolveAllowedPath(file, context.root, [context.root]); + } + + const { stdout, stderr } = await spawnWithInput( + "git", + ["apply", "--whitespace=nowarn", "-"], + { + cwd: context.root, + maxBuffer: 10 * 1024 * 1024, + }, + input.patch, + ); + + return { stdout, stderr, files }; +} + +export async function gitPush( + input: GitPushInput, + context: { root: string }, +): Promise { + const remote = input.remote ?? "origin"; + assertGitRefPart(remote, "remote"); + if (input.branch !== undefined) assertGitRefPart(input.branch, "branch"); + + const args = ["push"]; + if (input.setUpstream) args.push("-u"); + args.push(remote); + if (input.branch) args.push(input.branch); + + const { stdout, stderr } = await execFileAsync("git", args, { + cwd: context.root, + maxBuffer: 10 * 1024 * 1024, + }); + + return { stdout, stderr, remote, branch: input.branch }; +} + +export function extractPatchPaths(patch: string): string[] { + const paths = new Set(); + + for (const line of patch.split(/\r?\n/)) { + const match = line.match(/^diff --git a\/(.+) b\/(.+)$/); + if (!match) continue; + + const oldPath = normalizePatchPath(match[1]); + const newPath = normalizePatchPath(match[2]); + if (oldPath) paths.add(oldPath); + if (newPath) paths.add(newPath); + } + + return Array.from(paths); +} + +function normalizePatchPath(path: string | undefined): string | undefined { + if (!path || path === "/dev/null") return undefined; + return path; +} + +function assertGitRefPart(value: string, name: string): void { + if (!/^[A-Za-z0-9._/-]+$/.test(value) || value.includes("..") || value.startsWith("-")) { + throw new Error(`Invalid git ${name}.`); + } +} + +function spawnWithInput( + command: string, + args: string[], + options: { cwd: string; maxBuffer: number }, + input: string, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + if (stdout.length + stderr.length > options.maxBuffer) { + child.kill(); + reject(new Error("Command output exceeded maxBuffer.")); + } + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + if (stdout.length + stderr.length > options.maxBuffer) { + child.kill(); + reject(new Error("Command output exceeded maxBuffer.")); + } + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(stderr.trim() || `${command} exited with status ${code}`)); + } + }); + + child.stdin.end(input); + }); +} From 262db6f7b711cc7928b4567f1852e636fc75cd3d Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 01:05:24 +0800 Subject: [PATCH 16/41] Remove legacy .pi skills compatibility --- docs/chatgpt-coding-workflow.md | 1 - docs/gotchas.md | 4 +++- src/skills.test.ts | 25 ------------------------- src/skills.ts | 10 ---------- 4 files changed, 3 insertions(+), 37 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index c740984..47af611 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -85,7 +85,6 @@ DevSpace discovers skills from: - workspace-local skills in `skills/local` - workspace-installed skills in `skills/installed` - `DEVSPACE_AGENT_DIR`, which defaults to `~/.codex` -- legacy project `.pi/skills` - optional paths from `DEVSPACE_SKILL_PATHS` When `open_workspace` returns matching skills, the model should read the diff --git a/docs/gotchas.md b/docs/gotchas.md index c5099dc..8261c2d 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -196,7 +196,9 @@ DEVSPACE_SKILLS=1 npx @waishnav/devspace serve DevSpace looks in: - `DEVSPACE_AGENT_DIR`, defaulting to `~/.codex` -- project `.pi/skills` +- `skills/local` +- `skills/installed` +- `skills/core` - `DEVSPACE_SKILL_PATHS` If a skill appears in `open_workspace`, the model must read that skill's diff --git a/src/skills.test.ts b/src/skills.test.ts index 23fce1d..c08e311 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -17,7 +17,6 @@ try { const explicitSkills = join(root, "explicit-skills"); await mkdir(join(projectRoot, "skills", "local", "project-skill"), { recursive: true }); await mkdir(join(projectRoot, "skills", "installed", "installed-skill"), { recursive: true }); - await mkdir(join(projectRoot, ".pi", "skills", "legacy-skill"), { recursive: true }); await mkdir(join(agentDir, "skills", "global-skill"), { recursive: true }); await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); await mkdir(join(explicitSkills, "disabled"), { recursive: true }); @@ -44,17 +43,6 @@ try { "# Installed Skill", ].join("\n"), ); - await writeFile( - join(projectRoot, ".pi", "skills", "legacy-skill", "SKILL.md"), - [ - "---", - "name: legacy-skill", - "description: Legacy skill description.", - "---", - "", - "# Legacy Skill", - ].join("\n"), - ); await writeFile( join(agentDir, "skills", "global-skill", "SKILL.md"), [ @@ -79,7 +67,6 @@ try { ); await mkdir(join(projectRoot, "skills", "local", "duplicate-local"), { recursive: true }); await mkdir(join(projectRoot, "skills", "installed", "duplicate-installed"), { recursive: true }); - await mkdir(join(projectRoot, ".pi", "skills", "duplicate-legacy"), { recursive: true }); await writeFile( join(projectRoot, "skills", "local", "duplicate-local", "SKILL.md"), [ @@ -102,17 +89,6 @@ try { "# Duplicate Installed", ].join("\n"), ); - await writeFile( - join(projectRoot, ".pi", "skills", "duplicate-legacy", "SKILL.md"), - [ - "---", - "name: duplicate-priority-skill", - "description: Legacy loses to local and installed.", - "---", - "", - "# Duplicate Legacy", - ].join("\n"), - ); await writeFile( join(explicitSkills, "disabled", "SKILL.md"), [ @@ -146,7 +122,6 @@ try { const loaded = loadWorkspaceSkills(config, projectRoot); assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "installed-skill"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "legacy-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "devspace-workflow"), true); assert.equal(loaded.skills.some((skill) => skill.name === "senior-architect-lite"), true); assert.equal(loaded.skills.some((skill) => skill.name === "skill-authoring-lite"), true); diff --git a/src/skills.ts b/src/skills.ts index f36be2d..df7f3b9 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -42,12 +42,6 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk skillPaths: [bundledSkillPath()], includeDefaults: false, }), - loadSkills({ - cwd, - agentDir: config.agentDir, - skillPaths: [workspaceLegacySkillPath(cwd)], - includeDefaults: false, - }), loadSkills({ cwd, agentDir: config.agentDir, @@ -121,10 +115,6 @@ function workspaceInstalledSkillPath(cwd: string): string { return resolve(cwd, "skills", "installed"); } -function workspaceLegacySkillPath(cwd: string): string { - return resolve(cwd, ".pi", "skills"); -} - function mergeLoadedSkills(batches: LoadedSkills[]): LoadedSkills { const winners = new Map(); const diagnostics: LoadSkillsResult["diagnostics"] = []; From 5a0e47903b378dbd244e59afb1baf51d1f2423f3 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 14:29:31 +0800 Subject: [PATCH 17/41] feat: add local config and service management --- .gitignore | 1 + README.md | 93 ++++++- docs/chatgpt-coding-workflow.md | 14 + docs/configuration.md | 111 +++++++- docs/gotchas.md | 6 + package.json | 2 +- src/cli-skills.test.ts | 19 ++ src/cli.ts | 460 +++++++++++++++++++++++++++++++- src/config-operations.test.ts | 113 ++++++++ src/config-operations.ts | 301 +++++++++++++++++++++ src/config.test.ts | 7 + src/config.ts | 33 ++- src/db/client.ts | 15 +- src/db/schema.ts | 59 +++- src/oauth-provider.test.ts | 92 +++++-- src/oauth-provider.ts | 329 +++-------------------- src/oauth-store.ts | 388 +++++++++++++++++++++++++++ src/server.ts | 280 ++++++++++++++++++- src/service.test.ts | 69 +++++ src/service/manager.ts | 451 +++++++++++++++++++++++++++++++ src/service/runner.ts | 40 +++ src/service/templates.ts | 130 +++++++++ src/service/types.ts | 54 ++++ src/skill-manager.test.ts | 176 ++++++++++++ src/skill-manager.ts | 316 ++++++++++++++++++++++ src/ui/card-types.ts | 6 + src/ui/workspace-app.tsx | 6 + src/user-config.ts | 154 ++++++++++- src/workspaces.test.ts | 29 ++ src/workspaces.ts | 25 +- 30 files changed, 3426 insertions(+), 353 deletions(-) create mode 100644 src/cli-skills.test.ts create mode 100644 src/config-operations.test.ts create mode 100644 src/config-operations.ts create mode 100644 src/oauth-store.ts create mode 100644 src/service.test.ts create mode 100644 src/service/manager.ts create mode 100644 src/service/runner.ts create mode 100644 src/service/templates.ts create mode 100644 src/service/types.ts create mode 100644 src/skill-manager.test.ts create mode 100644 src/skill-manager.ts diff --git a/.gitignore b/.gitignore index 5e4384b..db0ed7d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ releases/ .env *.log WORKING/ +skills/installed/ diff --git a/README.md b/README.md index 9ffd296..f86dd3f 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,78 @@ Most users should connect through a public HTTPS tunnel: https://your-tunnel-host.example.com/mcp ``` +## Configuration Management + +Update the local server config with short commands: + +```bash +devspace config show +devspace config port 7676 +devspace config host 127.0.0.1 +devspace config domain devspace.example.com +devspace config key +``` + +Configuration changes are saved immediately. If a managed DevSpace background +service is currently running, DevSpace automatically restarts it so the new +settings take effect right away. + +`devspace config show` displays the effective bind host, port, MCP path, public +URL, workspace list, service state, and a masked access key. If the current +Owner password comes from `DEVSPACE_OAUTH_OWNER_TOKEN`, DevSpace masks and shows +that effective value instead of reporting it as missing. + +`devspace config key` rotates the existing DevSpace Owner password, clears saved +OAuth approvals and tokens, and forces connected clients to reauthorize. + +## Workspace Management + +Persist the workspace roots DevSpace is allowed to open: + +```bash +devspace workspace add ~/workspace/project-a --default +devspace workspace add ~/workspace/project-b +devspace workspace list +devspace workspace default ~/workspace/project-b +devspace workspace remove ~/workspace/project-a +``` + +You can also allow extra paths for one run only: + +```bash +devspace serve --add-dir ~/scratch/project-c --workspace ~/workspace/project-b +``` + +Workspace paths are the authorization boundary for DevSpace and MCP file tools. +Adding a workspace authorizes only that path and its children. + +If you start DevSpace without any configured workspaces or `DEVSPACE_ALLOWED_ROOTS`, +DevSpace now fails closed: the server can start, but workspace access is denied +until you explicitly add an allowed path. + +## Service Management + +DevSpace service management only manages DevSpace itself. It does not manage +arbitrary system services. + +```bash +devspace service install --autostart +devspace service status +devspace service logs --tail 100 +devspace service restart +devspace service doctor +``` + +Platform behavior: + +- macOS uses a per-user LaunchAgent. +- Linux and Ubuntu use a per-user systemd service when available. +- Windows uses Task Scheduler. +- WSL prefers user systemd and otherwise reports a Windows Task Scheduler fallback. + +DevSpace does not automatically configure DNS, reverse proxies, TLS +certificates, or firewall rules. + ## What ChatGPT Can Do Once connected, ChatGPT can open one of your approved project folders as a @@ -101,6 +173,24 @@ DevSpace gives ChatGPT tools to: DevSpace also bundles a small set of built-in workflow and engineering skills in `skills/core/`. Their structure is inspired by [alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills), which is released under the MIT license. +Project skill directories are split by purpose: + +- `skills/core`: built-in DevSpace skills, committed with DevSpace +- `skills/local`: project-defined skills you want to keep in version control +- `skills/installed`: user-installed project skills, ignored by git by default + +Manage installed skills with: + +```bash +devspace skills install --repo openai/skills --path skills/.curated/research +devspace skills list +devspace skills remove research + +devspace skills install -g --repo openai/skills --path skills/.curated/research +devspace skills list -g +devspace skills remove -g research +``` + ## Mental Model DevSpace is remote access to selected local folders. @@ -120,7 +210,8 @@ For a normal ChatGPT coding session: ## Platform Support DevSpace supports Linux, macOS, and Windows environments with a Bash-compatible -shell. +shell for the main CLI, and supports native per-user service installation on +macOS, Linux, Windows, and WSL. | Platform | Status | Notes | | ------------------------------------------------- | ----------------- | ---------------------------------------------- | diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 47af611..b4be2e0 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -87,6 +87,20 @@ DevSpace discovers skills from: - `DEVSPACE_AGENT_DIR`, which defaults to `~/.codex` - optional paths from `DEVSPACE_SKILL_PATHS` +User-installed project skills can be managed through DevSpace itself: + +```text +请使用 DevSpace 打开当前项目,然后调用 install_skill,把 GitHub 仓库 openai/skills 里的 skills/.curated/research 安装到当前 workspace。 +``` + +```text +请调用 list_installed_skills,列出当前 workspace 的 installed skills。 +``` + +```text +请调用 remove_skill,删除当前 workspace 里名为 research 的 installed skill。 +``` + When `open_workspace` returns matching skills, the model should read the advertised `SKILL.md` before following that skill. diff --git a/docs/configuration.md b/docs/configuration.md index 0101a3d..a27d6bb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,10 +22,87 @@ DEVSPACE_CONFIG_DIR=/path/to/config npx @waishnav/devspace serve npx @waishnav/devspace init npx @waishnav/devspace serve npx @waishnav/devspace doctor +npx @waishnav/devspace config show +npx @waishnav/devspace config port 7676 +npx @waishnav/devspace config host 127.0.0.1 +npx @waishnav/devspace config domain devspace.example.com +npx @waishnav/devspace config key +npx @waishnav/devspace workspace add ~/workspace/project-a --default +npx @waishnav/devspace workspace list +npx @waishnav/devspace service install --autostart +npx @waishnav/devspace service status +npx @waishnav/devspace service logs --tail 100 npx @waishnav/devspace config get -npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com ``` +## Configuration Management + +The primary config commands are: + +```bash +devspace config show +devspace config port 7676 +devspace config host 127.0.0.1 +devspace config domain devspace.example.com +devspace config key +``` + +`config port`, `config host`, `config domain`, and `config key` save the new +value immediately. If a managed DevSpace background service is currently +running, DevSpace automatically restarts it. + +`config key` rotates the existing Owner password stored in `auth.json`, +invalidates saved OAuth approvals and tokens, and requires clients to +reauthorize. + +`config show` reports the effective runtime values. Access keys are always +masked. If the active Owner password comes from `DEVSPACE_OAUTH_OWNER_TOKEN`, +DevSpace masks and shows that effective value. + +## Workspace Management + +Persisted workspace roots replace the old one-shot “roots only at init” flow: + +```bash +devspace workspace add ~/workspace/project-a --default +devspace workspace add ~/workspace/project-b +devspace workspace list +devspace workspace remove ~/workspace/project-a +devspace workspace clear-default +``` + +Use temporary workspace overrides for one run: + +```bash +devspace serve --add-dir ~/scratch/project-c --workspace ~/workspace/project-b +``` + +These workspace paths define the authorization boundary for DevSpace file tools. +If no workspace is configured and `DEVSPACE_ALLOWED_ROOTS` is unset, DevSpace +starts in a safe blocked state with no authorized workspace roots. + +## Service Management + +DevSpace only manages its own background service: + +```bash +devspace service install --autostart +devspace service status +devspace service restart +devspace service logs --tail 200 +devspace service doctor +``` + +Platform behavior: + +- Linux and Ubuntu use `systemctl --user` when user systemd is available. +- macOS uses a per-user LaunchAgent. +- Windows uses Task Scheduler. +- WSL uses user systemd when available and otherwise reports a Task Scheduler fallback. + +DevSpace never auto-configures DNS, reverse proxies, TLS certificates, or +firewall rules. + ## Core Environment Variables | Variable | Purpose | @@ -34,11 +111,17 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com | `PORT` | Local port. Defaults to `7676`. | | `DEVSPACE_ALLOWED_ROOTS` | Comma-separated local roots that workspaces may open. | | `DEVSPACE_PUBLIC_BASE_URL` | Public origin for the server, without `/mcp`. | +| `DEVSPACE_MCP_PATH` | Optional MCP path override. Defaults to `/mcp`. | | `DEVSPACE_TUNNEL` | Optional automatic tunnel mode. Currently supports `cloudflare` when explicitly enabled. | | `DEVSPACE_ALLOWED_HOSTS` | Optional Host header allowlist override. | | `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | | `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | | `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | +| `DEVSPACE_SESSION_WORKSPACE` | Temporary default workspace for the current `serve` run. | + +When `DEVSPACE_ALLOWED_ROOTS` is omitted, DevSpace does not fall back to the +current working directory anymore. You must explicitly configure allowed roots +through `devspace workspace add ...` or this environment variable. ## OAuth @@ -52,9 +135,11 @@ DevSpace uses a single-user OAuth approval flow. | `DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS` | `chatgpt.com,localhost,127.0.0.1` | | `DEVSPACE_OAUTH_STATE_PATH` | `$DEVSPACE_STATE_DIR/oauth.json` | -Registered OAuth clients and refresh token hashes are persisted in -`$DEVSPACE_STATE_DIR/oauth.json` by default. Access tokens and authorization -codes remain in memory only. +Registered OAuth clients, token hashes, authorization code hashes, and approved +consents are persisted in SQLite at `$DEVSPACE_STATE_DIR/devspace.sqlite` by +default. `DEVSPACE_OAUTH_STATE_PATH` is kept as the legacy JSON state import +path; when an existing JSON file is present, DevSpace imports compatible clients, +token hashes, and consents into SQLite without storing raw tokens. MCP clients discover metadata from: @@ -121,6 +206,24 @@ Use `--no-tunnel` to override configured tunnel mode for one run. | `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`. | | `DEVSPACE_SKILL_PATHS` | Optional comma-separated skill directories. | +Project skill layout: + +- `skills/core`: built-in DevSpace skills +- `skills/local`: project skills meant to be committed +- `skills/installed`: user-installed project skills, typically git-ignored + +Manage installed skills with: + +```bash +devspace skills install --repo openai/skills --path skills/.curated/research +devspace skills list +devspace skills remove research + +devspace skills install -g --repo openai/skills --path skills/.curated/research +devspace skills list -g +devspace skills remove -g research +``` + Example: ```bash diff --git a/docs/gotchas.md b/docs/gotchas.md index 8261c2d..d7c7ea5 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -201,6 +201,12 @@ DevSpace looks in: - `skills/core` - `DEVSPACE_SKILL_PATHS` +Recommended meaning: + +- `skills/core`: built-in DevSpace skills +- `skills/local`: project-defined skills you want to commit +- `skills/installed`: user-installed project skills that should stay git-ignored + If a skill appears in `open_workspace`, the model must read that skill's `SKILL.md` before reading other files inside the skill directory. diff --git a/package.json b/package.json index d7465e3..295f492 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspace-operations.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts", + "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/config-operations.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/skill-manager.test.ts && node --import tsx src/cli-skills.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspace-operations.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts && node --import tsx src/service.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli-skills.test.ts b/src/cli-skills.test.ts new file mode 100644 index 0000000..8eda9b9 --- /dev/null +++ b/src/cli-skills.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "devspace-cli-skills-test-")); + +try { + const help = execFileSync("node", ["--import", "tsx", "src/cli.ts", "help"], { + cwd: "/Users/thinkook/workspace/open_source/devspace", + encoding: "utf8", + }); + assert.match(help, /devspace skills install/); + assert.match(help, /devspace skills list -g/); + assert.match(help, /devspace skills remove -g/); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/src/cli.ts b/src/cli.ts index be0242a..f9f918f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,25 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { stdin as input, stdout as output } from "node:process"; +import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; import * as prompts from "@clack/prompts"; import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; import { resolveTunnelMode, startQuickTunnel, type QuickTunnel } from "./cloudflare-tunnel.js"; import { loadConfig } from "./config.js"; +import { + addWorkspace, + buildConfigShowResult, + clearDefaultWorkspace, + listWorkspaces, + removeWorkspace, + resetConfigKey, + setConfigDomain, + setConfigHost, + setConfigPort, + setDefaultWorkspace, +} from "./config-operations.js"; import { generateOwnerToken, loadDevspaceFiles, @@ -15,10 +28,20 @@ import { type DevspaceUserConfig, } from "./user-config.js"; import { expandHomePath } from "./roots.js"; - -type Command = "serve" | "init" | "doctor" | "config" | "help"; +import { createServiceManager } from "./service/manager.js"; +import { + installSkill, + listInstalledSkills, + removeInstalledSkill, + resolveWorkspaceRoot, + type SkillInstallSource, + type SkillScope, +} from "./skill-manager.js"; + +type Command = "serve" | "service-run" | "init" | "doctor" | "config" | "workspace" | "service" | "skills" | "help"; const require = createRequire(import.meta.url); const SUPPORTED_NODE_RANGE = ">=20.12 <27"; +const CLI_ENTRYPOINT = fileURLToPath(import.meta.url); async function main(argv: string[]): Promise { assertSupportedNode(); @@ -31,6 +54,9 @@ async function main(argv: string[]): Promise { await ensureConfigured(); await serve(args); return; + case "service-run": + await serve([]); + return; case "init": await runInit({ force: args.includes("--force") }); return; @@ -38,7 +64,16 @@ async function main(argv: string[]): Promise { await runDoctor(); return; case "config": - runConfigCommand(args); + await runConfigCommand(args); + return; + case "workspace": + await runWorkspaceCommand(args); + return; + case "service": + await runServiceCommand(args); + return; + case "skills": + await runSkillsCommand(args); return; case "help": printHelp(); @@ -48,7 +83,7 @@ async function main(argv: string[]): Promise { function normalizeCommand(command: string | undefined): Command { if (!command || command === "serve" || command === "start") return "serve"; - if (command === "init" || command === "doctor" || command === "config") return command; + if (command === "service-run" || command === "init" || command === "doctor" || command === "config" || command === "workspace" || command === "service" || command === "skills") return command; if (command === "help" || command === "--help" || command === "-h") return "help"; throw new Error(`Unknown command: ${command}`); } @@ -141,8 +176,8 @@ async function runInit({ force }: { force: boolean }): Promise { const lines = [ `Config: ${configPath}`, `Auth: ${authPath}`, - `Local MCP URL: http://${config.host}:${config.port}/mcp`, - ...(publicBaseUrl ? [`Public MCP URL: ${publicBaseUrl}/mcp`] : []), + `Local MCP URL: http://${config.host}:${config.port}${config.server?.mcpPath ?? "/mcp"}`, + ...(publicBaseUrl ? [`Public MCP URL: ${new URL(config.server?.mcpPath ?? "/mcp", publicBaseUrl).toString()}`] : []), ]; prompts.note(lines.join("\n"), "DevSpace configured"); prompts.note( @@ -177,6 +212,14 @@ async function serve(args: string[] = []): Promise { ); } + const sessionArgs = extractServeArgs(args); + if (sessionArgs.additionalRoots.length > 0) { + process.env.DEVSPACE_ALLOWED_ROOTS = mergeAllowedRoots(sessionArgs.additionalRoots); + } + if (sessionArgs.workspace) { + process.env.DEVSPACE_SESSION_WORKSPACE = sessionArgs.workspace; + } + let tunnel: QuickTunnel | undefined; const configuredTunnel = resolveTunnelMode({ args, @@ -186,7 +229,7 @@ async function serve(args: string[] = []): Promise { if (configuredTunnel === "cloudflare") { const files = loadDevspaceFiles(); const host = process.env.HOST ?? files.config.host ?? "127.0.0.1"; - const port = Number(process.env.PORT ?? files.config.port ?? 7676); + const port = Number(process.env.PORT ?? files.config.server?.port ?? files.config.port ?? 7676); const tunnelHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host; const localBaseUrl = `http://${tunnelHost}:${port}`; @@ -198,9 +241,9 @@ async function serve(args: string[] = []): Promise { const config = loadConfig(); const { app } = createServer(config); const httpServer = app.listen(config.port, config.host, () => { - console.log(`devspace listening on http://${config.host}:${config.port}/mcp`); + console.log(`devspace listening on http://${config.host}:${config.port}${config.mcpPath}`); console.log(`public base url: ${config.publicBaseUrl}`); - console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); + console.log(`allowed roots: ${config.allowedRoots.join(", ") || "(none configured - workspace access will be denied until you add one)"}`); console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`); if (config.allowedHosts.includes("*")) { console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); @@ -234,16 +277,19 @@ async function runDoctor(): Promise { try { const config = loadConfig(); - console.log(`Local MCP URL: http://${config.host}:${config.port}/mcp`); - console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`); - console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); + console.log(`Local MCP URL: http://${config.host}:${config.port}${config.mcpPath}`); + console.log(`Public MCP URL: ${new URL(config.mcpPath, config.publicBaseUrl).toString()}`); + console.log(`Allowed roots: ${config.allowedRoots.join(", ") || "(none configured)"}`); + if (config.allowedRoots.length === 0) { + console.log("Workspace access: blocked until you add a workspace with `devspace workspace add ` or set DEVSPACE_ALLOWED_ROOTS."); + } console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); } catch (error) { console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`); } } -function runConfigCommand(args: string[]): void { +async function runConfigCommand(args: string[]): Promise { const [subcommand, key, ...rest] = args; const files = loadDevspaceFiles(); @@ -252,6 +298,53 @@ function runConfigCommand(args: string[]): void { return; } + if (subcommand === "show") { + const show = await buildConfigShowResult(CLI_ENTRYPOINT); + if (args.includes("--json")) { + console.log(JSON.stringify(show, null, 2)); + return; + } + + console.log([ + `bind host: ${show.host}`, + `port: ${show.port}`, + `MCP path: ${show.mcpPath}`, + `public URL: ${show.publicUrl}`, + `workspaces: ${show.workspaces.join(", ") || "(none)"}`, + `default workspace: ${show.defaultWorkspace ?? "(none)"}`, + `service installed: ${show.serviceInstalled ? "yes" : "no"}`, + `service running: ${show.serviceRunning ? "yes" : "no"}`, + `platform: ${show.platform}`, + `service manager: ${show.serviceManager}`, + `access key: ${show.accessKey}`, + ].join("\n")); + return; + } + + if (subcommand === "port") { + const port = Number(key); + console.log(await setConfigPort(port, CLI_ENTRYPOINT)); + return; + } + + if (subcommand === "host") { + if (!key) throw new Error("Missing host value."); + console.log(await setConfigHost(key, CLI_ENTRYPOINT)); + return; + } + + if (subcommand === "domain") { + const value = [key, ...rest].join(" ").trim(); + if (!value) throw new Error("Missing domain or URL."); + console.log(await setConfigDomain(value, CLI_ENTRYPOINT)); + return; + } + + if (subcommand === "key") { + console.log(await resetConfigKey(CLI_ENTRYPOINT)); + return; + } + if (subcommand !== "set") { throw new Error(`Unknown config command: ${subcommand}`); } @@ -267,10 +360,188 @@ function runConfigCommand(args: string[]): void { writeDevspaceConfig({ ...files.config, publicBaseUrl: normalizeOptionalPublicBaseUrl(value), + server: { + ...(files.config.server ?? {}), + publicBaseUrl: normalizeOptionalPublicBaseUrl(value), + }, }); console.log(`Updated ${files.configPath}`); } +async function runWorkspaceCommand(args: string[]): Promise { + const [subcommand, ...restArgs] = args; + const flags = restArgs.filter((arg) => arg.startsWith("--")); + const positional = restArgs.filter((arg) => !arg.startsWith("--")); + const value = positional[0]; + switch (subcommand) { + case "add": + if (!value) throw new Error("Missing workspace path."); + console.log(await addWorkspace(value, { + create: flags.includes("--create"), + makeDefault: flags.includes("--default"), + })); + return; + case "list": { + const result = listWorkspaces(); + if (flags.includes("--json")) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log([ + "Workspaces:", + ...result.workspaces.map((workspace) => `${workspace}${workspace === result.defaultWorkspace ? " default" : ""}`), + ].join("\n")); + return; + } + case "remove": + if (!value) throw new Error("Missing workspace path."); + console.log(await removeWorkspace(value)); + return; + case "default": + if (!value) throw new Error("Missing workspace path."); + console.log(await setDefaultWorkspace(value)); + return; + case "clear-default": + console.log(await clearDefaultWorkspace()); + return; + default: + throw new Error(`Unknown workspace command: ${subcommand ?? ""}`); + } +} + +async function runServiceCommand(args: string[]): Promise { + const config = loadConfig(); + const manager = createServiceManager({ config, cliEntrypoint: CLI_ENTRYPOINT }); + const [subcommand, ...rest] = args; + + switch (subcommand) { + case "install": + console.log((await manager.install({ autostart: rest.includes("--autostart") })).message); + return; + case "uninstall": + console.log((await manager.uninstall()).message); + return; + case "enable": + console.log((await manager.enable()).message); + return; + case "disable": + console.log((await manager.disable()).message); + return; + case "start": + console.log((await manager.start()).message); + return; + case "stop": + console.log((await manager.stop()).message); + return; + case "restart": + console.log((await manager.restart()).message); + return; + case "status": { + const status = await manager.status(); + if (rest.includes("--json")) { + console.log(JSON.stringify(status, null, 2)); + return; + } + console.log([ + `manager: ${status.manager}`, + `service: ${status.serviceName}`, + `installed: ${status.installed ? "yes" : "no"}`, + `enabled: ${status.enabled ? "yes" : "no"}`, + `running: ${status.running ? "yes" : "no"}`, + `endpoint: ${status.endpoint ?? "(unknown)"}`, + `public base URL: ${status.publicBaseUrl ?? "(unknown)"}`, + `log path: ${status.logPath ?? "(unknown)"}`, + ].join("\n")); + return; + } + case "logs": + console.log(await manager.logs({ tail: parseTailArgument(rest) })); + return; + case "doctor": { + const doctor = await manager.doctor(); + console.log([ + `manager: ${doctor.manager}`, + ...doctor.checks.map((check) => `[${check.level.toUpperCase()}] ${check.message}`), + ].join("\n")); + return; + } + default: + throw new Error(`Unknown service command: ${subcommand ?? ""}`); + } +} + +async function runSkillsCommand(args: string[]): Promise { + const [subcommand, ...rest] = args; + const config = loadConfig(); + + switch (subcommand) { + case "install": { + const { scope, workspace, source } = parseSkillsInstallArgs(rest); + const workspaceRoot = scope === "workspace" + ? resolveWorkspaceRoot(config, workspace ?? process.cwd()) + : undefined; + const installed = await installSkill({ + config, + workspaceRoot, + scope, + source, + }); + console.log([ + `Installed ${installed.name}`, + `Scope: ${installed.scope}`, + `Path: ${installed.path}`, + `Source: ${installed.sourceSummary}`, + ].join("\n")); + return; + } + case "list": { + const { scope, workspace } = parseSkillsScopeArgs(rest); + const workspaceRoot = scope === "workspace" + ? resolveWorkspaceRoot(config, workspace ?? process.cwd()) + : undefined; + const skills = await listInstalledSkills({ + config, + workspaceRoot, + scope, + }); + if (skills.length === 0) { + console.log("No installed skills."); + return; + } + console.log( + skills + .map((skill) => [ + `${skill.name} (${skill.scope})`, + ` path: ${skill.path}`, + ` description: ${skill.description}`, + ].join("\n")) + .join("\n"), + ); + return; + } + case "remove": { + const { scope, workspace, name } = parseSkillsRemoveArgs(rest); + const workspaceRoot = scope === "workspace" + ? resolveWorkspaceRoot(config, workspace ?? process.cwd()) + : undefined; + const removed = await removeInstalledSkill({ + config, + workspaceRoot, + scope, + name, + }); + console.log([ + `Removed ${removed.name}`, + `Scope: ${removed.scope}`, + `Path: ${removed.removedPath}`, + ].join("\n")); + return; + } + default: + throw new Error(`Unknown skills command: ${subcommand ?? ""}`); + } +} + function printHelp(): void { console.log( [ @@ -279,12 +550,37 @@ function printHelp(): void { "Usage:", " devspace Run first-time setup if needed, then start the server", " devspace serve Start the server", + " devspace serve --add-dir Temporarily allow an extra workspace root", + " devspace serve --workspace Temporarily set the default workspace for this run", + " devspace service-run Internal service entrypoint", " devspace serve --tunnel Start the server with an explicit Cloudflare quick tunnel", " devspace serve --no-tunnel Disable a configured Cloudflare quick tunnel for this run", " devspace init Create or update ~/.devspace/config.json and auth.json", " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", + " devspace config show Print effective config and service state", + " devspace config port ", + " devspace config host ", + " devspace config domain ", + " devspace config key", " devspace config set publicBaseUrl ", + " devspace workspace add [--default] [--create]", + " devspace workspace list [--json]", + " devspace workspace remove ", + " devspace workspace default ", + " devspace workspace clear-default", + " devspace skills install [--workspace ] [--repo --path [--ref ] | --github-url | --local-path ]", + " devspace skills install -g [--repo --path [--ref ] | --github-url | --local-path ]", + " devspace skills list [--workspace ]", + " devspace skills list -g", + " devspace skills remove [--workspace ] ", + " devspace skills remove -g ", + " devspace service install [--autostart]", + " devspace service uninstall", + " devspace service enable|disable|start|stop|restart", + " devspace service status [--json]", + " devspace service logs [--tail N]", + " devspace service doctor", "", "Optional Cloudflare quick tunnel:", " DEVSPACE_TUNNEL=cloudflare devspace serve", @@ -296,6 +592,46 @@ function printHelp(): void { ); } +function parseTailArgument(args: string[]): number { + const index = args.indexOf("--tail"); + if (index === -1) return 200; + const value = Number(args[index + 1]); + return Number.isInteger(value) && value > 0 ? value : 200; +} + +function extractServeArgs(args: string[]): { additionalRoots: string[]; workspace?: string } { + const additionalRoots: string[] = []; + let workspace: string | undefined; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--add-dir") { + const value = args[index + 1]; + if (!value) throw new Error("Missing path after --add-dir."); + additionalRoots.push(resolve(expandHomePath(value))); + index += 1; + continue; + } + if (arg === "--workspace") { + const value = args[index + 1]; + if (!value) throw new Error("Missing path after --workspace."); + workspace = resolve(expandHomePath(value)); + index += 1; + } + } + + return { additionalRoots, workspace }; +} + +function mergeAllowedRoots(additionalRoots: string[]): string { + const files = loadDevspaceFiles(); + const merged = new Set([ + ...(files.config.workspaces?.allowed ?? files.config.allowedRoots ?? []), + ...additionalRoots, + ]); + return Array.from(merged).join(","); +} + function normalizeOptionalPublicBaseUrl(value: string): string | null { const trimmed = value.trim(); if (!trimmed || trimmed === "null" || trimmed === "none") return null; @@ -312,6 +648,104 @@ function normalizePublicBaseUrl(value: string): string { return parsed.toString().replace(/\/$/, ""); } +function parseSkillsInstallArgs(args: string[]): { + scope: SkillScope; + workspace?: string; + source: SkillInstallSource; +} { + const { scope, workspace } = parseSkillsScopeArgs(args); + const repo = valueAfterFlag(args, "--repo"); + const path = valueAfterFlag(args, "--path"); + const ref = valueAfterFlag(args, "--ref"); + const githubUrl = valueAfterFlag(args, "--github-url"); + const localPath = valueAfterFlag(args, "--local-path"); + const selectedSources = [Boolean(repo || path), Boolean(githubUrl), Boolean(localPath)].filter(Boolean).length; + + if (selectedSources !== 1) { + throw new Error("Choose exactly one skill source: --repo/--path, --github-url, or --local-path."); + } + + if (githubUrl) { + return { + scope, + workspace, + source: { kind: "github_url", url: githubUrl }, + }; + } + + if (localPath) { + return { + scope, + workspace, + source: { kind: "local", path: resolve(expandHomePath(localPath)) }, + }; + } + + if (!repo || !path) { + throw new Error("GitHub install requires both --repo and --path."); + } + + return { + scope, + workspace, + source: { kind: "github", repo, path, ref: ref || undefined }, + }; +} + +function parseSkillsRemoveArgs(args: string[]): { + scope: SkillScope; + workspace?: string; + name: string; +} { + const { scope, workspace } = parseSkillsScopeArgs(args); + const positional = positionalSkillArgs(args); + const [name] = positional; + if (!name) throw new Error("Missing skill name."); + if (positional.length > 1) throw new Error("Remove accepts exactly one skill name."); + return { scope, workspace, name }; +} + +function parseSkillsScopeArgs(args: string[]): { + scope: SkillScope; + workspace?: string; +} { + const global = args.includes("-g") || args.includes("--global"); + const workspace = valueAfterFlag(args, "--workspace"); + if (global && workspace) { + throw new Error("Use either -g/--global or --workspace, not both."); + } + + return { + scope: global ? "global" : "workspace", + workspace: workspace ? resolve(expandHomePath(workspace)) : undefined, + }; +} + +function positionalSkillArgs(args: string[]): string[] { + return args.filter((arg, index) => !isSkillFlagArgument(args, index)); +} + +function isSkillFlagArgument(args: string[], index: number): boolean { + const arg = args[index]; + if (arg === "-g" || arg === "--global") return true; + + const valueFlags = new Set(["--workspace", "--repo", "--path", "--ref", "--github-url", "--local-path"]); + if (valueFlags.has(arg)) return true; + + const previous = args[index - 1]; + return valueFlags.has(previous); +} + +function valueAfterFlag(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + if (index === -1) return undefined; + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`Missing value after ${flag}.`); + } + return value; +} + type TextPromptOptions = Omit[0], "validate"> & { defaultValue: string; validate?: (value: string | undefined) => string | Error | undefined; diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts new file mode 100644 index 0000000..539c090 --- /dev/null +++ b/src/config-operations.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + addWorkspace, + buildConfigShowResult, + clearDefaultWorkspace, + listWorkspaces, + removeWorkspace, + resetConfigKey, + setConfigDomain, + setConfigHost, + setConfigPort, + setDefaultWorkspace, +} from "./config-operations.js"; +import { loadDevspaceFiles, writeDevspaceAuth } from "./user-config.js"; +import type { ServiceManager } from "./service/types.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-config-ops-test-")); +process.env.DEVSPACE_CONFIG_DIR = root; +process.env.DEVSPACE_STATE_DIR = join(root, "state"); + +const testManager: ServiceManager = { + kind: "unsupported", + serviceName: "devspace-test", + async isSupported() { + return false; + }, + async install() { + return { ok: false, manager: "unsupported", message: "unsupported" }; + }, + async uninstall() { + return { ok: false, manager: "unsupported", message: "unsupported" }; + }, + async enable() { + return { ok: false, manager: "unsupported", message: "unsupported" }; + }, + async disable() { + return { ok: false, manager: "unsupported", message: "unsupported" }; + }, + async start() { + return { ok: false, manager: "unsupported", message: "unsupported" }; + }, + async stop() { + return { ok: false, manager: "unsupported", message: "unsupported" }; + }, + async restart() { + return { ok: true, manager: "unsupported", message: "Restarted service" }; + }, + async status() { + return { + installed: true, + enabled: true, + running: true, + manager: "unsupported", + serviceName: "devspace-test", + }; + }, + async logs() { + return ""; + }, + async doctor() { + return { manager: "unsupported", checks: [] }; + }, +}; + +try { + writeDevspaceAuth({ ownerToken: "test-owner-token-that-is-long-enough" }); + + const initialWorkspace = join(root, "workspace-a"); + await addWorkspace(initialWorkspace, { create: true, makeDefault: true }); + const resolvedInitialWorkspace = realpathSync(initialWorkspace); + let listed = listWorkspaces(); + assert.deepEqual(listed.workspaces, [resolvedInitialWorkspace]); + assert.equal(listed.defaultWorkspace, resolvedInitialWorkspace); + + const secondWorkspace = join(root, "workspace-b"); + await addWorkspace(secondWorkspace, { create: true }); + const resolvedSecondWorkspace = realpathSync(secondWorkspace); + await setDefaultWorkspace(secondWorkspace); + listed = listWorkspaces(); + assert.equal(listed.defaultWorkspace, resolvedSecondWorkspace); + + await clearDefaultWorkspace(); + assert.equal(listWorkspaces().defaultWorkspace, undefined); + + await removeWorkspace(initialWorkspace); + assert.deepEqual(listWorkspaces().workspaces, [resolvedSecondWorkspace]); + + await setConfigHost("127.0.0.1", import.meta.url, { manager: testManager }); + await setConfigDomain("https://devspace.example.com/custom-mcp", import.meta.url, { manager: testManager }); + const filesAfterDomain = loadDevspaceFiles(); + assert.equal(filesAfterDomain.config.server?.publicBaseUrl, "https://devspace.example.com"); + assert.equal(filesAfterDomain.config.server?.mcpPath, "/custom-mcp"); + + const oldToken = loadDevspaceFiles().auth.ownerToken; + await resetConfigKey(import.meta.url, { manager: testManager }); + const newToken = loadDevspaceFiles().auth.ownerToken; + assert.notEqual(newToken, oldToken); + + process.env.DEVSPACE_OAUTH_OWNER_TOKEN = "env-owner-token-that-is-long-enough"; + const shown = await buildConfigShowResult(import.meta.url, { manager: testManager }); + assert.match(shown.accessKey, /^env\*+/); + delete process.env.DEVSPACE_OAUTH_OWNER_TOKEN; + + await assert.rejects(() => setConfigPort(0, import.meta.url, { manager: testManager }), /between 1 and 65535/); +} finally { + rmSync(root, { recursive: true, force: true }); + delete process.env.DEVSPACE_CONFIG_DIR; + delete process.env.DEVSPACE_STATE_DIR; + delete process.env.DEVSPACE_OAUTH_OWNER_TOKEN; +} diff --git a/src/config-operations.ts b/src/config-operations.ts new file mode 100644 index 0000000..920bd11 --- /dev/null +++ b/src/config-operations.ts @@ -0,0 +1,301 @@ +import { createServer } from "./server.js"; +import { existsSync, mkdirSync, realpathSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { createServer as createNetServer } from "node:net"; +import { dirname, resolve } from "node:path"; +import { devspaceAuthPath, loadDevspaceFiles, type DevspaceAuthConfig, type DevspaceUserConfig, writeDevspaceAuth, writeDevspaceConfig } from "./user-config.js"; +import { createServiceManager, restartServiceIfRunning } from "./service/manager.js"; +import { expandHomePath } from "./roots.js"; +import { generateOwnerToken } from "./user-config.js"; +import { loadConfig } from "./config.js"; +import { SingleUserOAuthProvider } from "./oauth-provider.js"; +import type { ServiceManager } from "./service/types.js"; + +interface ConfigOperationOptions { + manager?: ServiceManager; +} + +export interface ConfigShowResult { + host: string; + port: number; + mcpPath: string; + publicUrl: string; + workspaces: string[]; + defaultWorkspace?: string; + platform: string; + serviceManager: string; + serviceInstalled: boolean; + serviceRunning: boolean; + accessKey: string; +} + +export async function buildConfigShowResult( + cliEntrypoint: string, + options: ConfigOperationOptions = {}, +): Promise { + const config = loadConfig(); + const manager = options.manager ?? createServiceManager({ config, cliEntrypoint }); + const status = await manager.status(); + return { + host: config.host, + port: config.port, + mcpPath: config.mcpPath, + publicUrl: new URL(config.mcpPath, config.publicBaseUrl).toString(), + workspaces: config.configuredWorkspaces, + defaultWorkspace: config.defaultWorkspace, + platform: process.platform, + serviceManager: manager.kind, + serviceInstalled: status.installed, + serviceRunning: status.running, + accessKey: maskSecret(effectiveOwnerToken()), + }; +} + +export async function setConfigPort( + port: number, + cliEntrypoint: string, + options: ConfigOperationOptions = {}, +): Promise { + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Port must be an integer between 1 and 65535."); + } + + const occupied = await inspectPort(port); + if (occupied) { + throw new Error(`Port ${port} is already in use${occupied}.`); + } + + const files = loadDevspaceFiles(); + writeDevspaceConfig({ + ...files.config, + host: files.config.server?.host ?? files.config.host ?? "127.0.0.1", + port, + server: { + ...(files.config.server ?? {}), + host: files.config.server?.host ?? files.config.host ?? "127.0.0.1", + port, + }, + }); + + return applyConfigUpdate(cliEntrypoint, undefined, options); +} + +export async function setConfigHost( + host: string, + cliEntrypoint: string, + options: ConfigOperationOptions = {}, +): Promise { + validateHost(host); + const files = loadDevspaceFiles(); + writeDevspaceConfig({ + ...files.config, + host, + server: { + ...(files.config.server ?? {}), + host, + port: files.config.server?.port ?? files.config.port, + }, + }); + return applyConfigUpdate( + cliEntrypoint, + isPublicHost(host) + ? "Warning: this host may expose DevSpace beyond localhost. Ensure auth, TLS, and firewall rules are correctly configured." + : undefined, + options, + ); +} + +export async function setConfigDomain( + input: string, + cliEntrypoint: string, + options: ConfigOperationOptions = {}, +): Promise { + const normalized = normalizeDomainLikeInput(input); + const files = loadDevspaceFiles(); + writeDevspaceConfig({ + ...files.config, + publicBaseUrl: normalized.publicBaseUrl, + server: { + ...(files.config.server ?? {}), + publicBaseUrl: normalized.publicBaseUrl, + mcpPath: normalized.mcpPath, + }, + }); + const warning = normalized.publicBaseUrl.startsWith("http://") + ? "Warning: public URL uses HTTP. Prefer HTTPS for any remote MCP access." + : undefined; + return applyConfigUpdate(cliEntrypoint, warning, options); +} + +export async function resetConfigKey( + cliEntrypoint: string, + options: ConfigOperationOptions = {}, +): Promise { + const files = loadDevspaceFiles(); + const newToken = generateOwnerToken(); + writeDevspaceAuth({ ownerToken: newToken }); + + const config = loadConfig(); + const oauthProvider = new SingleUserOAuthProvider(config.oauth, new URL(config.mcpPath, config.publicBaseUrl)); + oauthProvider.resetState(); + + const restartMessage = await applyConfigUpdate(cliEntrypoint, undefined, options); + return [ + "Access key has been reset successfully.", + "Existing clients must be reconfigured.", + restartMessage, + ].filter(Boolean).join("\n"); +} + +export async function addWorkspace(path: string, options: { + create?: boolean; + makeDefault?: boolean; +}): Promise { + const resolved = await resolveWorkspacePath(path, options.create ?? false); + const files = loadDevspaceFiles(); + const current = new Set(files.config.workspaces?.allowed ?? files.config.allowedRoots ?? []); + if (current.has(resolved)) { + return `Workspace already added: ${resolved}`; + } + current.add(resolved); + writeDevspaceConfig({ + ...files.config, + allowedRoots: Array.from(current), + workspaces: { + allowed: Array.from(current), + default: options.makeDefault ? resolved : files.config.workspaces?.default ?? null, + }, + }); + return `Added workspace: ${resolved}`; +} + +export function listWorkspaces(): { workspaces: string[]; defaultWorkspace?: string } { + const files = loadDevspaceFiles(); + return { + workspaces: files.config.workspaces?.allowed ?? files.config.allowedRoots ?? [], + defaultWorkspace: files.config.workspaces?.default ?? undefined, + }; +} + +export async function removeWorkspace(path: string): Promise { + const resolved = await resolveWorkspacePath(path, false); + const files = loadDevspaceFiles(); + const remaining = (files.config.workspaces?.allowed ?? files.config.allowedRoots ?? []).filter((entry) => entry !== resolved); + writeDevspaceConfig({ + ...files.config, + allowedRoots: remaining, + workspaces: { + allowed: remaining, + default: files.config.workspaces?.default === resolved ? null : files.config.workspaces?.default ?? null, + }, + }); + return `Removed workspace: ${resolved}`; +} + +export async function setDefaultWorkspace(path: string): Promise { + const resolved = await resolveWorkspacePath(path, false); + const files = loadDevspaceFiles(); + const workspaces = files.config.workspaces?.allowed ?? files.config.allowedRoots ?? []; + if (!workspaces.includes(resolved)) { + throw new Error(`Workspace is not configured: ${resolved}`); + } + writeDevspaceConfig({ + ...files.config, + workspaces: { + allowed: workspaces, + default: resolved, + }, + }); + return `Default workspace set to ${resolved}`; +} + +export async function clearDefaultWorkspace(): Promise { + const files = loadDevspaceFiles(); + writeDevspaceConfig({ + ...files.config, + workspaces: { + allowed: files.config.workspaces?.allowed ?? files.config.allowedRoots ?? [], + default: null, + }, + }); + return "Cleared default workspace"; +} + +function validateHost(host: string): void { + const trimmed = host.trim(); + if (!trimmed) throw new Error("Host is required."); + if (!/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1|::|[A-Za-z0-9._:-]+)$/.test(trimmed)) { + throw new Error(`Invalid host: ${host}`); + } +} + +function isPublicHost(host: string): boolean { + return !["127.0.0.1", "localhost", "::1"].includes(host); +} + +function normalizeDomainLikeInput(input: string): { publicBaseUrl: string; mcpPath: string } { + const trimmed = input.trim(); + if (!trimmed) throw new Error("Domain or URL is required."); + const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed) ? trimmed : `https://${trimmed}`; + const parsed = new URL(withScheme); + if (parsed.username || parsed.password) { + throw new Error("Public URL must not include a username or password."); + } + parsed.hash = ""; + parsed.search = ""; + const mcpPath = parsed.pathname.replace(/\/+$/, "") || "/mcp"; + parsed.pathname = ""; + return { + publicBaseUrl: parsed.toString().replace(/\/$/, ""), + mcpPath, + }; +} + +async function applyConfigUpdate( + cliEntrypoint: string, + extraMessage?: string, + options: ConfigOperationOptions = {}, +): Promise { + const config = loadConfig(); + const manager = options.manager ?? createServiceManager({ config, cliEntrypoint }); + const outcome = await restartServiceIfRunning(manager); + return [extraMessage, outcome.message].filter(Boolean).join("\n"); +} + +async function inspectPort(port: number): Promise { + const available = await canBindPort(port); + return available ? undefined : ` by another process on this machine`; +} + +function canBindPort(port: number): Promise { + return new Promise((resolve) => { + const server = createNetServer(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} + +async function resolveWorkspacePath(path: string, create: boolean): Promise { + const target = resolve(expandHomePath(path)); + if (create) { + await mkdir(target, { recursive: true }); + } + if (!existsSync(target)) { + throw new Error(`Workspace path does not exist: ${target}`); + } + return realpathSync(target); +} + +function maskSecret(secret: string | undefined): string { + if (!secret) return "(not configured)"; + if (secret.length <= 6) return "*".repeat(secret.length); + return `${secret.slice(0, 3)}${"*".repeat(Math.max(8, secret.length - 5))}${secret.slice(-2)}`; +} + +function effectiveOwnerToken(): string | undefined { + const envToken = process.env.DEVSPACE_OAUTH_OWNER_TOKEN?.trim(); + if (envToken) return envToken; + return loadDevspaceFiles().auth.ownerToken; +} diff --git a/src/config.test.ts b/src/config.test.ts index 492fb66..434c517 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -13,6 +13,13 @@ const baseEnv = { }; assert.equal(loadConfig(baseEnv).widgets, "full"); +assert.deepEqual( + loadConfig({ + DEVSPACE_CONFIG_DIR: emptyConfigDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }).allowedRoots, + [], +); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); diff --git a/src/config.ts b/src/config.ts index 136c32c..92cdb89 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; -import { loadDevspaceFiles } from "./user-config.js"; +import { loadDevspaceFiles, normalizeMcpPath } from "./user-config.js"; export type ToolNamingMode = "legacy" | "short"; export type WidgetMode = "off" | "changes" | "full"; @@ -14,8 +14,12 @@ const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; export interface ServerConfig { host: string; port: number; + mcpPath: string; oauth: OAuthConfig; allowedRoots: string[]; + configuredWorkspaces: string[]; + defaultWorkspace?: string; + sessionWorkspace?: string; allowedHosts: string[]; publicBaseUrl: string; minimalTools: boolean; @@ -44,7 +48,7 @@ function parsePort(value: string | number | undefined): number { function parseAllowedRoots(value: string | string[] | undefined): string[] { if (Array.isArray(value)) { const roots = value.map((entry) => entry.trim()).filter(Boolean); - return (roots.length > 0 ? roots : [process.cwd()]).map((root) => resolve(expandHomePath(root))); + return roots.map((root) => resolve(expandHomePath(root))); } const rawRoots = @@ -53,8 +57,7 @@ function parseAllowedRoots(value: string | string[] | undefined): string[] { .map((entry) => entry.trim()) .filter(Boolean) ?? []; - const roots = rawRoots.length > 0 ? rawRoots : [process.cwd()]; - return roots.map((root) => resolve(expandHomePath(root))); + return rawRoots.map((root) => resolve(expandHomePath(root))); } function parseAllowedHosts(value: string | string[] | undefined, derivedHosts: string[]): string[] { @@ -224,11 +227,21 @@ function defaultAgentDir(): string { export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const files = loadDevspaceFiles(env); - const host = env.HOST ?? files.config.host ?? "127.0.0.1"; - const port = parsePort(env.PORT ?? files.config.port); + const host = env.HOST ?? files.config.server?.host ?? files.config.host ?? "127.0.0.1"; + const port = parsePort(env.PORT ?? files.config.server?.port ?? files.config.port); const stateDir = resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())); + const mcpPath = normalizeMcpPath(env.DEVSPACE_MCP_PATH ?? files.config.server?.mcpPath); + const configuredWorkspaces = parseAllowedRoots( + env.DEVSPACE_ALLOWED_ROOTS ?? files.config.workspaces?.allowed ?? files.config.allowedRoots, + ); + const defaultWorkspace = files.config.workspaces?.default + ? resolve(expandHomePath(files.config.workspaces.default)) + : undefined; + const sessionWorkspace = env.DEVSPACE_SESSION_WORKSPACE + ? resolve(expandHomePath(env.DEVSPACE_SESSION_WORKSPACE)) + : undefined; const publicBaseUrl = parsePublicBaseUrl( - env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), + env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.server?.publicBaseUrl ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), ); const derivedAllowedHosts = [ "localhost", @@ -242,8 +255,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { return { host, port, + mcpPath, oauth: parseOAuthConfig(env, files.auth.ownerToken, stateDir), - allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), + allowedRoots: configuredWorkspaces, + configuredWorkspaces, + defaultWorkspace, + sessionWorkspace, allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, minimalTools: parseMinimalTools(env), diff --git a/src/db/client.ts b/src/db/client.ts index e3057c8..87718f9 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import Database from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; @@ -18,10 +18,15 @@ export function databasePath(stateDir: string): string { } export function openDatabase(stateDir: string): DatabaseHandle { - mkdirSync(stateDir, { recursive: true }); - const sqlite = new Database(databasePath(stateDir)); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + chmodSync(stateDir, 0o700); + const path = databasePath(stateDir); + const sqlite = new Database(path); + chmodSync(path, 0o600); sqlite.pragma("journal_mode = WAL"); sqlite.pragma("foreign_keys = ON"); + chmodIfExists(`${path}-wal`, 0o600); + chmodIfExists(`${path}-shm`, 0o600); return { sqlite, @@ -33,3 +38,7 @@ export function openDatabase(stateDir: string): DatabaseHandle { function createDrizzleDatabase(sqlite: SqliteDatabase) { return drizzle(sqlite, { schema }); } + +function chmodIfExists(path: string, mode: number): void { + if (existsSync(path)) chmodSync(path, mode); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 5db2c86..f0da772 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { index, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const workspaceSessions = sqliteTable( "workspace_sessions", @@ -97,6 +97,63 @@ export const workspaceUserInputs = sqliteTable( }, ); +export const oauthClients = sqliteTable("oauth_clients", { + clientId: text("client_id").primaryKey(), + clientJson: text("client_json").notNull(), + createdAt: integer("created_at").notNull(), +}); + +export const oauthAuthorizationCodes = sqliteTable( + "oauth_authorization_codes", + { + codeHash: text("code_hash").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClients.clientId, { onDelete: "cascade" }), + paramsJson: text("params_json").notNull(), + expiresAtMs: integer("expires_at_ms").notNull(), + }, + (table) => [index("oauth_authorization_codes_expiry_idx").on(table.expiresAtMs)], +); + +export const oauthTokens = sqliteTable( + "oauth_tokens", + { + tokenHash: text("token_hash").notNull(), + tokenKind: text("token_kind").notNull(), + clientId: text("client_id") + .notNull() + .references(() => oauthClients.clientId, { onDelete: "cascade" }), + scopesJson: text("scopes_json").notNull(), + expiresAt: integer("expires_at").notNull(), + resource: text("resource"), + }, + (table) => [ + primaryKey({ columns: [table.tokenHash, table.tokenKind] }), + index("oauth_tokens_expiry_idx").on(table.expiresAt), + ], +); + +export const oauthConsents = sqliteTable( + "oauth_consents", + { + consentKey: text("consent_key").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClients.clientId, { onDelete: "cascade" }), + redirectUri: text("redirect_uri").notNull(), + resource: text("resource").notNull(), + scopesJson: text("scopes_json").notNull(), + approvedAt: integer("approved_at").notNull(), + }, + (table) => [index("oauth_consents_client_idx").on(table.clientId)], +); + +export const oauthMetadata = sqliteTable("oauth_metadata", { + key: text("key").primaryKey(), + value: text("value").notNull(), +}); + export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; diff --git a/src/oauth-provider.test.ts b/src/oauth-provider.test.ts index dd75949..aca30d9 100644 --- a/src/oauth-provider.test.ts +++ b/src/oauth-provider.test.ts @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { stat, chmod } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import Database from "better-sqlite3"; import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import { SingleUserOAuthProvider, type OAuthConfig } from "./oauth-provider.js"; +import { databasePath } from "./db/client.js"; import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; @@ -31,19 +33,19 @@ try { }); const firstTokens = issueTokens(firstProvider, client.client_id, ["devspace"], resourceServerUrl); - const savedState = JSON.parse(readFileSync(statePath, "utf8")); + const savedState = readPersistedState(statePath); assert.equal(savedState.clients.length, 1); assert.deepEqual(savedState.approvedConsents, []); assert.equal(savedState.accessTokens.length, 1); assert.equal(savedState.accessTokens[0].tokenHash.length > 0, true); - assert.equal(savedState.accessTokens[0].token, undefined); + assert.equal("token" in savedState.accessTokens[0], false); assert.equal(savedState.refreshTokens.length, 1); assert.equal(savedState.refreshTokens[0].tokenHash.length > 0, true); - assert.equal(savedState.refreshTokens[0].token, undefined); + assert.equal("token" in savedState.refreshTokens[0], false); assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.access_token)), false); assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.refresh_token)), false); - const stateStats = await stat(statePath); + const stateStats = await stat(databasePath(dirname(statePath))); const dirStats = await stat(join(root, "state")); assert.equal(stateStats.mode & 0o777, 0o600); assert.equal(dirStats.mode & 0o777, 0o700); @@ -66,7 +68,7 @@ try { assert.equal(Boolean(secondTokens.refresh_token), true); assert.notEqual(secondTokens.refresh_token, firstTokens.refresh_token); - const rotatedState = JSON.parse(readFileSync(statePath, "utf8")); + const rotatedState = readPersistedState(statePath); assert.equal(rotatedState.refreshTokens.length, 1); assert.equal(rotatedState.accessTokens.length, 2); assert.equal(JSON.stringify(rotatedState).includes(assertString(firstTokens.access_token)), false); @@ -105,7 +107,7 @@ try { () => expiredProvider.exchangeRefreshToken(client, assertString(firstTokens.refresh_token), undefined, resourceServerUrl), InvalidGrantError, ); - const cleanedExpiredState = JSON.parse(readFileSync(expiredStatePath, "utf8")); + const cleanedExpiredState = readPersistedState(expiredStatePath); assert.equal(cleanedExpiredState.accessTokens.length, 0); assert.equal(cleanedExpiredState.refreshTokens.length, 0); @@ -115,8 +117,8 @@ try { await chmod(corruptStatePath, 0o600); const corruptProvider = new SingleUserOAuthProvider({ ...config, statePath: corruptStatePath }, resourceServerUrl); assert.equal(corruptProvider.clientsStore.getClient(client.client_id), undefined); - const repairedState = JSON.parse(readFileSync(corruptStatePath, "utf8")); - assert.deepEqual(repairedState, { version: 1, clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); + const repairedState = readPersistedState(corruptStatePath); + assert.deepEqual(repairedState, { clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); const emptyStatePath = join(root, "empty", "oauth.json"); mkdirSync(join(root, "empty"), { recursive: true }); @@ -124,8 +126,8 @@ try { await chmod(emptyStatePath, 0o600); const emptyProvider = new SingleUserOAuthProvider({ ...config, statePath: emptyStatePath }, resourceServerUrl); assert.equal(emptyProvider.clientsStore.getClient(client.client_id), undefined); - const rewrittenEmptyState = JSON.parse(readFileSync(emptyStatePath, "utf8")); - assert.deepEqual(rewrittenEmptyState, { version: 1, clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); + const rewrittenEmptyState = readPersistedState(emptyStatePath); + assert.deepEqual(rewrittenEmptyState, { clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); const customProvider = new SingleUserOAuthProvider({ ...config, statePath: customStatePath }, resourceServerUrl); customProvider.clientsStore.registerClient({ @@ -133,7 +135,7 @@ try { redirect_uris: ["http://localhost/custom"], scope: "devspace", }); - assert.equal(JSON.parse(readFileSync(customStatePath, "utf8")).clients.length, 1); + assert.equal(readPersistedState(customStatePath).clients.length, 1); const expiredAccessStatePath = join(root, "expired-access", "oauth.json"); mkdirSync(join(root, "expired-access"), { recursive: true }); @@ -162,7 +164,7 @@ try { () => expiredAccessProvider.verifyAccessToken(assertString(expiredAccessTokens.access_token)), InvalidTokenError, ); - const cleanedExpiredAccessState = JSON.parse(readFileSync(expiredAccessStatePath, "utf8")); + const cleanedExpiredAccessState = readPersistedState(expiredAccessStatePath); assert.equal(cleanedExpiredAccessState.accessTokens.length, 0); const consentStatePath = join(root, "consent", "oauth.json"); @@ -185,7 +187,7 @@ try { assert.equal(firstConsentPost.redirectUrl?.searchParams.get("state"), "state-1"); assert.match(assertPresentString(firstConsentPost.redirectUrl?.searchParams.get("code")), /^code-/); - const consentSavedState = JSON.parse(readFileSync(consentStatePath, "utf8")); + const consentSavedState = readPersistedState(consentStatePath); assert.equal(consentSavedState.approvedConsents.length, 1); assert.equal(consentSavedState.approvedConsents[0].clientId, consentClient.client_id); assert.equal(consentSavedState.approvedConsents[0].redirectUri, "http://localhost/consent"); @@ -252,7 +254,7 @@ try { assert.equal(restartedConsentGet.redirectStatus, 302); assert.equal(assertUrl(restartedConsentGet.redirectUrl).origin + assertUrl(restartedConsentGet.redirectUrl).pathname, "http://localhost/consent"); - const finalConsentState = JSON.parse(readFileSync(consentStatePath, "utf8")); + const finalConsentState = readPersistedState(consentStatePath); assert.equal(JSON.stringify(finalConsentState).includes(config.ownerToken), false); assert.equal(JSON.stringify(finalConsentState).includes(assertString(firstTokens.access_token)), false); assert.equal(JSON.stringify(finalConsentState).includes(assertString(firstTokens.refresh_token)), false); @@ -274,6 +276,64 @@ function issueTokens( return rawIssueTokens.call(provider, clientId, scopes, resource); } +function readPersistedState(statePath: string) { + const db = new Database(databasePath(dirname(statePath)), { readonly: true }); + try { + const clients = (db.prepare("select client_json from oauth_clients order by created_at asc").all() as { client_json: string }[]) + .map((row) => JSON.parse(row.client_json)); + const tokens = db.prepare("select token_hash, token_kind, client_id, scopes_json, expires_at, resource from oauth_tokens order by token_hash asc").all() as { + token_hash: string; + token_kind: "access" | "refresh"; + client_id: string; + scopes_json: string; + expires_at: number; + resource: string | null; + }[]; + const consents = db.prepare("select client_id, redirect_uri, resource, scopes_json, approved_at from oauth_consents order by approved_at asc").all() as { + client_id: string; + redirect_uri: string; + resource: string; + scopes_json: string; + approved_at: number; + }[]; + + return { + clients, + accessTokens: tokens + .filter((row) => row.token_kind === "access") + .map(rowToStoredToken), + refreshTokens: tokens + .filter((row) => row.token_kind === "refresh") + .map(rowToStoredToken), + approvedConsents: consents.map((row) => ({ + clientId: row.client_id, + redirectUri: row.redirect_uri, + resource: row.resource, + scopes: JSON.parse(row.scopes_json) as string[], + approvedAt: row.approved_at, + })), + }; + } finally { + db.close(); + } +} + +function rowToStoredToken(row: { + token_hash: string; + client_id: string; + scopes_json: string; + expires_at: number; + resource: string | null; +}) { + return { + tokenHash: row.token_hash, + clientId: row.client_id, + scopes: JSON.parse(row.scopes_json) as string[], + expiresAt: row.expires_at, + resource: row.resource ?? undefined, + }; +} + function assertString(value: string | undefined): string { if (typeof value !== "string") { throw new Error("Expected string value"); diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 50f4b6c..26bf4d4 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -1,14 +1,4 @@ import { timingSafeEqual, randomBytes, randomUUID, createHash } from "node:crypto"; -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { dirname } from "node:path"; import type { Response } from "express"; import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; import type { OAuthServerProvider, AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; @@ -20,6 +10,11 @@ import type { OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; +import { + consentKey, + SqliteOAuthStore, + type AuthorizationCodeRecord, +} from "./oauth-store.js"; export interface OAuthConfig { ownerToken: string; @@ -30,60 +25,6 @@ export interface OAuthConfig { statePath?: string; } -interface AuthorizationCodeRecord { - clientId: string; - params: AuthorizationParams; - expiresAtMs: number; -} - -interface AccessTokenRecord { - token: string; - clientId: string; - scopes: string[]; - expiresAt: number; - resource?: URL; -} - -interface RefreshTokenRecord { - token: string; - clientId: string; - scopes: string[]; - expiresAt: number; - resource?: URL; -} - -interface StoredRefreshTokenRecord { - tokenHash: string; - clientId: string; - scopes: string[]; - expiresAt: number; - resource?: string; -} - -interface StoredAccessTokenRecord { - tokenHash: string; - clientId: string; - scopes: string[]; - expiresAt: number; - resource?: string; -} - -interface StoredOAuthConsentRecord { - clientId: string; - redirectUri: string; - resource: string; - scopes: string[]; - approvedAt: number; -} - -interface StoredOAuthState { - version: number; - clients: OAuthClientInformationFull[]; - accessTokens: StoredAccessTokenRecord[]; - refreshTokens: StoredRefreshTokenRecord[]; - approvedConsents: StoredOAuthConsentRecord[]; -} - const CODE_TTL_MS = 5 * 60 * 1000; function randomToken(): string { @@ -181,118 +122,15 @@ function redirectHostAllowed(redirectUri: string, allowedHosts: string[]): boole return allowedHosts.includes(parsed.hostname); } -function emptyOAuthState(): StoredOAuthState { - return { - version: 1, - clients: [], - accessTokens: [], - refreshTokens: [], - approvedConsents: [], - }; -} - -function parseOAuthState(raw: string): StoredOAuthState { - const parsed = JSON.parse(raw) as Partial; - return { - version: 1, - clients: Array.isArray(parsed.clients) ? parsed.clients : [], - accessTokens: Array.isArray(parsed.accessTokens) ? parsed.accessTokens : [], - refreshTokens: Array.isArray(parsed.refreshTokens) ? parsed.refreshTokens : [], - approvedConsents: Array.isArray(parsed.approvedConsents) ? parsed.approvedConsents : [], - }; -} - -function readOAuthState(statePath: string | undefined): StoredOAuthState { - if (!statePath || !existsSync(statePath)) return emptyOAuthState(); - - try { - const raw = readFileSync(statePath, "utf8"); - if (!raw.trim()) return emptyOAuthState(); - return parseOAuthState(raw); - } catch { - return emptyOAuthState(); - } -} - -function ensurePrivateDirectory(directory: string): void { - mkdirSync(directory, { recursive: true, mode: 0o700 }); - chmodSync(directory, 0o700); -} - -function writeOAuthState( - statePath: string | undefined, - clients: OAuthClientInformationFull[], - accessTokens: Iterable<[string, AccessTokenRecord]>, - refreshTokens: Iterable<[string, RefreshTokenRecord]>, - approvedConsents: Iterable, -): void { - if (!statePath) return; - - const directory = dirname(statePath); - ensurePrivateDirectory(directory); - - const state: StoredOAuthState = { - version: 1, - clients, - accessTokens: Array.from(accessTokens, ([tokenHash, record]) => ({ - tokenHash, - clientId: record.clientId, - scopes: record.scopes, - expiresAt: record.expiresAt, - resource: record.resource?.href, - })), - refreshTokens: Array.from(refreshTokens, ([tokenHash, record]) => ({ - tokenHash, - clientId: record.clientId, - scopes: record.scopes, - expiresAt: record.expiresAt, - resource: record.resource?.href, - })), - approvedConsents: Array.from(approvedConsents, (record) => ({ - clientId: record.clientId, - redirectUri: record.redirectUri, - resource: record.resource, - scopes: record.scopes, - approvedAt: record.approvedAt, - })), - }; - const tempPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`; - - try { - writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); - chmodSync(tempPath, 0o600); - renameSync(tempPath, statePath); - chmodSync(statePath, 0o600); - } finally { - rmSync(tempPath, { force: true }); - } -} - -function parseStoredResource(resource: string | undefined): URL | undefined { - if (!resource) return undefined; - - try { - return new URL(resource); - } catch { - return undefined; - } -} - -export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore { - private readonly clients = new Map(); +export class SqliteOAuthClientsStore implements OAuthRegisteredClientsStore { constructor( private readonly allowedRedirectHosts: string[], - initialClients: OAuthClientInformationFull[] = [], - private readonly onChange: () => void = () => {}, - ) { - for (const client of initialClients) { - this.clients.set(client.client_id, client); - } - } + private readonly store: SqliteOAuthStore, + ) {} getClient(clientId: string): OAuthClientInformationFull | undefined { - return this.clients.get(clientId); + return this.store.getClient(clientId); } registerClient( @@ -311,22 +149,18 @@ export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore { grant_types: client.grant_types ?? ["authorization_code", "refresh_token"], response_types: client.response_types ?? ["code"], }; - this.clients.set(registered.client_id, registered); - this.onChange(); + this.store.saveClient(registered); return registered; } dumpClients(): OAuthClientInformationFull[] { - return Array.from(this.clients.values()); + return this.store.listClients(); } } export class SingleUserOAuthProvider implements OAuthServerProvider { - readonly clientsStore: InMemoryOAuthClientsStore; - private readonly codes = new Map(); - private readonly accessTokens = new Map(); - private readonly refreshTokens = new Map(); - private readonly approvedConsents = new Map(); + readonly clientsStore: SqliteOAuthClientsStore; + private readonly store: SqliteOAuthStore; private readonly resourceServerUrl: URL; constructor( @@ -334,80 +168,8 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { resourceServerUrl: URL, ) { this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl); - const state = readOAuthState(config.statePath); - this.clientsStore = new InMemoryOAuthClientsStore( - config.allowedRedirectHosts, - state.clients, - () => this.saveOAuthState(), - ); - - const now = Math.floor(Date.now() / 1000); - for (const record of state.accessTokens) { - if ( - typeof record?.tokenHash !== "string" || - typeof record?.clientId !== "string" || - !Array.isArray(record?.scopes) || - typeof record?.expiresAt !== "number" || - record.expiresAt < now - ) { - continue; - } - - this.accessTokens.set(record.tokenHash, { - token: record.tokenHash, - clientId: record.clientId, - scopes: record.scopes, - expiresAt: record.expiresAt, - resource: parseStoredResource(record.resource), - }); - } - - for (const record of state.refreshTokens) { - if ( - typeof record?.tokenHash !== "string" || - typeof record?.clientId !== "string" || - !Array.isArray(record?.scopes) || - typeof record?.expiresAt !== "number" || - record.expiresAt < now - ) { - continue; - } - - this.refreshTokens.set(record.tokenHash, { - token: record.tokenHash, - clientId: record.clientId, - scopes: record.scopes, - expiresAt: record.expiresAt, - resource: parseStoredResource(record.resource), - }); - } - - for (const record of state.approvedConsents) { - if ( - typeof record?.clientId !== "string" || - typeof record?.redirectUri !== "string" || - typeof record?.resource !== "string" || - !Array.isArray(record?.scopes) || - typeof record?.approvedAt !== "number" - ) { - continue; - } - - const client = this.clientsStore.getClient(record.clientId); - if (!client || !client.redirect_uris.includes(record.redirectUri)) { - continue; - } - - this.approvedConsents.set(consentKey(record.clientId, record.redirectUri, record.resource, record.scopes), { - clientId: record.clientId, - redirectUri: record.redirectUri, - resource: record.resource, - scopes: normalizeScopes(record.scopes), - approvedAt: record.approvedAt, - }); - } - - this.saveOAuthState(); + this.store = new SqliteOAuthStore(config.statePath); + this.clientsStore = new SqliteOAuthClientsStore(config.allowedRedirectHosts, this.store); } async authorize( @@ -433,7 +195,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { const currentConsentKey = consentKey(client.client_id, params.redirectUri, params.resource.href, scopes); if (res.req.method !== "POST") { - if (this.approvedConsents.has(currentConsentKey)) { + if (this.store.getConsent(currentConsentKey)) { this.redirectWithAuthorizationCode(client, params, res); return; } @@ -465,25 +227,22 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { return; } - this.approvedConsents.set(currentConsentKey, { + this.store.saveConsent(currentConsentKey, { clientId: client.client_id, redirectUri: params.redirectUri, resource: params.resource.href, scopes, approvedAt: Math.floor(Date.now() / 1000), }); - this.saveOAuthState(); this.redirectWithAuthorizationCode(client, params, res); } revokeClientConsent(clientId: string): void { - let changed = false; - for (const [key, record] of this.approvedConsents.entries()) { - if (record.clientId !== clientId) continue; - this.approvedConsents.delete(key); - changed = true; - } - if (changed) this.saveOAuthState(); + this.store.deleteClientConsents(clientId); + } + + resetState(): void { + this.store.resetState(); } private redirectWithAuthorizationCode( @@ -492,7 +251,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { res: Response, ): void { const code = `code-${randomUUID()}`; - this.codes.set(code, { + this.store.saveAuthorizationCode(hashToken(code), { clientId: client.client_id, params, expiresAtMs: Date.now() + CODE_TTL_MS, @@ -527,7 +286,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { throw new InvalidGrantError("Invalid resource"); } - this.codes.delete(authorizationCode); + this.store.deleteAuthorizationCode(hashToken(authorizationCode)); return this.issueTokens(client.client_id, record.params.scopes ?? this.config.scopes, record.params.resource); } @@ -537,11 +296,11 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { scopes?: string[], resource?: URL, ): Promise { - const record = this.refreshTokens.get(hashToken(refreshToken)); + const refreshTokenHash = hashToken(refreshToken); + const record = this.store.getRefreshToken(refreshTokenHash); if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) { if (record) { - this.refreshTokens.delete(hashToken(refreshToken)); - this.saveOAuthState(); + this.store.deleteRefreshToken(refreshTokenHash); } throw new InvalidGrantError("Invalid refresh token"); } @@ -554,19 +313,18 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { throw new AccessDeniedError("Refresh token cannot grant requested scopes"); } - this.refreshTokens.delete(hashToken(refreshToken)); + this.store.deleteRefreshToken(refreshTokenHash); return this.issueTokens(client.client_id, requestedScopes, resource ?? record.resource); } async verifyAccessToken(token: string): Promise { const hashed = hashToken(token); - const record = this.accessTokens.get(hashed); + const record = this.store.getAccessToken(hashed); if (!record) { throw new InvalidTokenError("Invalid or expired access token"); } if (record.expiresAt < Math.floor(Date.now() / 1000)) { - this.accessTokens.delete(hashed); - this.saveOAuthState(); + this.store.deleteAccessToken(hashed); throw new InvalidTokenError("Invalid or expired access token"); } @@ -581,16 +339,14 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { async revokeToken(_client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise { const hashed = hashToken(request.token); - this.accessTokens.delete(hashed); - this.refreshTokens.delete(hashed); - this.saveOAuthState(); + this.store.revokeToken(hashed); } private validCodeRecord( client: OAuthClientInformationFull, authorizationCode: string, ): AuthorizationCodeRecord { - const record = this.codes.get(authorizationCode); + const record = this.store.getAuthorizationCode(hashToken(authorizationCode)); if (!record || record.clientId !== client.client_id || record.expiresAtMs < Date.now()) { throw new InvalidGrantError("Invalid authorization code"); } @@ -604,21 +360,18 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { const accessExpiresAt = now + this.config.accessTokenTtlSeconds; const refreshExpiresAt = now + this.config.refreshTokenTtlSeconds; - this.accessTokens.set(hashToken(accessToken), { - token: accessToken, + this.store.saveAccessToken(hashToken(accessToken), { clientId, scopes, expiresAt: accessExpiresAt, resource, }); - this.refreshTokens.set(hashToken(refreshToken), { - token: refreshToken, + this.store.saveRefreshToken(hashToken(refreshToken), { clientId, scopes, expiresAt: refreshExpiresAt, resource, }); - this.saveOAuthState(); return { access_token: accessToken, @@ -628,16 +381,6 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { scope: scopes.join(" "), }; } - - private saveOAuthState(): void { - writeOAuthState( - this.config.statePath, - this.clientsStore.dumpClients(), - this.accessTokens.entries(), - this.refreshTokens.entries(), - this.approvedConsents.values(), - ); - } } function authorizationFormFields( @@ -663,7 +406,3 @@ function hashToken(token: string): string { function normalizeScopes(scopes: string[]): string[] { return [...scopes].sort(); } - -function consentKey(clientId: string, redirectUri: string, resource: string, scopes: string[]): string { - return `${clientId}\n${redirectUri}\n${resource}\n${normalizeScopes(scopes).join(" ")}`; -} diff --git a/src/oauth-store.ts b/src/oauth-store.ts new file mode 100644 index 0000000..9159a00 --- /dev/null +++ b/src/oauth-store.ts @@ -0,0 +1,388 @@ +import type { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; +import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname } from "node:path"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; + +export interface AuthorizationCodeRecord { + clientId: string; + params: AuthorizationParams; + expiresAtMs: number; +} + +export interface TokenRecord { + clientId: string; + scopes: string[]; + expiresAt: number; + resource?: URL; +} + +export interface ConsentRecord { + clientId: string; + redirectUri: string; + resource: string; + scopes: string[]; + approvedAt: number; +} + +type TokenKind = "access" | "refresh"; + +interface SerializedAuthorizationParams extends Omit { + resource?: string; +} + +interface StoredTokenRecord { + tokenHash?: string; + clientId?: string; + scopes?: string[]; + expiresAt?: number; + resource?: string; +} + +interface StoredConsentRecord { + clientId?: string; + redirectUri?: string; + resource?: string; + scopes?: string[]; + approvedAt?: number; +} + +interface StoredOAuthState { + clients?: OAuthClientInformationFull[]; + accessTokens?: StoredTokenRecord[]; + refreshTokens?: StoredTokenRecord[]; + approvedConsents?: StoredConsentRecord[]; +} + +export class SqliteOAuthStore { + private readonly database: DatabaseHandle; + + constructor(statePath: string | undefined) { + this.database = openDatabase(statePath ? dirname(statePath) : process.cwd()); + this.migrate(); + this.importLegacyState(statePath); + this.deleteExpired(); + } + + getClient(clientId: string): OAuthClientInformationFull | undefined { + const row = this.database.sqlite + .prepare("select client_json from oauth_clients where client_id = ?") + .get(clientId) as { client_json: string } | undefined; + return row ? (JSON.parse(row.client_json) as OAuthClientInformationFull) : undefined; + } + + listClients(): OAuthClientInformationFull[] { + const rows = this.database.sqlite + .prepare("select client_json from oauth_clients order by created_at asc") + .all() as { client_json: string }[]; + return rows.map((row) => JSON.parse(row.client_json) as OAuthClientInformationFull); + } + + saveClient(client: OAuthClientInformationFull): void { + this.database.sqlite + .prepare("insert or replace into oauth_clients (client_id, client_json, created_at) values (?, ?, ?)") + .run(client.client_id, JSON.stringify(client), client.client_id_issued_at ?? Math.floor(Date.now() / 1000)); + } + + getAuthorizationCode(codeHash: string): AuthorizationCodeRecord | undefined { + const row = this.database.sqlite + .prepare("select client_id, params_json, expires_at_ms from oauth_authorization_codes where code_hash = ?") + .get(codeHash) as { + client_id: string; + params_json: string; + expires_at_ms: number; + } | undefined; + if (!row) return undefined; + if (row.expires_at_ms < Date.now()) { + this.deleteAuthorizationCode(codeHash); + return undefined; + } + return { + clientId: row.client_id, + params: deserializeAuthorizationParams(row.params_json), + expiresAtMs: row.expires_at_ms, + }; + } + + saveAuthorizationCode(codeHash: string, record: AuthorizationCodeRecord): void { + this.database.sqlite + .prepare("insert or replace into oauth_authorization_codes (code_hash, client_id, params_json, expires_at_ms) values (?, ?, ?, ?)") + .run(codeHash, record.clientId, serializeAuthorizationParams(record.params), record.expiresAtMs); + } + + deleteAuthorizationCode(codeHash: string): void { + this.database.sqlite + .prepare("delete from oauth_authorization_codes where code_hash = ?") + .run(codeHash); + } + + getAccessToken(tokenHash: string): TokenRecord | undefined { + return this.getToken("access", tokenHash); + } + + saveAccessToken(tokenHash: string, record: TokenRecord): void { + this.saveToken("access", tokenHash, record); + } + + deleteAccessToken(tokenHash: string): void { + this.deleteToken("access", tokenHash); + } + + getRefreshToken(tokenHash: string): TokenRecord | undefined { + return this.getToken("refresh", tokenHash); + } + + saveRefreshToken(tokenHash: string, record: TokenRecord): void { + this.saveToken("refresh", tokenHash, record); + } + + deleteRefreshToken(tokenHash: string): void { + this.deleteToken("refresh", tokenHash); + } + + revokeToken(tokenHash: string): void { + this.database.sqlite + .prepare("delete from oauth_tokens where token_hash = ?") + .run(tokenHash); + } + + getConsent(key: string): ConsentRecord | undefined { + const row = this.database.sqlite + .prepare("select client_id, redirect_uri, resource, scopes_json, approved_at from oauth_consents where consent_key = ?") + .get(key) as { + client_id: string; + redirect_uri: string; + resource: string; + scopes_json: string; + approved_at: number; + } | undefined; + return row + ? { + clientId: row.client_id, + redirectUri: row.redirect_uri, + resource: row.resource, + scopes: JSON.parse(row.scopes_json) as string[], + approvedAt: row.approved_at, + } + : undefined; + } + + saveConsent(key: string, record: ConsentRecord): void { + this.database.sqlite + .prepare("insert or replace into oauth_consents (consent_key, client_id, redirect_uri, resource, scopes_json, approved_at) values (?, ?, ?, ?, ?, ?)") + .run(key, record.clientId, record.redirectUri, record.resource, JSON.stringify(record.scopes), record.approvedAt); + } + + deleteClientConsents(clientId: string): void { + this.database.sqlite + .prepare("delete from oauth_consents where client_id = ?") + .run(clientId); + } + + resetState(): void { + this.database.sqlite.exec(` + delete from oauth_authorization_codes; + delete from oauth_tokens; + delete from oauth_consents; + `); + } + + close(): void { + this.database.close(); + } + + private migrate(): void { + this.database.sqlite.exec(` + create table if not exists oauth_clients ( + client_id text primary key, + client_json text not null, + created_at integer not null + ); + create table if not exists oauth_authorization_codes ( + code_hash text primary key, + client_id text not null, + params_json text not null, + expires_at_ms integer not null, + foreign key (client_id) references oauth_clients(client_id) on delete cascade + ); + create index if not exists oauth_authorization_codes_expiry_idx + on oauth_authorization_codes(expires_at_ms); + create table if not exists oauth_tokens ( + token_hash text not null, + token_kind text not null, + client_id text not null, + scopes_json text not null, + expires_at integer not null, + resource text, + primary key (token_hash, token_kind), + foreign key (client_id) references oauth_clients(client_id) on delete cascade + ); + create index if not exists oauth_tokens_expiry_idx on oauth_tokens(expires_at); + create table if not exists oauth_consents ( + consent_key text primary key, + client_id text not null, + redirect_uri text not null, + resource text not null, + scopes_json text not null, + approved_at integer not null, + foreign key (client_id) references oauth_clients(client_id) on delete cascade + ); + create index if not exists oauth_consents_client_idx on oauth_consents(client_id); + create table if not exists oauth_metadata ( + key text primary key, + value text not null + ); + `); + } + + private deleteExpired(): void { + this.database.sqlite + .prepare("delete from oauth_authorization_codes where expires_at_ms < ?") + .run(Date.now()); + this.database.sqlite + .prepare("delete from oauth_tokens where expires_at < ?") + .run(Math.floor(Date.now() / 1000)); + } + + private getToken(kind: TokenKind, tokenHash: string): TokenRecord | undefined { + const row = this.database.sqlite + .prepare("select client_id, scopes_json, expires_at, resource from oauth_tokens where token_hash = ? and token_kind = ?") + .get(tokenHash, kind) as { + client_id: string; + scopes_json: string; + expires_at: number; + resource: string | null; + } | undefined; + if (!row) return undefined; + if (row.expires_at < Math.floor(Date.now() / 1000)) { + this.deleteToken(kind, tokenHash); + return undefined; + } + return { + clientId: row.client_id, + scopes: JSON.parse(row.scopes_json) as string[], + expiresAt: row.expires_at, + resource: row.resource ? parseStoredResource(row.resource) : undefined, + }; + } + + private saveToken(kind: TokenKind, tokenHash: string, record: TokenRecord): void { + this.database.sqlite + .prepare("insert or replace into oauth_tokens (token_hash, token_kind, client_id, scopes_json, expires_at, resource) values (?, ?, ?, ?, ?, ?)") + .run(tokenHash, kind, record.clientId, JSON.stringify(record.scopes), record.expiresAt, record.resource?.href ?? null); + } + + private deleteToken(kind: TokenKind, tokenHash: string): void { + this.database.sqlite + .prepare("delete from oauth_tokens where token_hash = ? and token_kind = ?") + .run(tokenHash, kind); + } + + private importLegacyState(statePath: string | undefined): void { + if (!statePath || !existsSync(statePath)) return; + + let state: StoredOAuthState; + try { + const raw = readFileSync(statePath, "utf8"); + if (!raw.trim()) return; + state = JSON.parse(raw) as StoredOAuthState; + } catch { + return; + } + + const mtime = statSync(statePath).mtimeMs; + const imported = this.database.sqlite + .prepare("select value from oauth_metadata where key = ?") + .get("legacy_json_import_mtime") as { value: string } | undefined; + if (imported?.value === String(mtime)) return; + + const now = Math.floor(Date.now() / 1000); + const transaction = this.database.sqlite.transaction(() => { + for (const client of state.clients ?? []) { + if (typeof client?.client_id !== "string") continue; + this.saveClient(client); + } + for (const record of state.accessTokens ?? []) { + if (!isStoredTokenRecord(record) || record.expiresAt < now) continue; + this.saveAccessToken(record.tokenHash, { + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: parseStoredResource(record.resource), + }); + } + for (const record of state.refreshTokens ?? []) { + if (!isStoredTokenRecord(record) || record.expiresAt < now) continue; + this.saveRefreshToken(record.tokenHash, { + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: parseStoredResource(record.resource), + }); + } + for (const record of state.approvedConsents ?? []) { + if (!isStoredConsentRecord(record)) continue; + const scopes = normalizeScopes(record.scopes); + this.saveConsent(consentKey(record.clientId, record.redirectUri, record.resource, scopes), { + clientId: record.clientId, + redirectUri: record.redirectUri, + resource: record.resource, + scopes, + approvedAt: record.approvedAt, + }); + } + this.database.sqlite + .prepare("insert or replace into oauth_metadata (key, value) values (?, ?)") + .run("legacy_json_import_mtime", String(mtime)); + }); + transaction(); + } +} + +export function consentKey(clientId: string, redirectUri: string, resource: string, scopes: string[]): string { + return [clientId, redirectUri, resource, normalizeScopes(scopes).join(" ")].join("\n"); +} + +function normalizeScopes(scopes: string[]): string[] { + return Array.from(new Set(scopes)).sort(); +} + +function serializeAuthorizationParams(params: AuthorizationParams): string { + return JSON.stringify({ ...params, resource: params.resource?.href }); +} + +function deserializeAuthorizationParams(value: string): AuthorizationParams { + const parsed = JSON.parse(value) as SerializedAuthorizationParams; + return { + ...parsed, + resource: parsed.resource ? new URL(parsed.resource) : undefined, + }; +} + +function parseStoredResource(resource: string | undefined): URL | undefined { + if (!resource) return undefined; + try { + return new URL(resource); + } catch { + return undefined; + } +} + +function isStoredTokenRecord(record: StoredTokenRecord): record is Required> & { resource?: string } { + return ( + typeof record?.tokenHash === "string" && + typeof record?.clientId === "string" && + Array.isArray(record?.scopes) && + typeof record?.expiresAt === "number" + ); +} + +function isStoredConsentRecord(record: StoredConsentRecord): record is Required { + return ( + typeof record?.clientId === "string" && + typeof record?.redirectUri === "string" && + typeof record?.resource === "string" && + Array.isArray(record?.scopes) && + typeof record?.approvedAt === "number" + ); +} diff --git a/src/server.ts b/src/server.ts index b13b84c..0c82818 100644 --- a/src/server.ts +++ b/src/server.ts @@ -42,6 +42,13 @@ import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { validateShellCommand } from "./shell-policy.js"; import { formatPathForPrompt } from "./skills.js"; +import { + installSkill, + listInstalledSkills, + removeInstalledSkill, + type InstalledSkillRecord, + type SkillInstallSource, +} from "./skill-manager.js"; import { contentStats, contentText, toolError, type ToolContent } from "./tool-result.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; @@ -219,6 +226,15 @@ const workspaceSkillOutputSchema = z.object({ path: z.string(), }); +const installedSkillOutputSchema = z.object({ + name: z.string(), + description: z.string(), + scope: z.enum(["workspace", "global"]), + path: z.string(), + removable: z.boolean(), + sourceType: z.enum(["workspace-installed", "global-installed"]), +}); + const workspaceAgentsFileOutputSchema = z.object({ path: z.string(), content: z.string(), @@ -552,8 +568,9 @@ function createMcpServer( inputSchema: { path: z .string() + .optional() .describe( - "Absolute path, or a leading-tilde home path such as ~/project, to a local project directory inside an allowed root.", + "Absolute path, or a leading-tilde home path such as ~/project, to a local project directory inside an allowed root. Omit this only when the server session has a configured default workspace.", ), mode: z .enum(["checkout", "worktree"]) @@ -719,6 +736,243 @@ function createMcpServer( }, ); + registerAppTool( + server, + "install_skill", + { + title: "Install skill", + description: + "Install a third-party skill into the current workspace or the global agent skill directory.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + scope: z.enum(["workspace", "global"]).optional(), + source: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("local"), + path: z.string(), + }), + z.object({ + kind: z.literal("github"), + repo: z.string(), + path: z.string(), + ref: z.string().optional(), + }), + z.object({ + kind: z.literal("github_url"), + url: z.string(), + }), + ]), + }, + outputSchema: { + result: z.string(), + status: z.literal("installed"), + scope: z.enum(["workspace", "global"]), + skill: installedSkillOutputSchema, + sourceSummary: z.string(), + visibleInCurrentWorkspace: z.boolean(), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: WRITE_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, scope, source }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + try { + const installed = await installSkill({ + config, + workspaceRoot: workspace.root, + scope: scope ?? "workspace", + source: source as SkillInstallSource, + }); + const refreshed = workspaces.refreshWorkspaceSkills(workspaceId); + const visible = refreshed.skills.some((skill) => skill.name === installed.name); + const content = [textBlock(`Installed skill ${installed.name} (${installed.scope}).`)]; + + logToolCall(config, { + tool: "install_skill", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "install_skill", + card: { + workspaceId, + status: "installed", + path: installed.path, + summary: { + scope: installed.scope, + visibleInCurrentWorkspace: visible, + }, + payload: { content }, + }, + }, + structuredContent: { + result: contentText(content), + status: "installed" as const, + scope: installed.scope, + skill: toInstalledSkillOutput(installed), + sourceSummary: installed.sourceSummary, + visibleInCurrentWorkspace: visible, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "install_skill", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "list_installed_skills", + { + title: "List installed skills", + description: + "List installed skills for the current workspace and optionally the global agent skill directory.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + scope: z.enum(["workspace", "global", "all"]).optional(), + }, + outputSchema: { + result: z.string(), + skills: z.array(installedSkillOutputSchema), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, scope }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + try { + const skills = await listInstalledSkills({ + config, + workspaceRoot: workspace.root, + scope: scope ?? "workspace", + }); + const content = [textBlock(formatInstalledSkillsList(skills))]; + + logToolCall(config, { + tool: "list_installed_skills", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "list_installed_skills", + card: { + workspaceId, + summary: { + skills: skills.length, + }, + payload: { content }, + }, + }, + structuredContent: { + result: contentText(content), + skills: skills.map(toInstalledSkillOutput), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "list_installed_skills", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "remove_skill", + { + title: "Remove skill", + description: + "Remove an installed skill from the current workspace or the global agent skill directory.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + scope: z.enum(["workspace", "global"]).optional(), + name: z.string(), + }, + outputSchema: { + result: z.string(), + status: z.literal("removed"), + scope: z.enum(["workspace", "global"]), + name: z.string(), + removedPath: z.string(), + visibleInCurrentWorkspace: z.boolean(), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: WRITE_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, scope, name }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + try { + const removed = await removeInstalledSkill({ + config, + workspaceRoot: workspace.root, + scope: scope ?? "workspace", + name, + }); + const refreshed = workspaces.refreshWorkspaceSkills(workspaceId); + const visible = refreshed.skills.some((skill) => skill.name === removed.name); + const content = [textBlock(`Removed skill ${removed.name} (${removed.scope}).`)]; + + logToolCall(config, { + tool: "remove_skill", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "remove_skill", + card: { + workspaceId, + status: "removed", + path: removed.removedPath, + summary: { + scope: removed.scope, + visibleInCurrentWorkspace: visible, + }, + payload: { content }, + }, + }, + structuredContent: { + result: contentText(content), + status: "removed" as const, + scope: removed.scope, + name: removed.name, + removedPath: removed.removedPath, + visibleInCurrentWorkspace: visible, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "remove_skill", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + registerAppTool( server, "set_collaboration_mode", @@ -2334,7 +2588,7 @@ export function createServer(config = loadConfig()): RunningServer { ...(allowedHosts ? { allowedHosts } : {}), }); const transports = new Map(); - const mcpUrl = new URL("/mcp", config.publicBaseUrl); + const mcpUrl = new URL(config.mcpPath, config.publicBaseUrl); const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl); const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl); const bearerAuth = requireBearerAuth({ @@ -2415,7 +2669,7 @@ export function createServer(config = loadConfig()): RunningServer { res.json({ ok: true, name: "devspace" }); }); - app.all("/mcp", async (req, res) => { + app.all(config.mcpPath, async (req, res) => { const requestId = res.locals.requestId as string | undefined; const sessionId = req.header("mcp-session-id"); const initializeRequest = req.method === "POST" && isInitializeRequest(req.body); @@ -2691,6 +2945,24 @@ function toStructuredUserInputRecord(record: WorkspaceUserInputRecord): { }; } +function toInstalledSkillOutput(skill: InstalledSkillRecord) { + return { + name: skill.name, + description: skill.description, + scope: skill.scope, + path: skill.path, + removable: skill.removable, + sourceType: skill.sourceType, + }; +} + +function formatInstalledSkillsList(skills: InstalledSkillRecord[]): string { + if (skills.length === 0) return "No installed skills."; + return skills + .map((skill) => `${skill.name} (${skill.scope})\nPath: ${skill.path}\nDescription: ${skill.description}`) + .join("\n\n"); +} + async function isMainModule(): Promise { if (!process.argv[1]) return false; @@ -2703,7 +2975,7 @@ if (await isMainModule()) { const { app, config } = createServer(); app.listen(config.port, config.host, () => { console.log( - `devspace listening on http://${config.host}:${config.port}/mcp`, + `devspace listening on http://${config.host}:${config.port}${config.mcpPath}`, ); console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); console.log("auth: oauth owner-token flow required"); diff --git a/src/service.test.ts b/src/service.test.ts new file mode 100644 index 0000000..972eb85 --- /dev/null +++ b/src/service.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "./config.js"; +import { createServiceManager } from "./service/manager.js"; +import { buildLaunchAgentPlist, buildSystemdUnit } from "./service/templates.js"; +import type { CommandRunner } from "./service/runner.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-service-test-")); + +try { + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: root, + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }); + + const systemdUnit = buildSystemdUnit({ + cliEntrypoint: "/tmp/devspace/dist/cli.js", + config, + }); + assert.match(systemdUnit, /ExecStart=/); + assert.match(systemdUnit, /Restart=on-failure/); + assert.match(systemdUnit, /devspace\.out\.log/); + + const launchdPlist = buildLaunchAgentPlist({ + cliEntrypoint: "/tmp/devspace/dist/cli.js", + config, + }); + assert.match(launchdPlist, /ProgramArguments/); + assert.match(launchdPlist, /service-run/); + assert.match(launchdPlist, /devspace\.err\.log/); + + const runner = createMockRunner(); + const manager = createServiceManager({ + config, + cliEntrypoint: "/tmp/devspace/dist/cli.js", + runner, + }); + const status = await manager.status(); + assert.equal(typeof status.installed, "boolean"); + assert.equal(status.endpoint?.endsWith(config.mcpPath), true); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +function createMockRunner(): CommandRunner { + return { + async exec(command, args) { + if (command === "systemctl" && args.includes("is-enabled")) { + return { stdout: "enabled", stderr: "", exitCode: 0 }; + } + if (command === "systemctl" && args.includes("is-active")) { + return { stdout: "active", stderr: "", exitCode: 0 }; + } + if (command === "systemctl") { + return { stdout: "", stderr: "", exitCode: 0 }; + } + if (command === "launchctl") { + return { stdout: "", stderr: "", exitCode: 0 }; + } + if (command === "schtasks.exe") { + return { stdout: "Status: Running\nScheduled Task State: Enabled\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + }; +} diff --git a/src/service/manager.ts b/src/service/manager.ts new file mode 100644 index 0000000..e9da6f7 --- /dev/null +++ b/src/service/manager.ts @@ -0,0 +1,451 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; +import type { ServerConfig } from "../config.js"; +import type { DevspaceUserConfig } from "../user-config.js"; +import { writeDevspaceConfig } from "../user-config.js"; +import { defaultCommandRunner, type CommandRunner } from "./runner.js"; +import { buildLaunchAgentPlist, buildSystemdUnit, buildServiceCommand, devspaceLogDir } from "./templates.js"; +import type { + ServiceDoctorResult, + ServiceInstallOptions, + ServiceManager, + ServiceManagerKind, + ServiceResult, + ServiceStatus, +} from "./types.js"; + +const SYSTEMD_SERVICE_NAME = "devspace.service"; +const LAUNCHD_LABEL = "com.devspace.server"; +const WINDOWS_TASK_NAME = "DevSpace MCP Server"; + +interface ManagerContext { + config: ServerConfig; + cliEntrypoint: string; + runner?: CommandRunner; +} + +export function createServiceManager(context: ManagerContext): ServiceManager { + const kind = detectServiceManagerKind(); + const runner = context.runner ?? defaultCommandRunner; + const base = { + config: context.config, + cliEntrypoint: context.cliEntrypoint, + runner, + }; + + switch (kind) { + case "systemd-user": + return createSystemdUserManager(base); + case "launchd": + return createLaunchdManager(base); + case "windows-task-scheduler": + case "wsl-task-scheduler-fallback": + return createWindowsTaskManager(base, kind); + default: + return createUnsupportedManager(base.config); + } +} + +export async function restartServiceIfRunning( + manager: ServiceManager, +): Promise<{ restarted: boolean; message?: string }> { + const status = await manager.status(); + if (!status.installed || !status.running) { + return { + restarted: false, + message: status.installed + ? "Config saved. DevSpace service is installed but not running; changes will apply on next start." + : "Config saved. Changes will apply the next time DevSpace starts.", + }; + } + + const result = await manager.restart(); + if (!result.ok) { + throw new Error(`Config saved, but automatic service restart failed: ${result.message}`); + } + + return { restarted: true }; +} + +export function updateServiceConfigMetadata( + config: DevspaceUserConfig, + manager: ServiceManager, + autostart: boolean, +): DevspaceUserConfig { + writeDevspaceConfig({ + ...config, + service: { + ...(config.service ?? {}), + manager: manager.kind, + autostart, + }, + }); + return config; +} + +export function detectServiceManagerKind(): ServiceManagerKind { + if (platform() === "darwin") return "launchd"; + if (platform() === "win32") return "windows-task-scheduler"; + if (process.env.WSL_DISTRO_NAME) { + return process.env.SYSTEMD_EXEC_PID ? "systemd-user" : "wsl-task-scheduler-fallback"; + } + if (platform() === "linux") { + return process.env.SYSTEMD_EXEC_PID ? "systemd-user" : "unsupported"; + } + return "unsupported"; +} + +function createUnsupportedManager(config: ServerConfig): ServiceManager { + return { + kind: "unsupported", + serviceName: "devspace", + async isSupported() { + return false; + }, + async install() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async uninstall() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async enable() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async disable() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async start() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async stop() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async restart() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async status() { + return baseStatus("unsupported", "devspace", config); + }, + async logs() { + throw new Error(unsupportedMessage()); + }, + async doctor() { + return { + manager: "unsupported", + checks: [{ level: "warn", message: unsupportedMessage() }], + }; + }, + }; +} + +function createSystemdUserManager(context: Required): ServiceManager { + const unitPath = join(homedir(), ".config", "systemd", "user", SYSTEMD_SERVICE_NAME); + return { + kind: "systemd-user", + serviceName: SYSTEMD_SERVICE_NAME, + async isSupported() { + const result = await context.runner.exec("systemctl", ["--user", "--version"]); + return result.exitCode === 0; + }, + async install(options) { + mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true }); + mkdirSync(devspaceLogDir(), { recursive: true }); + writeFileSync(unitPath, buildSystemdUnit({ cliEntrypoint: context.cliEntrypoint, config: context.config }), "utf8"); + await context.runner.exec("systemctl", ["--user", "daemon-reload"]); + if (options?.autostart) { + await context.runner.exec("systemctl", ["--user", "enable", SYSTEMD_SERVICE_NAME]); + await context.runner.exec("systemctl", ["--user", "restart", SYSTEMD_SERVICE_NAME]); + } + return { + ok: true, + manager: "systemd-user", + message: `Installed ${SYSTEMD_SERVICE_NAME} at ${unitPath}`, + }; + }, + async uninstall() { + await context.runner.exec("systemctl", ["--user", "disable", SYSTEMD_SERVICE_NAME]); + await context.runner.exec("systemctl", ["--user", "stop", SYSTEMD_SERVICE_NAME]); + if (existsSync(unitPath)) { + rmSync(unitPath, { force: true }); + } + await context.runner.exec("systemctl", ["--user", "daemon-reload"]); + return { + ok: true, + manager: "systemd-user", + message: `Uninstalled ${SYSTEMD_SERVICE_NAME}`, + }; + }, + async enable() { + return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "enable", SYSTEMD_SERVICE_NAME], "Enabled service"); + }, + async disable() { + return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "disable", SYSTEMD_SERVICE_NAME], "Disabled service"); + }, + async start() { + return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "start", SYSTEMD_SERVICE_NAME], "Started service"); + }, + async stop() { + return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "stop", SYSTEMD_SERVICE_NAME], "Stopped service"); + }, + async restart() { + return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "restart", SYSTEMD_SERVICE_NAME], "Restarted service"); + }, + async status() { + const installed = existsSync(unitPath); + const enabled = installed && (await context.runner.exec("systemctl", ["--user", "is-enabled", SYSTEMD_SERVICE_NAME])).exitCode === 0; + const running = installed && (await context.runner.exec("systemctl", ["--user", "is-active", SYSTEMD_SERVICE_NAME])).exitCode === 0; + return { + ...baseStatus("systemd-user", SYSTEMD_SERVICE_NAME, context.config), + installed, + enabled, + running, + logPath: join(devspaceLogDir(), "devspace.out.log"), + }; + }, + async logs(options) { + const logPath = join(devspaceLogDir(), "devspace.out.log"); + return readTail(logPath, options?.tail ?? 200); + }, + async doctor() { + const status = await this.status(); + return { + manager: "systemd-user", + checks: [ + { + level: (await this.isSupported()) ? "pass" : "warn", + message: (await this.isSupported()) ? "systemd user service is available" : "systemd user service is unavailable", + }, + { + level: status.installed ? "pass" : "info", + message: status.installed ? "DevSpace unit is installed" : "DevSpace unit is not installed", + }, + { + level: status.running ? "pass" : "warn", + message: status.running ? "DevSpace service is running" : "DevSpace service is not running", + }, + ], + }; + }, + }; +} + +function createLaunchdManager(context: Required): ServiceManager { + const plistPath = join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`); + return { + kind: "launchd", + serviceName: LAUNCHD_LABEL, + async isSupported() { + return true; + }, + async install() { + mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true }); + mkdirSync(devspaceLogDir(), { recursive: true }); + writeFileSync(plistPath, buildLaunchAgentPlist({ cliEntrypoint: context.cliEntrypoint, config: context.config }), "utf8"); + const bootstrap = await context.runner.exec("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath]); + if (bootstrap.exitCode !== 0 && !bootstrap.stderr.includes("already bootstrapped")) { + return { + ok: false, + manager: "launchd", + message: [ + "LaunchAgent file was written, but launchctl could not start it.", + bootstrap.stderr.trim() || bootstrap.stdout.trim() || "Failed to bootstrap LaunchAgent.", + ].filter(Boolean).join(" "), + }; + } + return { ok: true, manager: "launchd", message: `Installed LaunchAgent at ${plistPath}` }; + }, + async uninstall() { + await context.runner.exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); + if (existsSync(plistPath)) { + rmSync(plistPath, { force: true }); + } + return { ok: true, manager: "launchd", message: "Uninstalled service" }; + }, + async enable() { + if (!existsSync(plistPath)) { + return { ok: false, manager: "launchd", message: "LaunchAgent is not installed" }; + } + return execServiceResult(context.runner, "launchd", "launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath], "Enabled service"); + }, + async disable() { + return execServiceResult(context.runner, "launchd", "launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`], "Disabled service"); + }, + async start() { + if (!existsSync(plistPath)) { + return { ok: false, manager: "launchd", message: "LaunchAgent is not installed" }; + } + const kickstart = await context.runner.exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); + if (kickstart.exitCode === 0) { + return { ok: true, manager: "launchd", message: "Started service" }; + } + return execServiceResult(context.runner, "launchd", "launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath], "Started service"); + }, + async stop() { + return execServiceResult(context.runner, "launchd", "launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`], "Stopped service"); + }, + async restart() { + if (!existsSync(plistPath)) { + return { ok: false, manager: "launchd", message: "LaunchAgent is not installed" }; + } + const result = await context.runner.exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); + if (result.exitCode === 0) { + return { ok: true, manager: "launchd", message: "Restarted service" }; + } + await this.stop(); + return this.start(); + }, + async status() { + const installed = existsSync(plistPath); + const result = installed + ? await context.runner.exec("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]) + : { stdout: "", stderr: "", exitCode: 1 }; + return { + ...baseStatus("launchd", LAUNCHD_LABEL, context.config), + installed, + enabled: installed, + running: result.exitCode === 0, + logPath: join(devspaceLogDir(), "devspace.out.log"), + }; + }, + async logs(options) { + return readTail(join(devspaceLogDir(), "devspace.out.log"), options?.tail ?? 200); + }, + async doctor() { + const status = await this.status(); + return { + manager: "launchd", + checks: [ + { level: "pass", message: "launchd is available" }, + { + level: status.installed ? "pass" : "info", + message: status.installed ? "LaunchAgent is installed" : "LaunchAgent is not installed", + }, + { + level: status.running ? "pass" : "warn", + message: status.running ? "DevSpace service is running" : "DevSpace service is not running", + }, + { + level: existsSync(devspaceLogDir()) ? "pass" : "warn", + message: existsSync(devspaceLogDir()) ? "Log directory is available" : "Log directory is missing", + }, + ], + }; + }, + }; +} + +function createWindowsTaskManager( + context: Required, + kind: "windows-task-scheduler" | "wsl-task-scheduler-fallback", +): ServiceManager { + return { + kind, + serviceName: WINDOWS_TASK_NAME, + async isSupported() { + const result = await context.runner.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME]); + return result.exitCode === 0 || result.exitCode === 1; + }, + async install() { + const spec = buildServiceCommand(context.cliEntrypoint); + const taskCommand = `"${spec.command}" ${spec.args.map(windowsQuote).join(" ")}`; + return execServiceResult( + context.runner, + kind, + "schtasks.exe", + ["/Create", "/F", "/SC", "ONLOGON", "/TN", WINDOWS_TASK_NAME, "/TR", taskCommand], + `Installed task ${WINDOWS_TASK_NAME}`, + ); + }, + async uninstall() { + return execServiceResult(context.runner, kind, "schtasks.exe", ["/Delete", "/F", "/TN", WINDOWS_TASK_NAME], `Deleted task ${WINDOWS_TASK_NAME}`); + }, + async enable() { + return { ok: true, manager: kind, message: "Task Scheduler autostart is configured during install" }; + }, + async disable() { + return execServiceResult(context.runner, kind, "schtasks.exe", ["/Change", "/TN", WINDOWS_TASK_NAME, "/DISABLE"], "Disabled task"); + }, + async start() { + return execServiceResult(context.runner, kind, "schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME], "Started task"); + }, + async stop() { + return execServiceResult(context.runner, kind, "schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME], "Stopped task"); + }, + async restart() { + await this.stop(); + return this.start(); + }, + async status() { + const result = await context.runner.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"]); + const installed = result.exitCode === 0; + const running = /Status:\s+Running/i.test(result.stdout); + const enabled = !/Scheduled Task State:\s+Disabled/i.test(result.stdout); + return { + ...baseStatus(kind, WINDOWS_TASK_NAME, context.config), + installed, + enabled: installed && enabled, + running: installed && running, + logPath: join(devspaceLogDir(), "devspace.out.log"), + }; + }, + async logs(options) { + return readTail(join(devspaceLogDir(), "devspace.out.log"), options?.tail ?? 200); + }, + async doctor() { + const status = await this.status(); + return { + manager: kind, + checks: [ + { + level: status.installed ? "pass" : "info", + message: status.installed ? "Scheduled task is installed" : "Scheduled task is not installed", + }, + { + level: status.running ? "pass" : "warn", + message: status.running ? "DevSpace task is running" : "DevSpace task is not running", + }, + ], + }; + }, + }; +} + +function baseStatus(kind: ServiceManagerKind, serviceName: string, config: ServerConfig): ServiceStatus { + return { + installed: false, + enabled: false, + running: false, + manager: kind, + serviceName, + endpoint: new URL(config.mcpPath, `http://${config.host}:${config.port}`).toString(), + publicBaseUrl: config.publicBaseUrl, + }; +} + +async function execServiceResult( + runner: CommandRunner, + manager: ServiceManagerKind, + command: string, + args: string[], + successMessage: string, +): Promise { + const result = await runner.exec(command, args); + return result.exitCode === 0 + ? { ok: true, manager, message: successMessage } + : { ok: false, manager, message: result.stderr.trim() || result.stdout.trim() || successMessage }; +} + +async function readTail(path: string, tail: number): Promise { + if (!existsSync(path)) return ""; + const lines = readFileSync(path, "utf8").split(/\r?\n/); + return lines.slice(Math.max(0, lines.length - tail)).join("\n"); +} + +function unsupportedMessage(): string { + return "DevSpace service management is not supported on this platform."; +} + +function windowsQuote(value: string): string { + return `"${value.replaceAll('"', '\\"')}"`; +} diff --git a/src/service/runner.ts b/src/service/runner.ts new file mode 100644 index 0000000..8ba7816 --- /dev/null +++ b/src/service/runner.ts @@ -0,0 +1,40 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface CommandRunner { + exec(command: string, args: string[], options?: { cwd?: string }): Promise<{ + stdout: string; + stderr: string; + exitCode: number; + }>; +} + +export const defaultCommandRunner: CommandRunner = { + async exec(command, args, options) { + try { + const result = await execFileAsync(command, args, { + cwd: options?.cwd, + encoding: "utf8", + windowsHide: true, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: 0, + }; + } catch (error) { + const execError = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + code?: string | number; + }; + return { + stdout: execError.stdout ?? "", + stderr: execError.stderr ?? execError.message, + exitCode: typeof execError.code === "number" ? execError.code : 1, + }; + } + }, +}; diff --git a/src/service/templates.ts b/src/service/templates.ts new file mode 100644 index 0000000..7f6ec44 --- /dev/null +++ b/src/service/templates.ts @@ -0,0 +1,130 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ServerConfig } from "../config.js"; + +export interface ServiceCommandSpec { + command: string; + args: string[]; +} + +export function devspaceLogDir(): string { + return join(homedir(), ".devspace", "logs"); +} + +export function buildServiceCommand(cliEntrypoint: string): ServiceCommandSpec { + return { + command: process.execPath, + args: [cliEntrypoint, "service-run"], + }; +} + +export function buildServiceEnvironment(): Record { + const environment: Record = {}; + const allowed = [ + "DEVSPACE_CONFIG_DIR", + "DEVSPACE_PUBLIC_BASE_URL", + "DEVSPACE_ALLOWED_HOSTS", + "DEVSPACE_LOG_LEVEL", + "DEVSPACE_LOG_FORMAT", + "DEVSPACE_LOG_REQUESTS", + "DEVSPACE_LOG_ASSETS", + "DEVSPACE_LOG_TOOL_CALLS", + "DEVSPACE_LOG_SHELL_COMMANDS", + "DEVSPACE_TRUST_PROXY", + "PATH", + ]; + + for (const key of allowed) { + const value = process.env[key]; + if (value) environment[key] = value; + } + + if (!environment.DEVSPACE_CONFIG_DIR) { + environment.DEVSPACE_CONFIG_DIR = join(homedir(), ".devspace"); + } + + return environment; +} + +export function buildSystemdUnit(options: { + cliEntrypoint: string; + config: ServerConfig; +}): string { + const spec = buildServiceCommand(options.cliEntrypoint); + const logDir = devspaceLogDir(); + const execStart = [spec.command, ...spec.args].map(escapeSystemdArg).join(" "); + const environment = buildServiceEnvironment(); + + return [ + "[Unit]", + "Description=DevSpace MCP Server", + "After=network.target", + "", + "[Service]", + "Type=simple", + `ExecStart=${execStart}`, + "Restart=on-failure", + ...Object.entries(environment).map(([key, value]) => `Environment=${key}=${escapeEnvValue(value)}`), + `StandardOutput=append:${join(logDir, "devspace.out.log")}`, + `StandardError=append:${join(logDir, "devspace.err.log")}`, + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); +} + +export function buildLaunchAgentPlist(options: { + cliEntrypoint: string; + config: ServerConfig; + label?: string; +}): string { + const spec = buildServiceCommand(options.cliEntrypoint); + const logDir = devspaceLogDir(); + const label = options.label ?? "com.devspace.server"; + const environment = buildServiceEnvironment(); + + return ` + + + + Label + ${xmlEscape(label)} + ProgramArguments + + ${[spec.command, ...spec.args].map((arg) => `${xmlEscape(arg)}`).join("\n ")} + + RunAtLoad + + KeepAlive + + EnvironmentVariables + + ${Object.entries(environment).map(([key, value]) => `${xmlEscape(key)}\n ${xmlEscape(value)}`).join("\n ")} + + StandardOutPath + ${xmlEscape(join(logDir, "devspace.out.log"))} + StandardErrorPath + ${xmlEscape(join(logDir, "devspace.err.log"))} + + +`; +} + +function xmlEscape(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeEnvValue(value: string): string { + return value.replaceAll(" ", "\\ "); +} + +function escapeSystemdArg(value: string): string { + if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value; + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} diff --git a/src/service/types.ts b/src/service/types.ts new file mode 100644 index 0000000..27a7de4 --- /dev/null +++ b/src/service/types.ts @@ -0,0 +1,54 @@ +export type ServiceManagerKind = + | "systemd-user" + | "launchd" + | "windows-task-scheduler" + | "wsl-task-scheduler-fallback" + | "unsupported"; + +export interface ServiceInstallOptions { + autostart?: boolean; +} + +export interface ServiceResult { + ok: boolean; + manager: ServiceManagerKind; + message: string; +} + +export interface ServiceStatus { + installed: boolean; + enabled: boolean; + running: boolean; + manager: ServiceManagerKind; + serviceName: string; + logPath?: string; + endpoint?: string; + publicBaseUrl?: string; + pid?: number; + details?: Record; +} + +export interface ServiceDoctorResult { + manager: ServiceManagerKind; + checks: Array<{ + level: "pass" | "warn" | "info"; + message: string; + }>; +} + +export interface ServiceManager { + readonly kind: ServiceManagerKind; + readonly serviceName: string; + + isSupported(): Promise; + install(options?: ServiceInstallOptions): Promise; + uninstall(): Promise; + enable(): Promise; + disable(): Promise; + start(): Promise; + stop(): Promise; + restart(): Promise; + status(): Promise; + logs(options?: { tail?: number }): Promise; + doctor(): Promise; +} diff --git a/src/skill-manager.test.ts b/src/skill-manager.test.ts new file mode 100644 index 0000000..e255725 --- /dev/null +++ b/src/skill-manager.test.ts @@ -0,0 +1,176 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import assert from "node:assert/strict"; +import { loadConfig } from "./config.js"; +import { + installRootForScope, + installSkill, + listInstalledSkills, + parseGithubTreeUrl, + removeInstalledSkill, +} from "./skill-manager.js"; + +const execFileAsync = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "devspace-skill-manager-test-")); + +try { + const projectRoot = join(root, "project"); + const agentDir = join(root, "agent"); + const localSkill = join(root, "local-skill"); + const remoteRepo = join(root, "remote-skill-repo"); + + await mkdir(projectRoot, { recursive: true }); + await mkdir(join(agentDir, "skills"), { recursive: true }); + await mkdir(localSkill, { recursive: true }); + await writeFile( + join(localSkill, "SKILL.md"), + [ + "---", + "name: local-installed-skill", + "description: Installed from a local path.", + "---", + "", + "# Local Installed Skill", + ].join("\n"), + ); + await mkdir(join(localSkill, "references"), { recursive: true }); + await writeFile(join(localSkill, "references", "guide.md"), "hello\n"); + + await mkdir(remoteRepo, { recursive: true }); + await mkdir(join(remoteRepo, "skills", ".curated", "remote-installed-skill"), { recursive: true }); + await writeFile( + join(remoteRepo, "skills", ".curated", "remote-installed-skill", "SKILL.md"), + [ + "---", + "name: remote-installed-skill", + "description: Installed from a git repo.", + "---", + "", + "# Remote Installed Skill", + ].join("\n"), + ); + await execFileAsync("git", ["init"], { cwd: remoteRepo }); + await execFileAsync("git", ["config", "user.email", "devspace@example.com"], { cwd: remoteRepo }); + await execFileAsync("git", ["config", "user.name", "DevSpace Test"], { cwd: remoteRepo }); + await execFileAsync("git", ["add", "."], { cwd: remoteRepo }); + await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: remoteRepo }); + + const config = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: `${projectRoot},${root}`, + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + + const installedLocal = await installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: localSkill }, + localPathResolver: (path) => path, + }); + assert.equal(installedLocal.name, "local-installed-skill"); + assert.equal(installedLocal.scope, "workspace"); + assert.equal(installedLocal.path, join(projectRoot, "skills", "installed", "local-installed-skill")); + assert.equal( + await readFile(join(installedLocal.path, "references", "guide.md"), "utf8"), + "hello\n", + ); + + const listedWorkspace = await listInstalledSkills({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + }); + assert.deepEqual( + listedWorkspace.map((skill) => skill.name), + ["local-installed-skill"], + ); + + const installedGlobal = await installSkill({ + config, + workspaceRoot: projectRoot, + scope: "global", + source: { + kind: "github", + repo: "example/skills", + repoUrl: `file://${remoteRepo}`, + path: "skills/.curated/remote-installed-skill", + ref: "master", + }, + runGit: async (args) => { + await execFileAsync("git", args); + }, + }); + assert.equal(installedGlobal.scope, "global"); + assert.equal(installedGlobal.name, "remote-installed-skill"); + assert.equal(installedGlobal.path, join(agentDir, "skills", "remote-installed-skill")); + + const listedAll = await listInstalledSkills({ + config, + workspaceRoot: projectRoot, + scope: "all", + }); + assert.deepEqual( + listedAll.map((skill) => `${skill.scope}:${skill.name}`), + ["global:remote-installed-skill", "workspace:local-installed-skill"], + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: localSkill }, + localPathResolver: (path) => path, + }), + /already exists/, + ); + + const removedWorkspace = await removeInstalledSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + name: "local-installed-skill", + }); + assert.equal(removedWorkspace.name, "local-installed-skill"); + + const listedAfterRemove = await listInstalledSkills({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + }); + assert.deepEqual(listedAfterRemove, []); + + await assert.rejects( + () => + removeInstalledSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + name: "missing-skill", + }), + /not found/, + ); + + assert.deepEqual(parseGithubTreeUrl("https://github.com/openai/skills/tree/main/skills/.curated/research"), { + repo: "openai/skills", + ref: "main", + path: "skills/.curated/research", + }); + + assert.equal( + installRootForScope(config, projectRoot, "workspace"), + join(projectRoot, "skills", "installed"), + ); + assert.equal( + installRootForScope(config, projectRoot, "global"), + join(agentDir, "skills"), + ); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/skill-manager.ts b/src/skill-manager.ts new file mode 100644 index 0000000..a55048c --- /dev/null +++ b/src/skill-manager.ts @@ -0,0 +1,316 @@ +import { mkdtemp, mkdir, readFile, readdir, rm, stat, cp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import { assertAllowedPath, isPathInsideRoot } from "./roots.js"; +import type { ServerConfig } from "./config.js"; + +const execFileAsync = promisify(execFile); + +export type SkillScope = "workspace" | "global"; +export type SkillSourceType = "local" | "github" | "github_url"; +export type InstalledSkillSourceType = "workspace-installed" | "global-installed"; + +export type SkillInstallSource = + | { + kind: "local"; + path: string; + } + | { + kind: "github"; + repo: string; + path: string; + ref?: string; + repoUrl?: string; + } + | { + kind: "github_url"; + url: string; + }; + +export interface InstalledSkillRecord { + name: string; + description: string; + scope: SkillScope; + path: string; + removable: boolean; + sourceType: InstalledSkillSourceType; +} + +export interface InstalledSkillResult extends InstalledSkillRecord { + sourceSummary: string; +} + +export interface RemovedSkillResult { + name: string; + scope: SkillScope; + removedPath: string; +} + +interface GitRunner { + (args: string[]): Promise; +} + +interface ParsedSkillMetadata { + name: string; + description: string; + baseDir: string; +} + +export async function installSkill(options: { + config: ServerConfig; + workspaceRoot?: string; + scope: SkillScope; + source: SkillInstallSource; + githubBaseUrl?: string; + localPathResolver?: (path: string) => string; + runGit?: GitRunner; +}): Promise { + const sourceDir = await materializeSource(options.source, { + githubBaseUrl: options.githubBaseUrl, + localPathResolver: options.localPathResolver, + runGit: options.runGit, + }); + try { + const metadata = await readSkillMetadata(sourceDir.path); + const targetRoot = installRootForScope(options.config, options.workspaceRoot, options.scope); + const targetPath = join(targetRoot, metadata.name); + await mkdir(targetRoot, { recursive: true }); + + await ensurePathMissing(targetPath, metadata.name, options.scope); + await cp(metadata.baseDir, targetPath, { recursive: true, errorOnExist: true, force: false }); + + return { + name: metadata.name, + description: metadata.description, + scope: options.scope, + path: targetPath, + removable: true, + sourceType: options.scope === "workspace" ? "workspace-installed" : "global-installed", + sourceSummary: sourceDir.summary, + }; + } finally { + await sourceDir.dispose(); + } +} + +export async function removeInstalledSkill(options: { + config: ServerConfig; + workspaceRoot?: string; + scope: SkillScope; + name: string; +}): Promise { + validateSkillName(options.name); + const targetRoot = installRootForScope(options.config, options.workspaceRoot, options.scope); + const targetPath = resolve(targetRoot, options.name); + if (!isPathInsideRoot(targetPath, targetRoot)) { + throw new Error(`Refusing to remove skill outside installed root: ${options.name}`); + } + + const targetStats = await safeStat(targetPath); + if (!targetStats?.isDirectory()) { + throw new Error(`Installed skill not found: ${options.name}`); + } + + const metadata = await readSkillMetadata(targetPath); + if (metadata.name !== options.name) { + throw new Error(`Installed skill name mismatch for ${options.name}.`); + } + + await rm(targetPath, { recursive: true, force: false }); + return { + name: options.name, + scope: options.scope, + removedPath: targetPath, + }; +} + +export async function listInstalledSkills(options: { + config: ServerConfig; + workspaceRoot?: string; + scope: "workspace" | "global" | "all"; +}): Promise { + const scopes: SkillScope[] = + options.scope === "all" ? ["workspace", "global"] : [options.scope]; + const collected = await Promise.all( + scopes.map(async (scope) => listInstalledSkillsForScope(options.config, options.workspaceRoot, scope)), + ); + + return collected.flat().sort((a, b) => { + if (a.scope !== b.scope) return a.scope.localeCompare(b.scope); + return a.name.localeCompare(b.name); + }); +} + +export function resolveWorkspaceRoot(config: ServerConfig, workspacePath: string): string { + return assertAllowedPath(workspacePath, config.allowedRoots); +} + +export function installRootForScope( + config: ServerConfig, + workspaceRoot: string | undefined, + scope: SkillScope, +): string { + if (scope === "global") { + return resolve(config.agentDir, "skills"); + } + + if (!workspaceRoot) { + throw new Error("workspaceRoot is required for workspace-scoped skill operations."); + } + + return resolve(workspaceRoot, "skills", "installed"); +} + +async function listInstalledSkillsForScope( + config: ServerConfig, + workspaceRoot: string | undefined, + scope: SkillScope, +): Promise { + const root = installRootForScope(config, workspaceRoot, scope); + const rootStats = await safeStat(root); + if (!rootStats?.isDirectory()) return []; + + const entries = await readdir(root, { withFileTypes: true }); + const records: InstalledSkillRecord[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillDir = join(root, entry.name); + const metadata = await safeReadSkillMetadata(skillDir); + if (!metadata) continue; + records.push({ + name: metadata.name, + description: metadata.description, + scope, + path: skillDir, + removable: true, + sourceType: scope === "workspace" ? "workspace-installed" : "global-installed", + }); + } + + return records; +} + +async function readSkillMetadata(skillDir: string): Promise { + const skillFile = join(skillDir, "SKILL.md"); + const content = await readFile(skillFile, "utf8").catch(() => { + throw new Error(`Skill directory is missing SKILL.md: ${skillDir}`); + }); + const { frontmatter } = parseFrontmatter>(content); + const name = String(frontmatter.name ?? "").trim(); + const description = String(frontmatter.description ?? "").trim(); + + if (!name) { + throw new Error(`Skill frontmatter is missing name: ${skillFile}`); + } + if (!description) { + throw new Error(`Skill frontmatter is missing description: ${skillFile}`); + } + + validateSkillName(name); + + return { + name, + description, + baseDir: skillDir, + }; +} + +async function safeReadSkillMetadata(skillDir: string): Promise { + try { + return await readSkillMetadata(skillDir); + } catch { + return null; + } +} + +async function ensurePathMissing(targetPath: string, skillName: string, scope: SkillScope): Promise { + const existing = await safeStat(targetPath); + if (existing) { + throw new Error(`Installed skill already exists in ${scope} scope: ${skillName}`); + } +} + +function validateSkillName(name: string): void { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { + throw new Error(`Invalid skill name: ${name}`); + } +} + +async function safeStat(path: string) { + try { + return await stat(path); + } catch { + return null; + } +} + +async function materializeSource( + source: SkillInstallSource, + options: { + githubBaseUrl?: string; + localPathResolver?: (path: string) => string; + runGit?: GitRunner; + }, +): Promise<{ + path: string; + summary: string; + dispose: () => Promise; +}> { + if (source.kind === "local") { + const resolvedPath = options.localPathResolver ? options.localPathResolver(source.path) : resolve(source.path); + return { + path: resolvedPath, + summary: `local:${resolvedPath}`, + dispose: async () => {}, + }; + } + + const tempRoot = await mkdtemp(join(tmpdir(), "devspace-skill-")); + const checkoutRoot = join(tempRoot, "repo"); + const parsed = source.kind === "github_url" ? parseGithubTreeUrl(source.url) : source; + const repoBaseUrl = options.githubBaseUrl ?? "https://github.com/"; + const repoUrl = parsed.repoUrl ?? new URL(`${parsed.repo}.git`, repoBaseUrl).toString(); + const ref = parsed.ref ?? "main"; + const runGit = options.runGit ?? ((args: string[]) => execFileAsync("git", args).then(() => undefined)); + + try { + await runGit(["clone", "--depth", "1", "--filter=blob:none", "--sparse", "--branch", ref, repoUrl, checkoutRoot]); + await runGit(["-C", checkoutRoot, "sparse-checkout", "set", "--no-cone", parsed.path]); + } catch (error) { + await rm(tempRoot, { recursive: true, force: true }); + throw new Error( + `Failed to fetch GitHub skill from ${parsed.repo}:${parsed.path}${parsed.ref ? `@${parsed.ref}` : ""}.`, + ); + } + + const skillDir = join(checkoutRoot, parsed.path); + return { + path: skillDir, + summary: `github:${parsed.repo}/${parsed.path}${parsed.ref ? `@${parsed.ref}` : ""}`, + dispose: async () => { + await rm(tempRoot, { recursive: true, force: true }); + }, + }; +} + +export function parseGithubTreeUrl(url: string): { repo: string; path: string; ref?: string; repoUrl?: string } { + const parsed = new URL(url); + if (parsed.hostname !== "github.com") { + throw new Error(`Unsupported GitHub URL host: ${parsed.hostname}`); + } + + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length < 5 || parts[2] !== "tree") { + throw new Error(`Unsupported GitHub tree URL: ${url}`); + } + + return { + repo: `${parts[0]}/${parts[1]}`, + ref: parts[3], + path: parts.slice(4).join("/"), + }; +} diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 03585c9..c0f3d03 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -2,6 +2,9 @@ import type { App } from "@modelcontextprotocol/ext-apps"; export type ToolName = | "open_workspace" + | "install_skill" + | "list_installed_skills" + | "remove_skill" | "request_user_input" | "get_pending_user_input" | "answer_user_input" @@ -97,6 +100,9 @@ export interface ToolPayload { export function isToolName(value: unknown): value is ToolName { return ( value === "open_workspace" || + value === "install_skill" || + value === "list_installed_skills" || + value === "remove_skill" || value === "request_user_input" || value === "get_pending_user_input" || value === "answer_user_input" || diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index ca4a14d..b2b1bab 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -510,6 +510,12 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { switch (card.tool) { case "open_workspace": return { icon: folderIcon(), title: "Workspace", label, tone: "workspace" }; + case "install_skill": + return { icon: filePlusIcon(), title: "Install Skill", label, tone: "write" }; + case "list_installed_skills": + return { icon: filesIcon(), title: "Installed Skills", label, tone: "directory" }; + case "remove_skill": + return { icon: editIcon(), title: "Remove Skill", label, tone: "edit" }; case "request_user_input": return { icon: questionIcon(), title: "Request User Input", label, tone: "directory" }; case "get_pending_user_input": diff --git a/src/user-config.ts b/src/user-config.ts index dad8405..f25361f 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -3,14 +3,33 @@ import { existsSync, mkdirSync, readFileSync, + renameSync, + rmSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; export type TunnelMode = "cloudflare"; +export interface DevspaceServerUserConfig { + host?: string; + port?: number; + mcpPath?: string; + publicBaseUrl?: string | null; +} + +export interface DevspaceWorkspacesUserConfig { + allowed?: string[]; + default?: string | null; +} + +export interface DevspaceServiceUserConfig { + manager?: string; + autostart?: boolean; +} + export interface DevspaceUserConfig { host?: string; port?: number; @@ -21,6 +40,12 @@ export interface DevspaceUserConfig { worktreeRoot?: string; agentDir?: string; tunnel?: TunnelMode; + server?: DevspaceServerUserConfig; + workspaces?: DevspaceWorkspacesUserConfig; + service?: DevspaceServiceUserConfig; + allowedDirectories?: string[]; + publicUrl?: string | null; + baseUrl?: string | null; } export interface DevspaceAuthConfig { @@ -62,7 +87,7 @@ export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): Devspac authPath, configExists, authExists, - config: configExists ? readJsonFile(configPath) : {}, + config: configExists ? normalizeDevspaceUserConfig(readJsonFile(configPath)) : {}, auth: authExists ? readJsonFile(authPath) : {}, }; } @@ -73,7 +98,7 @@ export function writeDevspaceConfig( ): string { const filePath = devspaceConfigPath(env); mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, config, 0o600); + writeJsonFile(filePath, serializeDevspaceUserConfig(config), 0o600); return filePath; } @@ -101,5 +126,126 @@ function readJsonFile(filePath: string): T { } function writeJsonFile(filePath: string, value: unknown, mode: number): void { - writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode }); + const directory = dirname(filePath); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const tempPath = `${filePath}.${process.pid}.tmp`; + + try { + writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n", { mode }); + renameSync(tempPath, filePath); + } finally { + rmSync(tempPath, { force: true }); + } +} + +export function normalizeDevspaceUserConfig(raw: DevspaceUserConfig): DevspaceUserConfig { + const normalizedRoots = normalizePathList( + raw.workspaces?.allowed ?? raw.allowedRoots ?? raw.allowedDirectories, + ); + const defaultWorkspace = normalizeOptionalPath(raw.workspaces?.default); + const splitUrl = splitConfiguredPublicUrl( + raw.server?.publicBaseUrl ?? raw.publicBaseUrl ?? raw.publicUrl ?? raw.baseUrl ?? null, + raw.server?.mcpPath, + ); + const server: DevspaceServerUserConfig = { + host: raw.server?.host ?? raw.host, + port: raw.server?.port ?? raw.port, + mcpPath: splitUrl.mcpPath, + publicBaseUrl: splitUrl.publicBaseUrl, + }; + + return { + ...raw, + host: server.host, + port: server.port, + allowedRoots: normalizedRoots, + publicBaseUrl: server.publicBaseUrl, + server, + workspaces: { + allowed: normalizedRoots, + default: defaultWorkspace, + }, + service: { + manager: raw.service?.manager, + autostart: raw.service?.autostart, + }, + }; +} + +export function normalizeMcpPath(path: string | undefined): string { + const trimmed = path?.trim(); + if (!trimmed) return "/mcp"; + const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return normalized.replace(/\/+$/, "") || "/mcp"; +} + +export function splitConfiguredPublicUrl( + value: string | null | undefined, + explicitMcpPath?: string, +): { + publicBaseUrl: string | null; + mcpPath: string; +} { + const fallbackPath = normalizeMcpPath(explicitMcpPath); + const trimmed = value?.trim(); + if (!trimmed) { + return { + publicBaseUrl: null, + mcpPath: fallbackPath, + }; + } + + const parsed = new URL(trimmed); + const pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.hash = ""; + parsed.search = ""; + parsed.pathname = ""; + + return { + publicBaseUrl: parsed.toString().replace(/\/$/, ""), + mcpPath: normalizeMcpPath(pathname || fallbackPath), + }; +} + +export function serializeDevspaceUserConfig(config: DevspaceUserConfig): DevspaceUserConfig { + const normalized = normalizeDevspaceUserConfig(config); + return { + host: normalized.server?.host, + port: normalized.server?.port, + allowedRoots: normalized.workspaces?.allowed, + publicBaseUrl: normalized.server?.publicBaseUrl ?? null, + allowedHosts: normalized.allowedHosts, + stateDir: normalized.stateDir, + worktreeRoot: normalized.worktreeRoot, + agentDir: normalized.agentDir, + tunnel: normalized.tunnel, + server: { + host: normalized.server?.host, + port: normalized.server?.port, + mcpPath: normalized.server?.mcpPath, + publicBaseUrl: normalized.server?.publicBaseUrl ?? null, + }, + workspaces: { + allowed: normalized.workspaces?.allowed, + default: normalized.workspaces?.default ?? null, + }, + service: { + manager: normalized.service?.manager, + autostart: normalized.service?.autostart, + }, + }; +} + +function normalizePathList(paths: string[] | undefined): string[] | undefined { + const normalized = paths + ?.map((path) => normalizeOptionalPath(path)) + .filter((path): path is string => Boolean(path)); + if (!normalized || normalized.length === 0) return undefined; + return Array.from(new Set(normalized)); +} + +function normalizeOptionalPath(path: string | null | undefined): string | undefined { + const trimmed = path?.trim(); + if (!trimmed) return undefined; + return resolve(expandHomePath(trimmed)); } diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 423a24a..f3afe5c 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -82,6 +82,35 @@ try { const worktreeReadmePath = registry.resolvePath(worktreeWorkspace.workspace, "README.md"); assert.equal(worktreeReadmePath.startsWith(worktreeWorkspace.workspace.root), true); + await mkdir(join(root, "skills", "installed", "refresh-skill"), { recursive: true }); + await writeFile( + join(root, "skills", "installed", "refresh-skill", "SKILL.md"), + [ + "---", + "name: refresh-skill", + "description: Refresh test skill.", + "---", + "", + "# Refresh Skill", + ].join("\n"), + ); + assert.equal(workspace.skills.some((skill) => skill.name === "refresh-skill"), false); + const refreshedWorkspace = registry.refreshWorkspaceSkills(workspace.id); + assert.equal(refreshedWorkspace.skills.some((skill) => skill.name === "refresh-skill"), true); + + const defaultOnlyConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: `${root},${gitRoot}`, + DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "default-worktrees"), + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + DEVSPACE_SESSION_WORKSPACE: root, + PORT: "1", + }); + const defaultOnlyRegistry = new WorkspaceRegistry(defaultOnlyConfig); + const defaultWorkspace = await defaultOnlyRegistry.openWorkspace({ mode: "checkout" }); + assert.equal(defaultWorkspace.workspace.root, root); + assert.equal(defaultWorkspace.workspace.mode, "checkout"); + const stateDir = join(root, ".state"); const firstStore = new SqliteWorkspaceStore(stateDir); const persistentRegistry = new WorkspaceRegistry(config, firstStore); diff --git a/src/workspaces.ts b/src/workspaces.ts index 3b7b51e..d3b1c73 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -56,7 +56,7 @@ export interface WorkspaceReadPath { } export interface OpenWorkspaceInput { - path: string; + path?: string; mode?: WorkspaceMode; baseRef?: string; } @@ -71,13 +71,17 @@ export class WorkspaceRegistry { async openWorkspace(input: string | OpenWorkspaceInput): Promise { const options = typeof input === "string" ? { path: input } : input; + const path = options.path ?? this.defaultWorkspaceRoot(); + if (!path) { + throw new Error("Workspace path is required unless a default workspace has been configured for this session."); + } const mode = options.mode ?? "checkout"; if (mode === "worktree") { - return this.openWorktreeWorkspace(options.path, options.baseRef); + return this.openWorktreeWorkspace(path, options.baseRef); } - return this.openCheckoutWorkspace(options.path); + return this.openCheckoutWorkspace(path); } getWorkspace(workspaceId: string): Workspace { @@ -118,6 +122,15 @@ export class WorkspaceRegistry { return restoredWorkspace; } + refreshWorkspaceSkills(workspaceId: string): Workspace { + const workspace = this.getWorkspace(workspaceId); + const refreshed = this.loadSkillsForWorkspace(workspace.root); + workspace.skills = refreshed.skills; + workspace.skillDiagnostics = refreshed.skillDiagnostics; + workspace.activatedSkillDirs.clear(); + return workspace; + } + resolvePath(workspace: Workspace, inputPath: string): string { const absolutePath = resolveAllowedPath(inputPath, workspace.root, [workspace.root]); if (!isPathInsideRoot(absolutePath, workspace.root)) { @@ -160,6 +173,12 @@ export class WorkspaceRegistry { return assertAllowedPath(directory, [workspace.root]); } + defaultWorkspaceRoot(): string | undefined { + const preferred = this.config.sessionWorkspace ?? this.config.defaultWorkspace; + if (!preferred) return undefined; + return assertAllowedPath(preferred, this.config.allowedRoots); + } + private async openCheckoutWorkspace(path: string): Promise { const root = assertAllowedPath(path, this.config.allowedRoots); await mkdir(root, { recursive: true }); From db5f31c64a737a8db9c603d67db59940c35e1839 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 15:04:36 +0800 Subject: [PATCH 18/41] feat: refresh skill loading and prompting flow --- README.md | 8 +- docs/chatgpt-coding-workflow.md | 14 +- docs/configuration.md | 4 +- docs/gotchas.md | 4 +- package.json | 2 +- skills/.system/create-plan/SKILL.md | 54 ++++ skills/.system/define-goal/SKILL.md | 53 ++++ .../devspace-workflow/SKILL.md | 8 +- .../devspace-workflow/references/commands.md | 12 +- .../devspace-workflow/references/examples.md | 0 .../devspace-workflow/references/style.md | 4 +- .../senior-architect-lite/SKILL.md | 0 .../references/decision-guide.md | 0 .../senior-architect-lite/references/style.md | 0 .../skill-authoring-lite/SKILL.md | 0 .../references/structure-checklist.md | 0 src/goal-definition.test.ts | 37 +++ src/goal-definition.ts | 58 ++++ src/prompting.test.ts | 5 +- src/prompting.ts | 6 +- src/server.ts | 253 ++++++++++++++---- src/skill-manager.test.ts | 108 +++++++- src/skill-manager.ts | 75 +++++- src/skills.test.ts | 125 ++++++--- src/skills.ts | 215 ++++++++++++--- src/ui/card-types.ts | 2 + src/ui/workspace-app.tsx | 2 + src/workspace-store.ts | 46 +++- 28 files changed, 912 insertions(+), 183 deletions(-) create mode 100644 skills/.system/create-plan/SKILL.md create mode 100644 skills/.system/define-goal/SKILL.md rename skills/{core => .system}/devspace-workflow/SKILL.md (77%) rename skills/{core => .system}/devspace-workflow/references/commands.md (80%) rename skills/{core => .system}/devspace-workflow/references/examples.md (100%) rename skills/{core => .system}/devspace-workflow/references/style.md (88%) rename skills/{core => .system}/senior-architect-lite/SKILL.md (100%) rename skills/{core => .system}/senior-architect-lite/references/decision-guide.md (100%) rename skills/{core => .system}/senior-architect-lite/references/style.md (100%) rename skills/{core => .system}/skill-authoring-lite/SKILL.md (100%) rename skills/{core => .system}/skill-authoring-lite/references/structure-checklist.md (100%) create mode 100644 src/goal-definition.test.ts create mode 100644 src/goal-definition.ts diff --git a/README.md b/README.md index f86dd3f..95663c7 100644 --- a/README.md +++ b/README.md @@ -170,15 +170,19 @@ DevSpace gives ChatGPT tools to: - discover local agent skills from your skill folders - show tool cards and optional change summaries in ChatGPT Apps-compatible hosts -DevSpace also bundles a small set of built-in workflow and engineering skills in `skills/core/`. +DevSpace also bundles a small set of built-in workflow and engineering skills. Their structure is inspired by [alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills), which is released under the MIT license. Project skill directories are split by purpose: -- `skills/core`: built-in DevSpace skills, committed with DevSpace +- system built-in DevSpace skills, committed with DevSpace - `skills/local`: project-defined skills you want to keep in version control - `skills/installed`: user-installed project skills, ignored by git by default +ChatGPT Plus on the web cannot natively install or register Codex Skills. DevSpace provides the MCP-side skill installation, discovery, and resolution layer instead. + +`@devspace /plan` and `@devspace /goal` are alias-style workflow conventions. They are not native ChatGPT slash commands. + Manage installed skills with: ```bash diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index b4be2e0..6a93cbc 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -81,12 +81,16 @@ Skills are enabled by default for coding-agent workflows. DevSpace discovers skills from: -- built-in DevSpace skills in `skills/core` +- built-in DevSpace skills - workspace-local skills in `skills/local` - workspace-installed skills in `skills/installed` - `DEVSPACE_AGENT_DIR`, which defaults to `~/.codex` - optional paths from `DEVSPACE_SKILL_PATHS` +ChatGPT Plus on the web cannot natively install or register Codex Skills. In this setup, DevSpace provides MCP-based skill installation, discovery, and resolution. + +`@devspace /plan` and `@devspace /goal` are workflow aliases, not native ChatGPT slash commands. + User-installed project skills can be managed through DevSpace itself: ```text @@ -101,6 +105,14 @@ User-installed project skills can be managed through DevSpace itself: 请调用 remove_skill,删除当前 workspace 里名为 research 的 installed skill。 ``` +```text +@devspace /plan 为跨平台服务管理增加 restart、status 和 logs 支持 +``` + +```text +@devspace /goal 将 DevSpace 的第三方 Skill 安装流程收敛为可测试、可回滚、跨平台兼容的实现 +``` + When `open_workspace` returns matching skills, the model should read the advertised `SKILL.md` before following that skill. diff --git a/docs/configuration.md b/docs/configuration.md index a27d6bb..a87436b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -208,10 +208,12 @@ Use `--no-tunnel` to override configured tunnel mode for one run. Project skill layout: -- `skills/core`: built-in DevSpace skills +- system built-in DevSpace skills - `skills/local`: project skills meant to be committed - `skills/installed`: user-installed project skills, typically git-ignored +ChatGPT Plus on the web cannot natively install or register Codex Skills. DevSpace provides the MCP-side skill installation, discovery, and resolution layer instead. + Manage installed skills with: ```bash diff --git a/docs/gotchas.md b/docs/gotchas.md index d7c7ea5..6b7fd91 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -198,12 +198,12 @@ DevSpace looks in: - `DEVSPACE_AGENT_DIR`, defaulting to `~/.codex` - `skills/local` - `skills/installed` -- `skills/core` +- system built-in skills - `DEVSPACE_SKILL_PATHS` Recommended meaning: -- `skills/core`: built-in DevSpace skills +- system built-in DevSpace skills - `skills/local`: project-defined skills you want to commit - `skills/installed`: user-installed project skills that should stay git-ignored diff --git a/package.json b/package.json index 295f492..46ce1fc 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/config-operations.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/skill-manager.test.ts && node --import tsx src/cli-skills.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspace-operations.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts && node --import tsx src/service.test.ts", + "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/config-operations.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/skill-manager.test.ts && node --import tsx src/cli-skills.test.ts && node --import tsx src/goal-definition.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspace-operations.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts && node --import tsx src/service.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/skills/.system/create-plan/SKILL.md b/skills/.system/create-plan/SKILL.md new file mode 100644 index 0000000..3bcb5e3 --- /dev/null +++ b/skills/.system/create-plan/SKILL.md @@ -0,0 +1,54 @@ +--- +name: create-plan +description: Create a concise, execution-ready implementation plan for a coding task. Use when the user asks for /plan or wants a read-only planning pass before making code changes. +license: MIT +metadata: + version: 1.0.0 + author: DevSpace + category: workflow + updated: 2026-06-21 +--- + +# Create Plan + +## Purpose + +Use this skill to turn a coding request into a concrete implementation plan without editing files or claiming the work is already done. + +## Workflow + +1. Read the most relevant code, tests, docs, and entrypoints first. +2. Stay read-only for the full planning pass. +3. Ask at most one or two questions, and only when a real blocker remains after inspection. +4. Make reasonable assumptions when the missing detail does not materially change the implementation. +5. Produce one practical plan with ordered, atomic actions. + +## Output Requirements + +- Keep the plan finite and implementation-oriented. +- Default to 6-10 action items. +- Include validation or test steps. +- Include risks, boundaries, or rollback notes when they matter. +- Do not output large code blocks. +- Do not write files, run mutations, or say the work is complete. + +## Recommended Shape + +```markdown +# Plan + +Short summary of the goal and the path. + +## Scope +- In: +- Out: + +## Action items +- [ ] ... + +## Validation +- ... + +## Risks +- ... +``` diff --git a/skills/.system/define-goal/SKILL.md b/skills/.system/define-goal/SKILL.md new file mode 100644 index 0000000..155664a --- /dev/null +++ b/skills/.system/define-goal/SKILL.md @@ -0,0 +1,53 @@ +--- +name: define-goal +description: Rewrite a vague request into a concrete, verifiable goal with scope and acceptance criteria. Use when the user asks for /goal or needs a measurable target before execution. +license: MIT +metadata: + version: 1.0.0 + author: DevSpace + category: workflow + updated: 2026-06-21 +--- + +# Define Goal + +## Purpose + +Use this skill to convert an ambiguous request into a specific goal that can be verified in the current workspace. + +## Workflow + +1. Identify what should be true when the work is done. +2. Limit the scope to the systems, modules, or behaviors that actually matter. +3. Define how success will be verified with concrete evidence, thresholds, or commands when possible. +4. State what is explicitly out of scope. +5. If a critical scope or verification detail is missing, ask one short question. Otherwise, make a reasonable assumption and continue. + +## Output Requirements + +- Keep the goal measurable and bounded. +- Prefer real verification evidence over vague quality language. +- Do not invent long-running lifecycle mechanics, dashboards, or progress logs. +- Do not simulate native Codex goal commands. + +## Recommended Shape + +```markdown +# Goal + +## Objective +... + +## Scope +- In: +- Out: + +## Success criteria +- ... + +## Verification +- ... + +## Stop / escalation conditions +- ... +``` diff --git a/skills/core/devspace-workflow/SKILL.md b/skills/.system/devspace-workflow/SKILL.md similarity index 77% rename from skills/core/devspace-workflow/SKILL.md rename to skills/.system/devspace-workflow/SKILL.md index e1f583c..486d974 100644 --- a/skills/core/devspace-workflow/SKILL.md +++ b/skills/.system/devspace-workflow/SKILL.md @@ -13,7 +13,7 @@ metadata: ## What This Skill Does -Use this skill when the user drives DevSpace with concise workflow messages such as `/plan`, `/goal`, or compact answers to pending questions. +Use this skill when the user drives DevSpace with concise workflow messages such as `/plan`, `/goal`, skill names, or compact answers to pending questions. ## Before Starting @@ -31,7 +31,7 @@ Trigger on messages like: - `@dev /plan ...` - `/plan ...` -Use `handle_workspace_command` first, then continue the planning workflow in `plan` mode. +Use `resolve_skill("/plan")` first, then follow the returned `create-plan` instructions. Treat `/plan` as an alias, not a native slash command. ### Goal Workflow @@ -40,13 +40,13 @@ Trigger on messages like: - `@dev /goal ...` - `/goal ...` -Use `handle_workspace_command` first, then continue with `create_goal`, `get_goal`, and `update_goal` as needed. +Use `resolve_skill("/goal")` first, then follow the returned `define-goal` instructions. If the user explicitly wants a lightweight persisted goal record, use `create_goal`, `get_goal`, and `update_goal` after the goal is well-defined. ### Compact Answers Trigger when there is pending `request_user_input` state and the user replies with compact text such as `1B,2A`, `1B, 2A`, or `1b 2a`. -Prefer passing the raw reply through `handle_workspace_command` or `answer_user_input(text)` instead of paraphrasing it. +Prefer passing the raw reply through `handle_workspace_command` only for compact answer parsing, or directly through `answer_user_input(text)`, instead of paraphrasing it. ### Batch File Changes diff --git a/skills/core/devspace-workflow/references/commands.md b/skills/.system/devspace-workflow/references/commands.md similarity index 80% rename from skills/core/devspace-workflow/references/commands.md rename to skills/.system/devspace-workflow/references/commands.md index 61ba4f4..47ba3cd 100644 --- a/skills/core/devspace-workflow/references/commands.md +++ b/skills/.system/devspace-workflow/references/commands.md @@ -9,9 +9,9 @@ Inputs: Expected behavior: -1. Call `handle_workspace_command`. -2. Ensure the workspace moves to `plan` mode. -3. Continue with planning tools and repository exploration. +1. Call `resolve_skill("/plan")`. +2. Read and follow the returned `create-plan` instructions. +3. Keep the full `/plan` pass read-only. 4. Keep replies short unless the user explicitly asks for detail. ## `/goal` @@ -23,9 +23,9 @@ Inputs: Expected behavior: -1. Call `handle_workspace_command`. -2. Create or continue the workspace goal. -3. Use `get_goal` and `update_goal` for lifecycle management. +1. Call `resolve_skill("/goal")`. +2. Read and follow the returned `define-goal` instructions. +3. Only use `create_goal`, `get_goal`, or `update_goal` if the user explicitly wants a lightweight persisted goal record. ## Compact Answers diff --git a/skills/core/devspace-workflow/references/examples.md b/skills/.system/devspace-workflow/references/examples.md similarity index 100% rename from skills/core/devspace-workflow/references/examples.md rename to skills/.system/devspace-workflow/references/examples.md diff --git a/skills/core/devspace-workflow/references/style.md b/skills/.system/devspace-workflow/references/style.md similarity index 88% rename from skills/core/devspace-workflow/references/style.md rename to skills/.system/devspace-workflow/references/style.md index e61dfcd..30eee9c 100644 --- a/skills/core/devspace-workflow/references/style.md +++ b/skills/.system/devspace-workflow/references/style.md @@ -9,8 +9,8 @@ ## Good Status Examples -- `Plan mode on` -- `Goal created` +- `Resolved /plan to create-plan` +- `Resolved /goal to define-goal` - `Answer recorded` - `No workflow command recognized` diff --git a/skills/core/senior-architect-lite/SKILL.md b/skills/.system/senior-architect-lite/SKILL.md similarity index 100% rename from skills/core/senior-architect-lite/SKILL.md rename to skills/.system/senior-architect-lite/SKILL.md diff --git a/skills/core/senior-architect-lite/references/decision-guide.md b/skills/.system/senior-architect-lite/references/decision-guide.md similarity index 100% rename from skills/core/senior-architect-lite/references/decision-guide.md rename to skills/.system/senior-architect-lite/references/decision-guide.md diff --git a/skills/core/senior-architect-lite/references/style.md b/skills/.system/senior-architect-lite/references/style.md similarity index 100% rename from skills/core/senior-architect-lite/references/style.md rename to skills/.system/senior-architect-lite/references/style.md diff --git a/skills/core/skill-authoring-lite/SKILL.md b/skills/.system/skill-authoring-lite/SKILL.md similarity index 100% rename from skills/core/skill-authoring-lite/SKILL.md rename to skills/.system/skill-authoring-lite/SKILL.md diff --git a/skills/core/skill-authoring-lite/references/structure-checklist.md b/skills/.system/skill-authoring-lite/references/structure-checklist.md similarity index 100% rename from skills/core/skill-authoring-lite/references/structure-checklist.md rename to skills/.system/skill-authoring-lite/references/structure-checklist.md diff --git a/src/goal-definition.test.ts b/src/goal-definition.test.ts new file mode 100644 index 0000000..59da6e5 --- /dev/null +++ b/src/goal-definition.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { + normalizeGoalDefinition, + parseGoalDefinition, + serializeGoalDefinition, +} from "./goal-definition.js"; + +const normalized = normalizeGoalDefinition({ + objective: " Ship lightweight goal flow ", + scope: { + in: [" goal tools ", " resolve_skill "], + out: [" dashboards ", ""], + }, + verification: [" npm test ", " npm run typecheck "], + stopConditions: [" Need product clarification "], +}); + +assert.deepEqual(normalized, { + objective: "Ship lightweight goal flow", + scope: { + in: ["goal tools", "resolve_skill"], + out: ["dashboards"], + }, + verification: ["npm test", "npm run typecheck"], + stopConditions: ["Need product clarification"], +}); + +const serialized = serializeGoalDefinition(normalized); +const parsed = parseGoalDefinition(serialized); +assert.equal(parsed.legacy, false); +assert.deepEqual(parsed.definition, normalized); + +const legacy = parseGoalDefinition("Ship the feature"); +assert.equal(legacy.legacy, true); +assert.deepEqual(legacy.definition, { + objective: "Ship the feature", +}); diff --git a/src/goal-definition.ts b/src/goal-definition.ts new file mode 100644 index 0000000..84b7cbb --- /dev/null +++ b/src/goal-definition.ts @@ -0,0 +1,58 @@ +export interface GoalScope { + in: string[]; + out: string[]; +} + +export interface GoalDefinition { + objective: string; + scope?: GoalScope; + verification?: string[]; + stopConditions?: string[]; +} + +export interface ParsedGoalDefinition { + definition: GoalDefinition; + legacy: boolean; +} + +const GOAL_PREFIX = "devspace-goal-v1:"; + +export function serializeGoalDefinition(definition: GoalDefinition): string { + return `${GOAL_PREFIX}${JSON.stringify(normalizeGoalDefinition(definition))}`; +} + +export function parseGoalDefinition(raw: string): ParsedGoalDefinition { + if (!raw.startsWith(GOAL_PREFIX)) { + return { + definition: { objective: raw }, + legacy: true, + }; + } + + try { + const parsed = JSON.parse(raw.slice(GOAL_PREFIX.length)) as GoalDefinition; + return { + definition: normalizeGoalDefinition(parsed), + legacy: false, + }; + } catch { + return { + definition: { objective: raw }, + legacy: true, + }; + } +} + +export function normalizeGoalDefinition(definition: GoalDefinition): GoalDefinition { + return { + objective: definition.objective.trim(), + scope: definition.scope + ? { + in: definition.scope.in.map((item) => item.trim()).filter(Boolean), + out: definition.scope.out.map((item) => item.trim()).filter(Boolean), + } + : undefined, + verification: definition.verification?.map((item) => item.trim()).filter(Boolean), + stopConditions: definition.stopConditions?.map((item) => item.trim()).filter(Boolean), + }; +} diff --git a/src/prompting.test.ts b/src/prompting.test.ts index d65c41c..610b7c4 100644 --- a/src/prompting.test.ts +++ b/src/prompting.test.ts @@ -26,8 +26,9 @@ assert.match(instructions, /Prefer action over explanation\./); assert.match(instructions, /Keep responses terse and operational\./); assert.match(instructions, /Do not add long design discussion, repeated background, or speculative future improvements unless the user explicitly asks for them\./); assert.match(instructions, /When the user sends a short reply such as '1B, 2A', treat it as workflow input and continue instead of explaining the mechanism back to them\./); -assert.match(instructions, /When available skills include a matching workflow skill, read that skill before handling slash-style workspace commands or compact user-input replies\./); -assert.match(instructions, /For concise workflow commands and compact pending-input replies, prefer handle_workspace_command or answer_user_input\(text\) over paraphrasing the user's message\./); +assert.match(instructions, /When the user mentions a skill name, \/plan, or \/goal, prefer resolve_skill to load the relevant SKILL\.md instructions\./); +assert.match(instructions, /Treat \/plan and \/goal as aliases, not native ChatGPT slash commands\./); +assert.match(instructions, /Use handle_workspace_command only for compact pending-input replies or legacy workflow compatibility\./); const planInstruction = workspaceInstruction("plan", false); assert.match(planInstruction, /ask clarifying questions with request_user_input only when they materially affect the plan/); diff --git a/src/prompting.ts b/src/prompting.ts index 1484b57..995dc9d 100644 --- a/src/prompting.ts +++ b/src/prompting.ts @@ -27,15 +27,15 @@ export function serverInstructions( : ""; const planning = - " Use get_collaboration_mode to inspect the workspace collaboration mode. Use set_collaboration_mode to switch between default execution and plan mode. In default mode, use update_plan for a concise execution checklist when helpful. In plan mode, prefer request_user_input, repository exploration, and concrete specification work; do not use update_plan while plan mode is active. When the user asks to pursue a concrete objective across multiple turns, use create_goal to start one goal for that workspace, get_goal to inspect its status, and update_goal to mark it complete or blocked."; + " Use get_collaboration_mode to inspect the workspace collaboration mode. Use set_collaboration_mode only when a lightweight collaboration toggle is useful. In default mode, use update_plan for a concise execution checklist when helpful. In plan mode, prefer request_user_input, repository exploration, and concrete specification work; do not use update_plan while plan mode is active. Treat create_goal, get_goal, and update_goal as lightweight, verifiable goal records for the current workspace rather than a long-running project-management system."; const style = " Prefer action over explanation. Keep responses terse and operational. For mode switches, goal updates, confirmations, cancellations, pending answers, and other straightforward workflow steps, return only the necessary status or next action. Do not add long design discussion, repeated background, or speculative future improvements unless the user explicitly asks for them. When the user sends a short reply such as '1B, 2A', treat it as workflow input and continue instead of explaining the mechanism back to them."; const commands = - " When available skills include a matching workflow skill, read that skill before handling slash-style workspace commands or compact user-input replies. For concise workflow commands and compact pending-input replies, prefer handle_workspace_command or answer_user_input(text) over paraphrasing the user's message."; + " When the user mentions a skill name, /plan, or /goal, prefer resolve_skill to load the relevant SKILL.md instructions. Treat /plan and /goal as aliases, not native ChatGPT slash commands. Use handle_workspace_command only for compact pending-input replies or legacy workflow compatibility. For concise pending-input replies, prefer answer_user_input(text) over paraphrasing the user's message."; - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, shell, plan, and goal tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}${planning}${style}${commands} Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, apply_workspace_patch for coordinated multi-file patches, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Use git_push for explicit push requests instead of raw git push through ${toolNames.shell}. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, shell, skill, plan, and goal tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}${planning}${style}${commands} Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, apply_workspace_patch for coordinated multi-file patches, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Use git_push for explicit push requests instead of raw git push through ${toolNames.shell}. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; } export function workspaceInstruction( diff --git a/src/server.ts b/src/server.ts index 0c82818..32d8acd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,7 +41,13 @@ import { import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { validateShellCommand } from "./shell-policy.js"; -import { formatPathForPrompt } from "./skills.js"; +import { + formatPathForPrompt, + resolveSkillDefinition, + skillSourceLabel, + type SkillResolveMode, + type SkillSource, +} from "./skills.js"; import { installSkill, listInstalledSkills, @@ -49,6 +55,12 @@ import { type InstalledSkillRecord, type SkillInstallSource, } from "./skill-manager.js"; +import { + normalizeGoalDefinition, + parseGoalDefinition, + serializeGoalDefinition, + type GoalDefinition, +} from "./goal-definition.js"; import { contentStats, contentText, toolError, type ToolContent } from "./tool-result.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; @@ -224,6 +236,7 @@ const workspaceSkillOutputSchema = z.object({ name: z.string(), description: z.string(), path: z.string(), + source: z.enum(["system", "local", "installed", "global"]), }); const installedSkillOutputSchema = z.object({ @@ -235,6 +248,27 @@ const installedSkillOutputSchema = z.object({ sourceType: z.enum(["workspace-installed", "global-installed"]), }); +const resolvedSkillOutputSchema = z.object({ + name: z.string(), + source: z.enum(["system", "local", "installed", "global"]), + path: z.string(), + alias: z.string().optional(), + mode: z.enum(["read_only", "normal"]), + instructions: z.string(), +}); + +const goalDefinitionOutputSchema = z.object({ + objective: z.string(), + scope: z + .object({ + in: z.array(z.string()), + out: z.array(z.string()), + }) + .optional(), + verification: z.array(z.string()).optional(), + stopConditions: z.array(z.string()).optional(), +}); + const workspaceAgentsFileOutputSchema = z.object({ path: z.string(), content: z.string(), @@ -623,6 +657,7 @@ function createMcpServer( name: skill.name, description: skill.description, path: formatPathForPrompt(skill.filePath), + source: skill.source, })); const loadedAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), @@ -694,6 +729,76 @@ function createMcpServer( }, ); + registerAppTool( + server, + "resolve_skill", + { + title: "Resolve skill", + description: + "Resolve a skill name or alias such as /plan or /goal for the current workspace. This tool only reads and returns skill instructions; it does not execute installation, file changes, or commands.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + nameOrAlias: z.string().describe("Skill name or alias such as create-plan, define-goal, /plan, or /goal."), + }, + outputSchema: { + result: z.string(), + skill: resolvedSkillOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, nameOrAlias }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + try { + const resolved = await resolveSkillDefinition(workspace.skills, nameOrAlias); + const content = [textBlock(resolved.instructions)]; + + logToolCall(config, { + tool: "resolve_skill", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "resolve_skill", + card: { + workspaceId, + path: resolved.path, + summary: { + source: resolved.source, + mode: resolved.mode, + alias: resolved.alias, + }, + payload: { content }, + }, + }, + structuredContent: { + result: contentText(content), + skill: { + name: resolved.name, + source: resolved.source, + path: resolved.path, + alias: resolved.alias, + mode: resolved.mode, + instructions: resolved.instructions, + }, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "resolve_skill", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + registerAppTool( server, "get_collaboration_mode", @@ -1034,19 +1139,7 @@ function createMcpServer( result: z.string(), recognized: z.boolean(), command: z.enum(["plan", "goal", "answer", "none"]), - mode: z.enum(["default", "plan"]).optional(), - goal: z - .object({ - objective: z.string(), - status: z.enum(["active", "complete", "blocked"]), - tokenBudget: z.number().int().positive().optional(), - createdAt: z.string(), - updatedAt: z.string(), - timeUsedSeconds: z.number().int().nonnegative(), - completedAt: z.string().optional(), - blockedAt: z.string().optional(), - }) - .optional(), + skill: resolvedSkillOutputSchema.optional(), prompt: userInputPromptOutputSchema.optional(), }, ...toolWidgetDescriptorMeta(config, "plan"), @@ -1054,7 +1147,7 @@ function createMcpServer( }, async ({ workspaceId, message }) => { const startedAt = performance.now(); - workspaces.getWorkspace(workspaceId); + const workspace = workspaces.getWorkspace(workspaceId); const pending = workspaceStore.getPendingUserInput(workspaceId); const parsed = parseWorkspaceCommand(message, pending); @@ -1077,11 +1170,8 @@ function createMcpServer( } if (parsed.kind === "plan") { - const collaboration = workspaceStore.setCollaborationMode({ - workspaceSessionId: workspaceId, - mode: "plan", - }); - const content = [textBlock(parsed.argument ? `Plan mode on\n${parsed.argument}` : "Plan mode on")]; + const resolved = await resolveSkillDefinition(workspace.skills, "/plan"); + const content = [textBlock(`Resolved /plan to ${resolved.name} (${skillSourceLabel(resolved.source)}).`)]; logToolCall(config, { tool: "handle_workspace_command", workspaceId, @@ -1094,26 +1184,21 @@ function createMcpServer( result: contentText(content), recognized: true, command: "plan" as const, - mode: collaboration.mode, + skill: { + name: resolved.name, + source: resolved.source, + path: resolved.path, + alias: resolved.alias, + mode: resolved.mode, + instructions: resolved.instructions, + }, }, }; } if (parsed.kind === "goal") { - if (!parsed.argument) { - const response = toolError("Goal command is missing an objective."); - logFailedToolResponse(config, { - tool: "handle_workspace_command", - workspaceId, - }, response.content, startedAt); - return response; - } - - const goal = workspaceStore.saveGoal({ - workspaceSessionId: workspaceId, - objective: parsed.argument, - }); - const content = [textBlock("Goal created")]; + const resolved = await resolveSkillDefinition(workspace.skills, "/goal"); + const content = [textBlock(`Resolved /goal to ${resolved.name} (${skillSourceLabel(resolved.source)}).`)]; logToolCall(config, { tool: "handle_workspace_command", workspaceId, @@ -1126,15 +1211,13 @@ function createMcpServer( result: contentText(content), recognized: true, command: "goal" as const, - goal: { - objective: goal.objective, - status: goal.status, - tokenBudget: goal.tokenBudget, - createdAt: goal.createdAt, - updatedAt: goal.updatedAt, - timeUsedSeconds: goal.timeUsedSeconds, - completedAt: goal.completedAt, - blockedAt: goal.blockedAt, + skill: { + name: resolved.name, + source: resolved.source, + path: resolved.path, + alias: resolved.alias, + mode: resolved.mode, + instructions: resolved.instructions, }, }, }; @@ -1607,6 +1690,10 @@ function createMcpServer( goal: z .object({ objective: z.string(), + scope: goalDefinitionOutputSchema.shape.scope, + verification: goalDefinitionOutputSchema.shape.verification, + stopConditions: goalDefinitionOutputSchema.shape.stopConditions, + legacy: z.boolean().optional(), status: z.enum(["active", "complete", "blocked"]), tokenBudget: z.number().int().positive().optional(), createdAt: z.string(), @@ -1624,6 +1711,7 @@ function createMcpServer( const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); const goal = workspaceStore.getGoal(workspaceId); + const parsedGoal = goal ? parseGoalDefinition(goal.objective) : null; const content = [textBlock(goal ? formatGoalResult(goal) : "No active or historical goal for this workspace.")]; logToolCall(config, { @@ -1639,7 +1727,11 @@ function createMcpServer( result: contentText(content), goal: goal ? { - objective: goal.objective, + objective: parsedGoal?.definition.objective ?? goal.objective, + scope: parsedGoal?.definition.scope, + verification: parsedGoal?.definition.verification, + stopConditions: parsedGoal?.definition.stopConditions, + legacy: parsedGoal?.legacy, status: goal.status, tokenBudget: goal.tokenBudget, createdAt: goal.createdAt, @@ -1666,6 +1758,9 @@ function createMcpServer( .string() .describe("Workspace identifier returned by open_workspace."), objective: z.string().describe("Concrete objective to pursue."), + scope: goalDefinitionOutputSchema.shape.scope.optional(), + verification: goalDefinitionOutputSchema.shape.verification, + stopConditions: goalDefinitionOutputSchema.shape.stopConditions, tokenBudget: z .number() .int() @@ -1677,6 +1772,9 @@ function createMcpServer( result: z.string(), goal: z.object({ objective: z.string(), + scope: goalDefinitionOutputSchema.shape.scope, + verification: goalDefinitionOutputSchema.shape.verification, + stopConditions: goalDefinitionOutputSchema.shape.stopConditions, status: z.literal("active"), tokenBudget: z.number().int().positive().optional(), createdAt: z.string(), @@ -1687,12 +1785,18 @@ function createMcpServer( ...toolWidgetDescriptorMeta(config, "goal"), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, - async ({ workspaceId, objective, tokenBudget }) => { + async ({ workspaceId, objective, scope, verification, stopConditions, tokenBudget }) => { const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); + const definition = normalizeGoalDefinition({ + objective, + scope, + verification, + stopConditions, + }); const goal = workspaceStore.saveGoal({ workspaceSessionId: workspaceId, - objective, + objective: serializeGoalDefinition(definition), tokenBudget, }); const content = [textBlock(formatGoalResult(goal))]; @@ -1709,7 +1813,10 @@ function createMcpServer( structuredContent: { result: contentText(content), goal: { - objective: goal.objective, + objective: definition.objective, + scope: definition.scope, + verification: definition.verification, + stopConditions: definition.stopConditions, status: goal.status, tokenBudget: goal.tokenBudget, createdAt: goal.createdAt, @@ -1727,18 +1834,26 @@ function createMcpServer( { title: "Update goal", description: - "Mark the current workspace-scoped goal complete or blocked.", + "Update the current workspace-scoped goal with lightweight objective, scope, verification, or status changes.", inputSchema: { workspaceId: z .string() .describe("Workspace identifier returned by open_workspace."), - status: z.enum(["complete", "blocked"]), + objective: z.string().optional(), + scope: goalDefinitionOutputSchema.shape.scope.optional(), + verification: goalDefinitionOutputSchema.shape.verification, + stopConditions: goalDefinitionOutputSchema.shape.stopConditions, + status: z.enum(["active", "complete", "blocked"]).optional(), }, outputSchema: { result: z.string(), goal: z.object({ objective: z.string(), - status: z.enum(["complete", "blocked"]), + scope: goalDefinitionOutputSchema.shape.scope, + verification: goalDefinitionOutputSchema.shape.verification, + stopConditions: goalDefinitionOutputSchema.shape.stopConditions, + legacy: z.boolean().optional(), + status: z.enum(["active", "complete", "blocked"]), tokenBudget: z.number().int().positive().optional(), createdAt: z.string(), updatedAt: z.string(), @@ -1750,11 +1865,28 @@ function createMcpServer( ...toolWidgetDescriptorMeta(config, "goal"), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, - async ({ workspaceId, status }) => { + async ({ workspaceId, objective, scope, verification, stopConditions, status }) => { const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); - const goal = workspaceStore.updateGoalStatus({ + const existing = workspaceStore.getGoal(workspaceId); + if (!existing) { + const response = toolError("No goal exists for this workspace."); + logFailedToolResponse(config, { + tool: "update_goal", + workspaceId, + }, response.content, startedAt); + return response; + } + const current = parseGoalDefinition(existing.objective); + const definition = normalizeGoalDefinition({ + objective: objective ?? current.definition.objective, + scope: scope ?? current.definition.scope, + verification: verification ?? current.definition.verification, + stopConditions: stopConditions ?? current.definition.stopConditions, + }); + const goal = workspaceStore.updateGoal({ workspaceSessionId: workspaceId, + objective: serializeGoalDefinition(definition), status, }); const content = [textBlock(formatGoalResult(goal, status === "complete"))]; @@ -1771,7 +1903,11 @@ function createMcpServer( structuredContent: { result: contentText(content), goal: { - objective: goal.objective, + objective: definition.objective, + scope: definition.scope, + verification: definition.verification, + stopConditions: definition.stopConditions, + legacy: false, status: goal.status, tokenBudget: goal.tokenBudget, createdAt: goal.createdAt, @@ -2875,8 +3011,15 @@ function formatGoalResult(goal: { completedAt?: string; blockedAt?: string; }, includeCompletionNote = false): string { + const parsed = parseGoalDefinition(goal.objective); const lines = [ - `Goal: ${goal.objective}`, + `Goal: ${parsed.definition.objective}`, + parsed.definition.scope + ? `Scope: In(${parsed.definition.scope.in.join("; ") || "none"}) / Out(${parsed.definition.scope.out.join("; ") || "none"})` + : undefined, + parsed.definition.verification?.length + ? `Verification: ${parsed.definition.verification.join("; ")}` + : undefined, `Status: ${goal.status}`, goal.tokenBudget !== undefined ? `Token budget: ${goal.tokenBudget}` : undefined, `Time used seconds: ${goal.timeUsedSeconds}`, diff --git a/src/skill-manager.test.ts b/src/skill-manager.test.ts index e255725..6be061f 100644 --- a/src/skill-manager.test.ts +++ b/src/skill-manager.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -19,8 +19,11 @@ const root = await mkdtemp(join(tmpdir(), "devspace-skill-manager-test-")); try { const projectRoot = join(root, "project"); const agentDir = join(root, "agent"); - const localSkill = join(root, "local-skill"); + const localSkill = join(root, "local-installed-skill"); const remoteRepo = join(root, "remote-skill-repo"); + const conflictingLocal = join(root, "create-plan"); + const invalidDirSkill = join(root, "mismatched-dir"); + const symlinkSkill = join(root, "symlink-skill"); await mkdir(projectRoot, { recursive: true }); await mkdir(join(agentDir, "skills"), { recursive: true }); @@ -58,6 +61,46 @@ try { await execFileAsync("git", ["add", "."], { cwd: remoteRepo }); await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: remoteRepo }); + await mkdir(conflictingLocal, { recursive: true }); + await writeFile( + join(conflictingLocal, "SKILL.md"), + [ + "---", + "name: create-plan", + "description: Should conflict with system skill.", + "---", + "", + "# Conflicting Local Skill", + ].join("\n"), + ); + + await mkdir(invalidDirSkill, { recursive: true }); + await writeFile( + join(invalidDirSkill, "SKILL.md"), + [ + "---", + "name: different-name", + "description: Directory name mismatch.", + "---", + "", + "# Invalid Skill", + ].join("\n"), + ); + + await mkdir(symlinkSkill, { recursive: true }); + await writeFile( + join(symlinkSkill, "SKILL.md"), + [ + "---", + "name: symlink-skill", + "description: Should be rejected because of symlink contents.", + "---", + "", + "# Symlink Skill", + ].join("\n"), + ); + await symlink(join(root, "project"), join(symlinkSkill, "linked-project")); + const config = loadConfig({ DEVSPACE_ALLOWED_ROOTS: `${projectRoot},${root}`, DEVSPACE_AGENT_DIR: agentDir, @@ -85,10 +128,7 @@ try { workspaceRoot: projectRoot, scope: "workspace", }); - assert.deepEqual( - listedWorkspace.map((skill) => skill.name), - ["local-installed-skill"], - ); + assert.deepEqual(listedWorkspace.map((skill) => skill.name), ["local-installed-skill"]); const installedGlobal = await installSkill({ config, @@ -131,6 +171,62 @@ try { /already exists/, ); + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: conflictingLocal }, + localPathResolver: (path) => path, + }), + /系统内置/, + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: invalidDirSkill }, + localPathResolver: (path) => path, + }), + /directory name must match/, + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: symlinkSkill }, + localPathResolver: (path) => path, + }), + /symlink/, + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "global", + source: { + kind: "github", + repo: "example/skills", + repoUrl: `file://${remoteRepo}`, + path: "../escape", + ref: "master", + }, + runGit: async (args) => { + await execFileAsync("git", args); + }, + }), + /Invalid skill path/, + ); + const removedWorkspace = await removeInstalledSkill({ config, workspaceRoot: projectRoot, diff --git a/src/skill-manager.ts b/src/skill-manager.ts index a55048c..0a0b511 100644 --- a/src/skill-manager.ts +++ b/src/skill-manager.ts @@ -1,11 +1,12 @@ -import { mkdtemp, mkdir, readFile, readdir, rm, stat, cp } from "node:fs/promises"; +import { mkdtemp, mkdir, opendir, readFile, readdir, rename, rm, stat, lstat, cp } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { parseFrontmatter } from "@earendil-works/pi-coding-agent"; import { assertAllowedPath, isPathInsideRoot } from "./roots.js"; import type { ServerConfig } from "./config.js"; +import { loadWorkspaceSkills, skillSourceLabel } from "./skills.js"; const execFileAsync = promisify(execFile); @@ -70,7 +71,8 @@ export async function installSkill(options: { }): Promise { const sourceDir = await materializeSource(options.source, { githubBaseUrl: options.githubBaseUrl, - localPathResolver: options.localPathResolver, + localPathResolver: options.localPathResolver + ?? ((path: string) => assertAllowedPath(path, options.config.allowedRoots)), runGit: options.runGit, }); try { @@ -78,9 +80,21 @@ export async function installSkill(options: { const targetRoot = installRootForScope(options.config, options.workspaceRoot, options.scope); const targetPath = join(targetRoot, metadata.name); await mkdir(targetRoot, { recursive: true }); + await validateSkillTree(metadata.baseDir); + await assertInstallConflicts(options.config, options.workspaceRoot, metadata.name, options.scope); await ensurePathMissing(targetPath, metadata.name, options.scope); - await cp(metadata.baseDir, targetPath, { recursive: true, errorOnExist: true, force: false }); + const stagingPath = join( + targetRoot, + `.${metadata.name}.tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + ); + try { + await cp(metadata.baseDir, stagingPath, { recursive: true, errorOnExist: true, force: false }); + await rename(stagingPath, targetPath); + } catch (error) { + await rm(stagingPath, { recursive: true, force: true }); + throw error; + } return { name: metadata.name, @@ -211,6 +225,9 @@ async function readSkillMetadata(skillDir: string): Promise } validateSkillName(name); + if (basename(skillDir) !== name) { + throw new Error(`Skill directory name must match frontmatter name: ${skillDir}`); + } return { name, @@ -240,6 +257,48 @@ function validateSkillName(name: string): void { } } +async function validateSkillTree(root: string): Promise { + const entries = await opendir(root); + for await (const entry of entries) { + const path = join(root, entry.name); + const stats = await lstat(path); + if (stats.isSymbolicLink()) { + throw new Error(`Skill directory contains unsupported symlink: ${path}`); + } + if (stats.isDirectory()) { + await validateSkillTree(path); + } + } +} + +async function assertInstallConflicts( + config: ServerConfig, + workspaceRoot: string | undefined, + skillName: string, + scope: SkillScope, +): Promise { + if (!workspaceRoot) return; + + const loaded = loadWorkspaceSkills(config, workspaceRoot); + const existing = loaded.skills.find((skill) => skill.name === skillName); + if (!existing) return; + + if (existing.source === "system" || existing.source === "local") { + throw new Error( + `Skill ${skillName} conflicts with an existing ${skillSourceLabel(existing.source)} skill.`, + ); + } + + if ( + (scope === "workspace" && existing.source === "installed") || + (scope === "global" && existing.source === "global") + ) { + throw new Error( + `Skill ${skillName} already exists in ${scope === "workspace" ? "项目已安装" : "全局已安装"} source.`, + ); + } +} + async function safeStat(path: string) { try { return await stat(path); @@ -272,6 +331,7 @@ async function materializeSource( const tempRoot = await mkdtemp(join(tmpdir(), "devspace-skill-")); const checkoutRoot = join(tempRoot, "repo"); const parsed = source.kind === "github_url" ? parseGithubTreeUrl(source.url) : source; + validateRelativeSkillPath(parsed.path); const repoBaseUrl = options.githubBaseUrl ?? "https://github.com/"; const repoUrl = parsed.repoUrl ?? new URL(`${parsed.repo}.git`, repoBaseUrl).toString(); const ref = parsed.ref ?? "main"; @@ -314,3 +374,10 @@ export function parseGithubTreeUrl(url: string): { repo: string; path: string; r path: parts.slice(4).join("/"), }; } + +function validateRelativeSkillPath(path: string): void { + const normalized = path.trim(); + if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) { + throw new Error(`Invalid skill path: ${path}`); + } +} diff --git a/src/skills.test.ts b/src/skills.test.ts index c08e311..30cb0ea 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -6,6 +6,7 @@ import { loadConfig } from "./config.js"; import { formatPathForPrompt, loadWorkspaceSkills, + resolveSkillDefinition, resolveSkillReadPath, } from "./skills.js"; @@ -17,9 +18,13 @@ try { const explicitSkills = join(root, "explicit-skills"); await mkdir(join(projectRoot, "skills", "local", "project-skill"), { recursive: true }); await mkdir(join(projectRoot, "skills", "installed", "installed-skill"), { recursive: true }); - await mkdir(join(agentDir, "skills", "global-skill"), { recursive: true }); - await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); - await mkdir(join(explicitSkills, "disabled"), { recursive: true }); + await mkdir(join(projectRoot, "skills", "core", "skill-authoring-lite"), { recursive: true }); + await mkdir(join(projectRoot, "skills", "local", "duplicate-priority-skill"), { recursive: true }); + await mkdir(join(projectRoot, "skills", "installed", "duplicate-priority-skill"), { recursive: true }); + await mkdir(join(agentDir, "skills", "duplicate-priority-skill"), { recursive: true }); + await mkdir(join(agentDir, "skills", "global-only-skill"), { recursive: true }); + await mkdir(join(projectRoot, "skills", "local", "create-plan"), { recursive: true }); + await mkdir(join(explicitSkills, "external-global-skill"), { recursive: true }); await writeFile( join(projectRoot, "skills", "local", "project-skill", "SKILL.md"), @@ -44,61 +49,80 @@ try { ].join("\n"), ); await writeFile( - join(agentDir, "skills", "global-skill", "SKILL.md"), + join(projectRoot, "skills", "core", "skill-authoring-lite", "SKILL.md"), [ "---", - "name: duplicate-skill", - "description: First duplicate wins.", + "name: skill-authoring-lite", + "description: Legacy core should lose to system.", "---", "", - "# Global Skill", + "# Legacy Core Skill", ].join("\n"), ); await writeFile( - join(explicitSkills, "duplicate", "SKILL.md"), + join(projectRoot, "skills", "local", "duplicate-priority-skill", "SKILL.md"), [ "---", - "name: duplicate-skill", - "description: Duplicate loser.", + "name: duplicate-priority-skill", + "description: Local wins over installed and global.", "---", "", - "# Duplicate Skill", + "# Duplicate Local", ].join("\n"), ); - await mkdir(join(projectRoot, "skills", "local", "duplicate-local"), { recursive: true }); - await mkdir(join(projectRoot, "skills", "installed", "duplicate-installed"), { recursive: true }); await writeFile( - join(projectRoot, "skills", "local", "duplicate-local", "SKILL.md"), + join(projectRoot, "skills", "installed", "duplicate-priority-skill", "SKILL.md"), [ "---", "name: duplicate-priority-skill", - "description: Local wins.", + "description: Installed loses to local.", "---", "", - "# Duplicate Local", + "# Duplicate Installed", ].join("\n"), ); await writeFile( - join(projectRoot, "skills", "installed", "duplicate-installed", "SKILL.md"), + join(agentDir, "skills", "duplicate-priority-skill", "SKILL.md"), [ "---", "name: duplicate-priority-skill", - "description: Installed loses to local.", + "description: Global loses to local and installed.", "---", "", - "# Duplicate Installed", + "# Duplicate Global", ].join("\n"), ); await writeFile( - join(explicitSkills, "disabled", "SKILL.md"), + join(agentDir, "skills", "global-only-skill", "SKILL.md"), [ "---", - "name: hidden-skill", - "description: Hidden skill.", - "disable-model-invocation: true", + "name: global-only-skill", + "description: Global-only skill.", "---", "", - "# Hidden Skill", + "# Global Only Skill", + ].join("\n"), + ); + await writeFile( + join(projectRoot, "skills", "local", "create-plan", "SKILL.md"), + [ + "---", + "name: create-plan", + "description: Local create-plan should lose to system.", + "---", + "", + "# Local Create Plan", + ].join("\n"), + ); + await writeFile( + join(explicitSkills, "external-global-skill", "SKILL.md"), + [ + "---", + "name: external-global-skill", + "description: External skill paths map into global source semantics.", + "---", + "", + "# External Global Skill", ].join("\n"), ); @@ -120,17 +144,24 @@ try { PORT: "1", }); const loaded = loadWorkspaceSkills(config, projectRoot); - assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "installed-skill"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "devspace-workflow"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "senior-architect-lite"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "skill-authoring-lite"), true); - assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-skill").length, 1); - assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-priority-skill").length, 1); + + assert.equal(loaded.skills.some((skill) => skill.name === "project-skill" && skill.source === "local"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "installed-skill" && skill.source === "installed"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "global-only-skill" && skill.source === "global"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "create-plan" && skill.source === "system"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "define-goal" && skill.source === "system"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "skill-authoring-lite" && skill.source === "system"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "external-global-skill" && skill.source === "global"), true); + const duplicatePrioritySkill = loaded.skills.find((skill) => skill.name === "duplicate-priority-skill"); assert.ok(duplicatePrioritySkill); - assert.match(duplicatePrioritySkill.filePath, /skills\/local\/duplicate-local\/SKILL\.md$/); - assert.equal(loaded.skills.some((skill) => skill.name === "hidden-skill"), true); + assert.equal(duplicatePrioritySkill.source, "local"); + assert.match(duplicatePrioritySkill.filePath, /skills\/local\/duplicate-priority-skill\/SKILL\.md$/); + + const legacySystemSkill = loaded.skills.find((skill) => skill.name === "skill-authoring-lite"); + assert.ok(legacySystemSkill); + assert.doesNotMatch(legacySystemSkill.filePath, /skills\/core\/skill-authoring-lite\/SKILL\.md$/); + assert.equal(loaded.diagnostics.some((diagnostic) => String(diagnostic.message).includes("skills/core is deprecated")), true); assert.equal(loaded.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true); const projectSkill = loaded.skills.find((skill) => skill.name === "project-skill"); @@ -145,20 +176,26 @@ try { await writeFile(resourcePath, "reference\n"); assert.equal(resolveSkillReadPath(loaded.skills, new Set(), resourcePath), undefined); assert.equal( - resolveSkillReadPath(loaded.skills, new Set([projectSkill.baseDir]), resourcePath) - ?.isSkillFile, + resolveSkillReadPath(loaded.skills, new Set([projectSkill.baseDir]), resourcePath)?.isSkillFile, false, ); - const bundledWorkflowSkill = loaded.skills.find((skill) => skill.name === "devspace-workflow"); - assert.ok(bundledWorkflowSkill); - const bundledReferencePath = join(bundledWorkflowSkill.baseDir, "references", "commands.md"); - assert.equal(resolveSkillReadPath(loaded.skills, new Set(), bundledReferencePath), undefined); - assert.equal( - resolveSkillReadPath(loaded.skills, new Set([bundledWorkflowSkill.baseDir]), bundledReferencePath) - ?.isSkillFile, - false, - ); + const resolvedPlan = await resolveSkillDefinition(loaded.skills, "/plan"); + assert.equal(resolvedPlan.name, "create-plan"); + assert.equal(resolvedPlan.source, "system"); + assert.equal(resolvedPlan.alias, "/plan"); + assert.equal(resolvedPlan.mode, "read_only"); + assert.match(resolvedPlan.instructions, /# Create Plan/); + + const resolvedGoal = await resolveSkillDefinition(loaded.skills, "/goal"); + assert.equal(resolvedGoal.name, "define-goal"); + assert.equal(resolvedGoal.source, "system"); + assert.equal(resolvedGoal.alias, "/goal"); + assert.equal(resolvedGoal.mode, "normal"); + + const resolvedExplicit = await resolveSkillDefinition(loaded.skills, "global-only-skill"); + assert.equal(resolvedExplicit.name, "global-only-skill"); + assert.equal(resolvedExplicit.source, "global"); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/src/skills.ts b/src/skills.ts index df7f3b9..c69709d 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,66 +1,104 @@ +import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { loadSkills, + loadSkillsFromDir, type Skill, type LoadSkillsResult, } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; import { expandHomePath, isPathInsideRoot } from "./roots.js"; +export type SkillSource = "system" | "local" | "installed" | "global"; +export type SkillResolveMode = "read_only" | "normal"; + +export interface DevSpaceSkill extends Skill { + source: SkillSource; + aliases?: string[]; + resolveMode: SkillResolveMode; + legacyCore?: boolean; +} + export interface LoadedSkills { - skills: Skill[]; + skills: DevSpaceSkill[]; diagnostics: LoadSkillsResult["diagnostics"]; } export interface SkillReadResolution { absolutePath: string; - skill: Skill; + skill: DevSpaceSkill; isSkillFile: boolean; } +export interface ResolvedSkillDefinition { + name: string; + source: SkillSource; + path: string; + alias?: string; + mode: SkillResolveMode; + instructions: string; + skill: DevSpaceSkill; +} + +interface SkillBatch { + skills: DevSpaceSkill[]; + diagnostics: LoadSkillsResult["diagnostics"]; +} + +const PLAN_ALIAS = "/plan"; +const GOAL_ALIAS = "/goal"; + export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSkills { if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; - const batches = [ - loadSkills({ - cwd, - agentDir: config.agentDir, - skillPaths: [workspaceLocalSkillPath(cwd)], - includeDefaults: false, - }), - loadSkills({ - cwd, - agentDir: config.agentDir, - skillPaths: [workspaceInstalledSkillPath(cwd)], - includeDefaults: false, - }), - loadSkills({ - cwd, - agentDir: config.agentDir, - skillPaths: [bundledSkillPath()], - includeDefaults: false, - }), - loadSkills({ - cwd, - agentDir: config.agentDir, - skillPaths: [], - includeDefaults: true, - }), - loadSkills({ - cwd, - agentDir: config.agentDir, - skillPaths: config.skillPaths, - includeDefaults: false, - }), + const batches: SkillBatch[] = [ + loadSkillsFromSourceDir(bundledSystemSkillPath(), "system"), + loadSkillsFromSourceDir(legacyWorkspaceCorePath(cwd), "system", { legacyCore: true }), + loadSkillsFromSourceDir(workspaceLocalSkillPath(cwd), "local"), + loadSkillsFromSourceDir(workspaceInstalledSkillPath(cwd), "installed"), + loadSkillsFromSourceDir(globalSkillPath(config.agentDir), "global"), + loadExplicitSkillPaths(config, cwd), ]; return mergeLoadedSkills(batches); } +export async function resolveSkillDefinition( + skills: DevSpaceSkill[], + nameOrAlias: string, +): Promise { + const lookup = normalizeSkillLookup(nameOrAlias); + const alias = lookup === PLAN_ALIAS || lookup === GOAL_ALIAS ? lookup : undefined; + const skillName = alias === PLAN_ALIAS + ? "create-plan" + : alias === GOAL_ALIAS + ? "define-goal" + : lookup; + + const skill = skills.find((candidate) => { + if (candidate.name === skillName) return true; + return candidate.aliases?.includes(lookup) ?? false; + }); + + if (!skill) { + throw new Error(`Skill not found: ${nameOrAlias}`); + } + + return { + name: skill.name, + source: skill.source, + path: resolve(skill.filePath), + alias, + mode: skill.resolveMode, + instructions: await readFile(skill.filePath, "utf8"), + skill, + }; +} + export function resolveSkillReadPath( - skills: Skill[], + skills: DevSpaceSkill[], activatedSkillDirs: Set, inputPath: string, ): SkillReadResolution | undefined { @@ -86,7 +124,7 @@ export function resolveSkillReadPath( export function markSkillActivated( activatedSkillDirs: Set, - skill: Skill, + skill: DevSpaceSkill, ): void { activatedSkillDirs.add(resolve(skill.baseDir)); } @@ -103,8 +141,25 @@ export function formatPathForPrompt(path: string): string { return resolvedPath.split(sep).join("/"); } -function bundledSkillPath(): string { - return resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", "core"); +export function skillSourceLabel(source: SkillSource): string { + switch (source) { + case "system": + return "系统内置"; + case "local": + return "项目自定义"; + case "installed": + return "项目已安装"; + case "global": + return "全局已安装"; + } +} + +function bundledSystemSkillPath(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", ".system"); +} + +function legacyWorkspaceCorePath(cwd: string): string { + return resolve(cwd, "skills", "core"); } function workspaceLocalSkillPath(cwd: string): string { @@ -115,8 +170,79 @@ function workspaceInstalledSkillPath(cwd: string): string { return resolve(cwd, "skills", "installed"); } -function mergeLoadedSkills(batches: LoadedSkills[]): LoadedSkills { - const winners = new Map(); +function globalSkillPath(agentDir: string): string { + return resolve(agentDir, "skills"); +} + +function loadSkillsFromSourceDir( + dir: string, + source: SkillSource, + options: { legacyCore?: boolean } = {}, +): SkillBatch { + const loaded = loadSkillsFromDir({ + dir, + source: source === "global" ? "user" : source, + }); + + const diagnostics = [...loaded.diagnostics]; + if (options.legacyCore && loaded.skills.length > 0) { + diagnostics.push({ + type: "warning", + message: `skills/core is deprecated; migrate these skills to skills/.system.`, + path: dir, + }); + } + + return { + diagnostics, + skills: loaded.skills.map((skill) => decorateSkill(skill, source, options)), + }; +} + +function loadExplicitSkillPaths(config: ServerConfig, cwd: string): SkillBatch { + if (config.skillPaths.length === 0) { + return { skills: [], diagnostics: [] }; + } + + const loaded = loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: config.skillPaths, + includeDefaults: false, + }); + + return { + diagnostics: loaded.diagnostics, + skills: loaded.skills.map((skill) => decorateSkill(skill, "global")), + }; +} + +function decorateSkill( + skill: Skill, + source: SkillSource, + options: { legacyCore?: boolean } = {}, +): DevSpaceSkill { + return { + ...skill, + source, + aliases: aliasesForSkill(skill.name), + resolveMode: resolveModeForSkill(skill.name), + legacyCore: options.legacyCore, + }; +} + +function aliasesForSkill(name: string): string[] | undefined { + if (name === "create-plan") return [PLAN_ALIAS]; + if (name === "define-goal") return [GOAL_ALIAS]; + return undefined; +} + +function resolveModeForSkill(name: string): SkillResolveMode { + return name === "create-plan" ? "read_only" : "normal"; +} + +function mergeLoadedSkills(batches: SkillBatch[]): LoadedSkills { + const winners = new Map(); const diagnostics: LoadSkillsResult["diagnostics"] = []; for (const batch of batches) { @@ -130,7 +256,7 @@ function mergeLoadedSkills(batches: LoadedSkills[]): LoadedSkills { diagnostics.push({ type: "collision", - message: `name "${skill.name}" collision`, + message: `name "${skill.name}" collision (${skillSourceLabel(existing.source)} wins over ${skillSourceLabel(skill.source)})`, path: skill.filePath, collision: { resourceType: "skill", @@ -147,3 +273,12 @@ function mergeLoadedSkills(batches: LoadedSkills[]): LoadedSkills { diagnostics, }; } + +function normalizeSkillLookup(nameOrAlias: string): string { + const trimmed = nameOrAlias.trim().replace(/^@\S+\s+/, ""); + if (trimmed.startsWith("/")) { + return trimmed.split(/\s+/)[0] ?? trimmed; + } + + return trimmed; +} diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index c0f3d03..d6cd239 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -2,6 +2,7 @@ import type { App } from "@modelcontextprotocol/ext-apps"; export type ToolName = | "open_workspace" + | "resolve_skill" | "install_skill" | "list_installed_skills" | "remove_skill" @@ -100,6 +101,7 @@ export interface ToolPayload { export function isToolName(value: unknown): value is ToolName { return ( value === "open_workspace" || + value === "resolve_skill" || value === "install_skill" || value === "list_installed_skills" || value === "remove_skill" || diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index b2b1bab..f896c2a 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -510,6 +510,8 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { switch (card.tool) { case "open_workspace": return { icon: folderIcon(), title: "Workspace", label, tone: "workspace" }; + case "resolve_skill": + return { icon: fileIcon(), title: "Resolve Skill", label, tone: "read" }; case "install_skill": return { icon: filePlusIcon(), title: "Install Skill", label, tone: "write" }; case "list_installed_skills": diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 57e9c46..2ffe31f 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -115,6 +115,12 @@ export interface WorkspaceStore { tokenBudget?: number; }): WorkspaceGoal; getGoal(workspaceSessionId: string): WorkspaceGoal | undefined; + updateGoal(input: { + workspaceSessionId: string; + objective?: string; + tokenBudget?: number; + status?: "active" | "complete" | "blocked"; + }): WorkspaceGoal; updateGoalStatus(input: { workspaceSessionId: string; status: "complete" | "blocked"; @@ -328,29 +334,39 @@ export class SqliteWorkspaceStore implements WorkspaceStore { return row ? rowToWorkspaceGoal(row) : undefined; } - updateGoalStatus(input: { + updateGoal(input: { workspaceSessionId: string; - status: "complete" | "blocked"; + objective?: string; + tokenBudget?: number; + status?: "active" | "complete" | "blocked"; }): WorkspaceGoal { const existing = this.getGoal(input.workspaceSessionId); if (!existing) { throw new Error("No goal exists for this workspace."); } - if (existing.status !== "active") { - throw new Error(`Goal is already ${existing.status}. Create a new goal to continue.`); - } const updatedAt = new Date().toISOString(); - const completedAt = input.status === "complete" ? updatedAt : null; - const blockedAt = input.status === "blocked" ? updatedAt : null; - const activeSeconds = calculateGoalActiveSeconds(existing, updatedAt); + const nextStatus = input.status ?? existing.status; + const completedAt = nextStatus === "complete" + ? existing.completedAt ?? updatedAt + : null; + const blockedAt = nextStatus === "blocked" + ? existing.blockedAt ?? updatedAt + : null; + const activeSeconds = nextStatus === "active" + ? String(existing.timeUsedSeconds) + : String(calculateGoalActiveSeconds(existing, updatedAt)); this.database.db .update(workspaceGoals) .set({ - status: input.status, + objective: input.objective ?? existing.objective, + tokenBudget: input.tokenBudget === undefined + ? (existing.tokenBudget === undefined ? null : String(existing.tokenBudget)) + : String(input.tokenBudget), + status: nextStatus, updatedAt, - activeSeconds: String(activeSeconds), + activeSeconds, completedAt, blockedAt, }) @@ -365,6 +381,16 @@ export class SqliteWorkspaceStore implements WorkspaceStore { return updated; } + updateGoalStatus(input: { + workspaceSessionId: string; + status: "complete" | "blocked"; + }): WorkspaceGoal { + return this.updateGoal({ + workspaceSessionId: input.workspaceSessionId, + status: input.status, + }); + } + setCollaborationMode(input: { workspaceSessionId: string; mode: CollaborationMode; From b3831f2b0cb887c871595919df16b410c49b4a80 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 15:56:12 +0800 Subject: [PATCH 19/41] docs: clarify strict skill install targets --- README.md | 5 +++++ docs/chatgpt-coding-workflow.md | 4 ++++ docs/configuration.md | 5 +++++ src/cli-skills.test.ts | 1 + src/cli.ts | 2 ++ src/skill-manager.test.ts | 35 +++++++++++++++++++++++++++++++++ 6 files changed, 52 insertions(+) diff --git a/README.md b/README.md index 95663c7..e45e8c0 100644 --- a/README.md +++ b/README.md @@ -187,14 +187,19 @@ Manage installed skills with: ```bash devspace skills install --repo openai/skills --path skills/.curated/research +devspace skills install --workspace /path/to/project --repo openai/skills --path skills/.curated/research devspace skills list +devspace skills list --workspace /path/to/project devspace skills remove research +devspace skills remove --workspace /path/to/project research devspace skills install -g --repo openai/skills --path skills/.curated/research devspace skills list -g devspace skills remove -g research ``` +`--repo/--path` and `--local-path` must point directly at one standard skill directory that contains `SKILL.md`. Repository roots, plugin roots, command folders, and agent-rules directories are rejected. + ## Mental Model DevSpace is remote access to selected local folders. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 6a93cbc..e35c88c 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -97,6 +97,10 @@ User-installed project skills can be managed through DevSpace itself: 请使用 DevSpace 打开当前项目,然后调用 install_skill,把 GitHub 仓库 openai/skills 里的 skills/.curated/research 安装到当前 workspace。 ``` +```text +请注意 install_skill 只接受标准 skill 包目录。像仓库根目录、plugin 目录、commands 目录或 agent rules 目录都不应该安装,只有直接包含 SKILL.md 的 skill 目录才可以。 +``` + ```text 请调用 list_installed_skills,列出当前 workspace 的 installed skills。 ``` diff --git a/docs/configuration.md b/docs/configuration.md index a87436b..f30de49 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -218,14 +218,19 @@ Manage installed skills with: ```bash devspace skills install --repo openai/skills --path skills/.curated/research +devspace skills install --workspace /path/to/project --repo openai/skills --path skills/.curated/research devspace skills list +devspace skills list --workspace /path/to/project devspace skills remove research +devspace skills remove --workspace /path/to/project research devspace skills install -g --repo openai/skills --path skills/.curated/research devspace skills list -g devspace skills remove -g research ``` +Both `--repo/--path` and `--local-path` must point directly at one standard skill directory with `SKILL.md`. Plugin roots, command folders, and agent-rules directories are not valid install targets. + Example: ```bash diff --git a/src/cli-skills.test.ts b/src/cli-skills.test.ts index 8eda9b9..d8002c8 100644 --- a/src/cli-skills.test.ts +++ b/src/cli-skills.test.ts @@ -14,6 +14,7 @@ try { assert.match(help, /devspace skills install/); assert.match(help, /devspace skills list -g/); assert.match(help, /devspace skills remove -g/); + assert.match(help, /install expects the target path to point at one standard skill directory with a SKILL\.md file/); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/cli.ts b/src/cli.ts index f9f918f..2be08a1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -575,6 +575,8 @@ function printHelp(): void { " devspace skills list -g", " devspace skills remove [--workspace ] ", " devspace skills remove -g ", + " install expects the target path to point at one standard skill directory with a SKILL.md file", + " plugin roots, command folders, and agent-rules directories are rejected", " devspace service install [--autostart]", " devspace service uninstall", " devspace service enable|disable|start|stop|restart", diff --git a/src/skill-manager.test.ts b/src/skill-manager.test.ts index 6be061f..d4d44a8 100644 --- a/src/skill-manager.test.ts +++ b/src/skill-manager.test.ts @@ -24,6 +24,8 @@ try { const conflictingLocal = join(root, "create-plan"); const invalidDirSkill = join(root, "mismatched-dir"); const symlinkSkill = join(root, "symlink-skill"); + const pluginLikeRoot = join(root, "plugin-like-root"); + const commandsOnlyDir = join(root, "commands-only"); await mkdir(projectRoot, { recursive: true }); await mkdir(join(agentDir, "skills"), { recursive: true }); @@ -101,6 +103,15 @@ try { ); await symlink(join(root, "project"), join(symlinkSkill, "linked-project")); + await mkdir(pluginLikeRoot, { recursive: true }); + await writeFile( + join(pluginLikeRoot, "plugin.json"), + JSON.stringify({ name: "plugin-like-root" }, null, 2), + ); + + await mkdir(commandsOnlyDir, { recursive: true }); + await writeFile(join(commandsOnlyDir, "README.md"), "# Commands Only\n"); + const config = loadConfig({ DEVSPACE_ALLOWED_ROOTS: `${projectRoot},${root}`, DEVSPACE_AGENT_DIR: agentDir, @@ -207,6 +218,30 @@ try { /symlink/, ); + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: pluginLikeRoot }, + localPathResolver: (path) => path, + }), + /missing SKILL\.md/, + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: commandsOnlyDir }, + localPathResolver: (path) => path, + }), + /missing SKILL\.md/, + ); + await assert.rejects( () => installSkill({ From afe47908acef501b387f02d6f7fafdcd50b12a66 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 16:03:15 +0800 Subject: [PATCH 20/41] test: avoid hardcoded local path in cli skills test --- src/cli-skills.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli-skills.test.ts b/src/cli-skills.test.ts index d8002c8..9b0b893 100644 --- a/src/cli-skills.test.ts +++ b/src/cli-skills.test.ts @@ -3,12 +3,14 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; const root = mkdtempSync(join(tmpdir(), "devspace-cli-skills-test-")); +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); try { const help = execFileSync("node", ["--import", "tsx", "src/cli.ts", "help"], { - cwd: "/Users/thinkook/workspace/open_source/devspace", + cwd: repoRoot, encoding: "utf8", }); assert.match(help, /devspace skills install/); From 782d054d8443403a569c1b3286419e4d65053e8b Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 17:02:32 +0800 Subject: [PATCH 21/41] fix: honor isolated service config paths --- src/service.test.ts | 35 +++++++++++++++++++++++++++++++++-- src/service/manager.ts | 26 +++++++++++++++++++------- src/service/templates.ts | 3 ++- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/service.test.ts b/src/service.test.ts index 972eb85..d447753 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -3,13 +3,14 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; -import { createServiceManager } from "./service/manager.js"; -import { buildLaunchAgentPlist, buildSystemdUnit } from "./service/templates.js"; +import { createServiceManager, detectServiceManagerKind } from "./service/manager.js"; +import { buildLaunchAgentPlist, buildSystemdUnit, devspaceLogDir } from "./service/templates.js"; import type { CommandRunner } from "./service/runner.js"; const root = mkdtempSync(join(tmpdir(), "devspace-service-test-")); try { + process.env.DEVSPACE_CONFIG_DIR = root; const config = loadConfig({ DEVSPACE_CONFIG_DIR: root, DEVSPACE_ALLOWED_ROOTS: root, @@ -23,6 +24,8 @@ try { assert.match(systemdUnit, /ExecStart=/); assert.match(systemdUnit, /Restart=on-failure/); assert.match(systemdUnit, /devspace\.out\.log/); + assert.equal(devspaceLogDir(), join(root, "logs")); + assert.match(systemdUnit, new RegExp(`${escapeRegExp(join(root, "logs", "devspace.out.log"))}`)); const launchdPlist = buildLaunchAgentPlist({ cliEntrypoint: "/tmp/devspace/dist/cli.js", @@ -31,6 +34,7 @@ try { assert.match(launchdPlist, /ProgramArguments/); assert.match(launchdPlist, /service-run/); assert.match(launchdPlist, /devspace\.err\.log/); + assert.match(launchdPlist, new RegExp(`${escapeRegExp(join(root, "logs", "devspace.err.log"))}`)); const runner = createMockRunner(); const manager = createServiceManager({ @@ -41,10 +45,37 @@ try { const status = await manager.status(); assert.equal(typeof status.installed, "boolean"); assert.equal(status.endpoint?.endsWith(config.mcpPath), true); + + assert.equal( + detectServiceManagerKind({ + platform: "linux", + env: { XDG_RUNTIME_DIR: "/run/user/0" }, + }), + "systemd-user", + ); + assert.equal( + detectServiceManagerKind({ + platform: "linux", + env: {}, + }), + "unsupported", + ); + assert.equal( + detectServiceManagerKind({ + platform: "linux", + env: { DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/0/bus" }, + }), + "systemd-user", + ); } finally { + delete process.env.DEVSPACE_CONFIG_DIR; rmSync(root, { recursive: true, force: true }); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function createMockRunner(): CommandRunner { return { async exec(command, args) { diff --git a/src/service/manager.ts b/src/service/manager.ts index e9da6f7..210e8f7 100644 --- a/src/service/manager.ts +++ b/src/service/manager.ts @@ -25,6 +25,11 @@ interface ManagerContext { runner?: CommandRunner; } +interface DetectServiceManagerOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +} + export function createServiceManager(context: ManagerContext): ServiceManager { const kind = detectServiceManagerKind(); const runner = context.runner ?? defaultCommandRunner; @@ -84,18 +89,25 @@ export function updateServiceConfigMetadata( return config; } -export function detectServiceManagerKind(): ServiceManagerKind { - if (platform() === "darwin") return "launchd"; - if (platform() === "win32") return "windows-task-scheduler"; - if (process.env.WSL_DISTRO_NAME) { - return process.env.SYSTEMD_EXEC_PID ? "systemd-user" : "wsl-task-scheduler-fallback"; +export function detectServiceManagerKind(options: DetectServiceManagerOptions = {}): ServiceManagerKind { + const currentPlatform = options.platform ?? platform(); + const env = options.env ?? process.env; + + if (currentPlatform === "darwin") return "launchd"; + if (currentPlatform === "win32") return "windows-task-scheduler"; + if (env.WSL_DISTRO_NAME) { + return hasSystemdUserSession(env) ? "systemd-user" : "wsl-task-scheduler-fallback"; } - if (platform() === "linux") { - return process.env.SYSTEMD_EXEC_PID ? "systemd-user" : "unsupported"; + if (currentPlatform === "linux") { + return hasSystemdUserSession(env) ? "systemd-user" : "unsupported"; } return "unsupported"; } +function hasSystemdUserSession(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.SYSTEMD_EXEC_PID || env.XDG_RUNTIME_DIR || env.DBUS_SESSION_BUS_ADDRESS); +} + function createUnsupportedManager(config: ServerConfig): ServiceManager { return { kind: "unsupported", diff --git a/src/service/templates.ts b/src/service/templates.ts index 7f6ec44..355d7b0 100644 --- a/src/service/templates.ts +++ b/src/service/templates.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import type { ServerConfig } from "../config.js"; +import { devspaceConfigDir } from "../user-config.js"; export interface ServiceCommandSpec { command: string; @@ -8,7 +9,7 @@ export interface ServiceCommandSpec { } export function devspaceLogDir(): string { - return join(homedir(), ".devspace", "logs"); + return join(devspaceConfigDir(), "logs"); } export function buildServiceCommand(cliEntrypoint: string): ServiceCommandSpec { From ee2671c7fc8a9612f7ba5004925342d083ce7531 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 18:54:42 +0800 Subject: [PATCH 22/41] Simplify service commands and make start install-on-demand --- README.md | 17 +++-- docs/configuration.md | 15 ++-- src/cli.ts | 30 +++----- src/config-operations.test.ts | 6 -- src/service.test.ts | 27 ++++++- src/service/manager.ts | 136 ++++++++++++---------------------- src/service/types.ts | 6 -- 7 files changed, 104 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index e45e8c0..1f679ca 100644 --- a/README.md +++ b/README.md @@ -133,14 +133,19 @@ until you explicitly add an allowed path. ## Service Management -DevSpace service management only manages DevSpace itself. It does not manage -arbitrary system services. +DevSpace service management only manages DevSpace itself. `devspace service start` +acts as the single entrypoint: if the background service is missing, DevSpace +creates it for the current platform and starts it; if it already exists, it +just starts it. It does not manage arbitrary system services. ```bash -devspace service install --autostart +devspace service start devspace service status -devspace service logs --tail 100 +devspace service logs devspace service restart +devspace service stop +devspace service disable +devspace service uninstall devspace service doctor ``` @@ -219,8 +224,8 @@ For a normal ChatGPT coding session: ## Platform Support DevSpace supports Linux, macOS, and Windows environments with a Bash-compatible -shell for the main CLI, and supports native per-user service installation on -macOS, Linux, Windows, and WSL. +shell for the main CLI, and supports native per-user service control on macOS, +Linux, Windows, and WSL. | Platform | Status | Notes | | ------------------------------------------------- | ----------------- | ---------------------------------------------- | diff --git a/docs/configuration.md b/docs/configuration.md index f30de49..50e765c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,9 +29,9 @@ npx @waishnav/devspace config domain devspace.example.com npx @waishnav/devspace config key npx @waishnav/devspace workspace add ~/workspace/project-a --default npx @waishnav/devspace workspace list -npx @waishnav/devspace service install --autostart +npx @waishnav/devspace service start npx @waishnav/devspace service status -npx @waishnav/devspace service logs --tail 100 +npx @waishnav/devspace service logs npx @waishnav/devspace config get ``` @@ -83,13 +83,18 @@ starts in a safe blocked state with no authorized workspace roots. ## Service Management -DevSpace only manages its own background service: +DevSpace only manages its own background service. `devspace service start` +installs the service on first use for the current platform, and starts it on +later runs: ```bash -devspace service install --autostart +devspace service start devspace service status devspace service restart -devspace service logs --tail 200 +devspace service stop +devspace service disable +devspace service uninstall +devspace service logs devspace service doctor ``` diff --git a/src/cli.ts b/src/cli.ts index 2be08a1..f15f02c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -415,15 +415,9 @@ async function runServiceCommand(args: string[]): Promise { const [subcommand, ...rest] = args; switch (subcommand) { - case "install": - console.log((await manager.install({ autostart: rest.includes("--autostart") })).message); - return; case "uninstall": console.log((await manager.uninstall()).message); return; - case "enable": - console.log((await manager.enable()).message); - return; case "disable": console.log((await manager.disable()).message); return; @@ -438,10 +432,6 @@ async function runServiceCommand(args: string[]): Promise { return; case "status": { const status = await manager.status(); - if (rest.includes("--json")) { - console.log(JSON.stringify(status, null, 2)); - return; - } console.log([ `manager: ${status.manager}`, `service: ${status.serviceName}`, @@ -454,9 +444,11 @@ async function runServiceCommand(args: string[]): Promise { ].join("\n")); return; } - case "logs": - console.log(await manager.logs({ tail: parseTailArgument(rest) })); + case "logs": { + const tail = parseTailArgument(rest); + console.log(await manager.logs(tail === undefined ? undefined : { tail })); return; + } case "doctor": { const doctor = await manager.doctor(); console.log([ @@ -577,10 +569,12 @@ function printHelp(): void { " devspace skills remove -g ", " install expects the target path to point at one standard skill directory with a SKILL.md file", " plugin roots, command folders, and agent-rules directories are rejected", - " devspace service install [--autostart]", + " devspace service start", " devspace service uninstall", - " devspace service enable|disable|start|stop|restart", - " devspace service status [--json]", + " devspace service disable", + " devspace service stop", + " devspace service restart", + " devspace service status", " devspace service logs [--tail N]", " devspace service doctor", "", @@ -594,11 +588,11 @@ function printHelp(): void { ); } -function parseTailArgument(args: string[]): number { +function parseTailArgument(args: string[]): number | undefined { const index = args.indexOf("--tail"); - if (index === -1) return 200; + if (index === -1) return undefined; const value = Number(args[index + 1]); - return Number.isInteger(value) && value > 0 ? value : 200; + return Number.isInteger(value) && value > 0 ? value : undefined; } function extractServeArgs(args: string[]): { additionalRoots: string[]; workspace?: string } { diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index 539c090..57199de 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -27,15 +27,9 @@ const testManager: ServiceManager = { async isSupported() { return false; }, - async install() { - return { ok: false, manager: "unsupported", message: "unsupported" }; - }, async uninstall() { return { ok: false, manager: "unsupported", message: "unsupported" }; }, - async enable() { - return { ok: false, manager: "unsupported", message: "unsupported" }; - }, async disable() { return { ok: false, manager: "unsupported", message: "unsupported" }; }, diff --git a/src/service.test.ts b/src/service.test.ts index d447753..71e37e5 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; @@ -8,8 +8,10 @@ import { buildLaunchAgentPlist, buildSystemdUnit, devspaceLogDir } from "./servi import type { CommandRunner } from "./service/runner.js"; const root = mkdtempSync(join(tmpdir(), "devspace-service-test-")); +const originalHome = process.env.HOME; try { + process.env.HOME = root; process.env.DEVSPACE_CONFIG_DIR = root; const config = loadConfig({ DEVSPACE_CONFIG_DIR: root, @@ -42,10 +44,19 @@ try { cliEntrypoint: "/tmp/devspace/dist/cli.js", runner, }); + const startResult = await manager.start(); + assert.equal(startResult.ok, true); + assert.match(startResult.message, /Started service|Installed and started service|Started task|Installed and started task/); const status = await manager.status(); assert.equal(typeof status.installed, "boolean"); assert.equal(status.endpoint?.endsWith(config.mcpPath), true); + const logPath = join(root, "logs", "devspace.out.log"); + mkdirSync(join(root, "logs"), { recursive: true }); + writeFileSync(logPath, "line-1\nline-2\nline-3\n", "utf8"); + assert.equal(await manager.logs(), "line-1\nline-2\nline-3\n"); + assert.equal(await manager.logs({ tail: 2 }), "line-3\n"); + assert.equal( detectServiceManagerKind({ platform: "linux", @@ -68,6 +79,11 @@ try { "systemd-user", ); } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } delete process.env.DEVSPACE_CONFIG_DIR; rmSync(root, { recursive: true, force: true }); } @@ -80,10 +96,10 @@ function createMockRunner(): CommandRunner { return { async exec(command, args) { if (command === "systemctl" && args.includes("is-enabled")) { - return { stdout: "enabled", stderr: "", exitCode: 0 }; + return { stdout: "", stderr: "", exitCode: 1 }; } if (command === "systemctl" && args.includes("is-active")) { - return { stdout: "active", stderr: "", exitCode: 0 }; + return { stdout: "", stderr: "", exitCode: 1 }; } if (command === "systemctl") { return { stdout: "", stderr: "", exitCode: 0 }; @@ -92,7 +108,10 @@ function createMockRunner(): CommandRunner { return { stdout: "", stderr: "", exitCode: 0 }; } if (command === "schtasks.exe") { - return { stdout: "Status: Running\nScheduled Task State: Enabled\n", stderr: "", exitCode: 0 }; + if (args.includes("/FO")) { + return { stdout: "", stderr: "", exitCode: 1 }; + } + return { stdout: "", stderr: "", exitCode: 1 }; } return { stdout: "", stderr: "", exitCode: 0 }; }, diff --git a/src/service/manager.ts b/src/service/manager.ts index 210e8f7..ad3d8ac 100644 --- a/src/service/manager.ts +++ b/src/service/manager.ts @@ -2,13 +2,10 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { homedir, platform } from "node:os"; import { join } from "node:path"; import type { ServerConfig } from "../config.js"; -import type { DevspaceUserConfig } from "../user-config.js"; -import { writeDevspaceConfig } from "../user-config.js"; import { defaultCommandRunner, type CommandRunner } from "./runner.js"; -import { buildLaunchAgentPlist, buildSystemdUnit, buildServiceCommand, devspaceLogDir } from "./templates.js"; +import { buildLaunchAgentPlist, buildServiceCommand, buildSystemdUnit, devspaceLogDir } from "./templates.js"; import type { ServiceDoctorResult, - ServiceInstallOptions, ServiceManager, ServiceManagerKind, ServiceResult, @@ -73,22 +70,6 @@ export async function restartServiceIfRunning( return { restarted: true }; } -export function updateServiceConfigMetadata( - config: DevspaceUserConfig, - manager: ServiceManager, - autostart: boolean, -): DevspaceUserConfig { - writeDevspaceConfig({ - ...config, - service: { - ...(config.service ?? {}), - manager: manager.kind, - autostart, - }, - }); - return config; -} - export function detectServiceManagerKind(options: DetectServiceManagerOptions = {}): ServiceManagerKind { const currentPlatform = options.platform ?? platform(); const env = options.env ?? process.env; @@ -115,15 +96,9 @@ function createUnsupportedManager(config: ServerConfig): ServiceManager { async isSupported() { return false; }, - async install() { - return { ok: false, manager: "unsupported", message: unsupportedMessage() }; - }, async uninstall() { return { ok: false, manager: "unsupported", message: unsupportedMessage() }; }, - async enable() { - return { ok: false, manager: "unsupported", message: unsupportedMessage() }; - }, async disable() { return { ok: false, manager: "unsupported", message: unsupportedMessage() }; }, @@ -160,21 +135,6 @@ function createSystemdUserManager(context: Required): ServiceMan const result = await context.runner.exec("systemctl", ["--user", "--version"]); return result.exitCode === 0; }, - async install(options) { - mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true }); - mkdirSync(devspaceLogDir(), { recursive: true }); - writeFileSync(unitPath, buildSystemdUnit({ cliEntrypoint: context.cliEntrypoint, config: context.config }), "utf8"); - await context.runner.exec("systemctl", ["--user", "daemon-reload"]); - if (options?.autostart) { - await context.runner.exec("systemctl", ["--user", "enable", SYSTEMD_SERVICE_NAME]); - await context.runner.exec("systemctl", ["--user", "restart", SYSTEMD_SERVICE_NAME]); - } - return { - ok: true, - manager: "systemd-user", - message: `Installed ${SYSTEMD_SERVICE_NAME} at ${unitPath}`, - }; - }, async uninstall() { await context.runner.exec("systemctl", ["--user", "disable", SYSTEMD_SERVICE_NAME]); await context.runner.exec("systemctl", ["--user", "stop", SYSTEMD_SERVICE_NAME]); @@ -188,13 +148,19 @@ function createSystemdUserManager(context: Required): ServiceMan message: `Uninstalled ${SYSTEMD_SERVICE_NAME}`, }; }, - async enable() { - return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "enable", SYSTEMD_SERVICE_NAME], "Enabled service"); - }, async disable() { return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "disable", SYSTEMD_SERVICE_NAME], "Disabled service"); }, async start() { + const installed = existsSync(unitPath); + if (!installed) { + mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true }); + mkdirSync(devspaceLogDir(), { recursive: true }); + writeFileSync(unitPath, buildSystemdUnit({ cliEntrypoint: context.cliEntrypoint, config: context.config }), "utf8"); + await context.runner.exec("systemctl", ["--user", "daemon-reload"]); + await context.runner.exec("systemctl", ["--user", "enable", SYSTEMD_SERVICE_NAME]); + return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "restart", SYSTEMD_SERVICE_NAME], "Installed and started service"); + } return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "start", SYSTEMD_SERVICE_NAME], "Started service"); }, async stop() { @@ -217,7 +183,7 @@ function createSystemdUserManager(context: Required): ServiceMan }, async logs(options) { const logPath = join(devspaceLogDir(), "devspace.out.log"); - return readTail(logPath, options?.tail ?? 200); + return readLog(logPath, options?.tail); }, async doctor() { const status = await this.status(); @@ -250,23 +216,6 @@ function createLaunchdManager(context: Required): ServiceManager async isSupported() { return true; }, - async install() { - mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true }); - mkdirSync(devspaceLogDir(), { recursive: true }); - writeFileSync(plistPath, buildLaunchAgentPlist({ cliEntrypoint: context.cliEntrypoint, config: context.config }), "utf8"); - const bootstrap = await context.runner.exec("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath]); - if (bootstrap.exitCode !== 0 && !bootstrap.stderr.includes("already bootstrapped")) { - return { - ok: false, - manager: "launchd", - message: [ - "LaunchAgent file was written, but launchctl could not start it.", - bootstrap.stderr.trim() || bootstrap.stdout.trim() || "Failed to bootstrap LaunchAgent.", - ].filter(Boolean).join(" "), - }; - } - return { ok: true, manager: "launchd", message: `Installed LaunchAgent at ${plistPath}` }; - }, async uninstall() { await context.runner.exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); if (existsSync(plistPath)) { @@ -274,18 +223,26 @@ function createLaunchdManager(context: Required): ServiceManager } return { ok: true, manager: "launchd", message: "Uninstalled service" }; }, - async enable() { - if (!existsSync(plistPath)) { - return { ok: false, manager: "launchd", message: "LaunchAgent is not installed" }; - } - return execServiceResult(context.runner, "launchd", "launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath], "Enabled service"); - }, async disable() { return execServiceResult(context.runner, "launchd", "launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`], "Disabled service"); }, async start() { if (!existsSync(plistPath)) { - return { ok: false, manager: "launchd", message: "LaunchAgent is not installed" }; + mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true }); + mkdirSync(devspaceLogDir(), { recursive: true }); + writeFileSync(plistPath, buildLaunchAgentPlist({ cliEntrypoint: context.cliEntrypoint, config: context.config }), "utf8"); + const bootstrap = await context.runner.exec("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath]); + if (bootstrap.exitCode !== 0 && !bootstrap.stderr.includes("already bootstrapped")) { + return { + ok: false, + manager: "launchd", + message: [ + "LaunchAgent file was written, but launchctl could not start it.", + bootstrap.stderr.trim() || bootstrap.stdout.trim() || "Failed to bootstrap LaunchAgent.", + ].filter(Boolean).join(" "), + }; + } + return { ok: true, manager: "launchd", message: "Installed and started service" }; } const kickstart = await context.runner.exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); if (kickstart.exitCode === 0) { @@ -321,7 +278,7 @@ function createLaunchdManager(context: Required): ServiceManager }; }, async logs(options) { - return readTail(join(devspaceLogDir(), "devspace.out.log"), options?.tail ?? 200); + return readLog(join(devspaceLogDir(), "devspace.out.log"), options?.tail); }, async doctor() { const status = await this.status(); @@ -358,27 +315,27 @@ function createWindowsTaskManager( const result = await context.runner.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME]); return result.exitCode === 0 || result.exitCode === 1; }, - async install() { - const spec = buildServiceCommand(context.cliEntrypoint); - const taskCommand = `"${spec.command}" ${spec.args.map(windowsQuote).join(" ")}`; - return execServiceResult( - context.runner, - kind, - "schtasks.exe", - ["/Create", "/F", "/SC", "ONLOGON", "/TN", WINDOWS_TASK_NAME, "/TR", taskCommand], - `Installed task ${WINDOWS_TASK_NAME}`, - ); - }, async uninstall() { return execServiceResult(context.runner, kind, "schtasks.exe", ["/Delete", "/F", "/TN", WINDOWS_TASK_NAME], `Deleted task ${WINDOWS_TASK_NAME}`); }, - async enable() { - return { ok: true, manager: kind, message: "Task Scheduler autostart is configured during install" }; - }, async disable() { return execServiceResult(context.runner, kind, "schtasks.exe", ["/Change", "/TN", WINDOWS_TASK_NAME, "/DISABLE"], "Disabled task"); }, async start() { + const installed = (await context.runner.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME])).exitCode === 0; + if (!installed) { + const spec = buildServiceCommand(context.cliEntrypoint); + const taskCommand = `"${spec.command}" ${spec.args.map(windowsQuote).join(" ")}`; + const created = await execServiceResult( + context.runner, + kind, + "schtasks.exe", + ["/Create", "/F", "/SC", "ONLOGON", "/TN", WINDOWS_TASK_NAME, "/TR", taskCommand], + `Installed task ${WINDOWS_TASK_NAME}`, + ); + if (!created.ok) return created; + return execServiceResult(context.runner, kind, "schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME], "Installed and started task"); + } return execServiceResult(context.runner, kind, "schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME], "Started task"); }, async stop() { @@ -402,7 +359,7 @@ function createWindowsTaskManager( }; }, async logs(options) { - return readTail(join(devspaceLogDir(), "devspace.out.log"), options?.tail ?? 200); + return readLog(join(devspaceLogDir(), "devspace.out.log"), options?.tail); }, async doctor() { const status = await this.status(); @@ -448,9 +405,11 @@ async function execServiceResult( : { ok: false, manager, message: result.stderr.trim() || result.stdout.trim() || successMessage }; } -async function readTail(path: string, tail: number): Promise { +async function readLog(path: string, tail?: number): Promise { if (!existsSync(path)) return ""; - const lines = readFileSync(path, "utf8").split(/\r?\n/); + const content = readFileSync(path, "utf8"); + if (tail === undefined) return content; + const lines = content.split(/\r?\n/); return lines.slice(Math.max(0, lines.length - tail)).join("\n"); } @@ -459,5 +418,6 @@ function unsupportedMessage(): string { } function windowsQuote(value: string): string { - return `"${value.replaceAll('"', '\\"')}"`; + if (!/[\s"]/u.test(value)) return value; + return `"${value.replace(/"/g, '""')}"`; } diff --git a/src/service/types.ts b/src/service/types.ts index 27a7de4..d0926eb 100644 --- a/src/service/types.ts +++ b/src/service/types.ts @@ -5,10 +5,6 @@ export type ServiceManagerKind = | "wsl-task-scheduler-fallback" | "unsupported"; -export interface ServiceInstallOptions { - autostart?: boolean; -} - export interface ServiceResult { ok: boolean; manager: ServiceManagerKind; @@ -41,9 +37,7 @@ export interface ServiceManager { readonly serviceName: string; isSupported(): Promise; - install(options?: ServiceInstallOptions): Promise; uninstall(): Promise; - enable(): Promise; disable(): Promise; start(): Promise; stop(): Promise; From 4a40ff4d73e31c82e244c61ec6dfaddafa997db3 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 20:33:48 +0800 Subject: [PATCH 23/41] Rename service uninstall to remove --- README.md | 2 +- docs/configuration.md | 2 +- src/cli.ts | 6 +++--- src/config-operations.test.ts | 2 +- src/service/manager.ts | 8 ++++---- src/service/types.ts | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1f679ca..6d5b912 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ devspace service logs devspace service restart devspace service stop devspace service disable -devspace service uninstall +devspace service remove devspace service doctor ``` diff --git a/docs/configuration.md b/docs/configuration.md index 50e765c..341a560 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,7 +93,7 @@ devspace service status devspace service restart devspace service stop devspace service disable -devspace service uninstall +devspace service remove devspace service logs devspace service doctor ``` diff --git a/src/cli.ts b/src/cli.ts index f15f02c..15be311 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -415,8 +415,8 @@ async function runServiceCommand(args: string[]): Promise { const [subcommand, ...rest] = args; switch (subcommand) { - case "uninstall": - console.log((await manager.uninstall()).message); + case "remove": + console.log((await manager.remove()).message); return; case "disable": console.log((await manager.disable()).message); @@ -570,7 +570,7 @@ function printHelp(): void { " install expects the target path to point at one standard skill directory with a SKILL.md file", " plugin roots, command folders, and agent-rules directories are rejected", " devspace service start", - " devspace service uninstall", + " devspace service remove", " devspace service disable", " devspace service stop", " devspace service restart", diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index 57199de..4a1b3d5 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -27,7 +27,7 @@ const testManager: ServiceManager = { async isSupported() { return false; }, - async uninstall() { + async remove() { return { ok: false, manager: "unsupported", message: "unsupported" }; }, async disable() { diff --git a/src/service/manager.ts b/src/service/manager.ts index ad3d8ac..39ce09f 100644 --- a/src/service/manager.ts +++ b/src/service/manager.ts @@ -96,7 +96,7 @@ function createUnsupportedManager(config: ServerConfig): ServiceManager { async isSupported() { return false; }, - async uninstall() { + async remove() { return { ok: false, manager: "unsupported", message: unsupportedMessage() }; }, async disable() { @@ -135,7 +135,7 @@ function createSystemdUserManager(context: Required): ServiceMan const result = await context.runner.exec("systemctl", ["--user", "--version"]); return result.exitCode === 0; }, - async uninstall() { + async remove() { await context.runner.exec("systemctl", ["--user", "disable", SYSTEMD_SERVICE_NAME]); await context.runner.exec("systemctl", ["--user", "stop", SYSTEMD_SERVICE_NAME]); if (existsSync(unitPath)) { @@ -216,7 +216,7 @@ function createLaunchdManager(context: Required): ServiceManager async isSupported() { return true; }, - async uninstall() { + async remove() { await context.runner.exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); if (existsSync(plistPath)) { rmSync(plistPath, { force: true }); @@ -315,7 +315,7 @@ function createWindowsTaskManager( const result = await context.runner.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME]); return result.exitCode === 0 || result.exitCode === 1; }, - async uninstall() { + async remove() { return execServiceResult(context.runner, kind, "schtasks.exe", ["/Delete", "/F", "/TN", WINDOWS_TASK_NAME], `Deleted task ${WINDOWS_TASK_NAME}`); }, async disable() { diff --git a/src/service/types.ts b/src/service/types.ts index d0926eb..5f210f5 100644 --- a/src/service/types.ts +++ b/src/service/types.ts @@ -37,7 +37,7 @@ export interface ServiceManager { readonly serviceName: string; isSupported(): Promise; - uninstall(): Promise; + remove(): Promise; disable(): Promise; start(): Promise; stop(): Promise; From 9c8134c9779e95e51e62235888be332175ebefc4 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 21:13:06 +0800 Subject: [PATCH 24/41] docs: add Chinese README --- README.md | 4 + README.zh-CN.md | 260 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 README.zh-CN.md diff --git a/README.md b/README.md index 6d5b912..c548a22 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ License

+

+ English | 简体中文 +

+ [![DevSpace connected to ChatGPT](docs/assets/devspace-screenshot.png)](docs/assets/devspace-screenshot.png) **Give ChatGPT a secure connection to your own machine and Turn ChatGPT into Codex** diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..dfce7e9 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,260 @@ +

+ + DevSpace logo + +

+ +

DevSpace

+ +

把类似 Codex 的编程工作流带到 ChatGPT。

+ +

+ npm + CI + License +

+ +

+ English | 简体中文 +

+ +[![DevSpace connected to ChatGPT](docs/assets/devspace-screenshot.png)](docs/assets/devspace-screenshot.png) + +**为 ChatGPT 提供一条安全连接到你自己机器的通道,把 ChatGPT 变成 Codex** + +DevSpace 是一个自托管的 MCP 服务器,让 ChatGPT 可以直接在你真实的本地项目里读取、编辑、搜索和运行代码,也就是使用你的文件、你的工具、你的终端,而无需把内容上传到第三方。它运行在你的机器上,通过你自己控制的隧道暴露出去,并使用只有你知道的密码来批准连接。 + +## 安装 + +DevSpace 需要 Node `>=20.12 <27`,推荐使用 Node 22 LTS。 + +安装 DevSpace CLI: + +```bash +npm install -g @waishnav/devspace +``` + +然后初始化并启动服务: + +```bash +devspace init +devspace serve +``` + +如果你不想全局安装,也可以直接运行: + +```bash +npx @waishnav/devspace init +npx @waishnav/devspace serve +``` + +在安装过程中,DevSpace 会询问你: + +- ChatGPT 被允许通过 DevSpace 打开的本地项目目录 +- 本地端口,通常为 `7676` +- 你的公网 HTTPS 基础地址,可以来自 Cloudflare Tunnel、ngrok、Pinggy、Tailscale Funnel 或其他反向代理 + +在初始化时,请填写不带 `/mcp` 的公网基础地址: + +```text +https://your-tunnel-host.example.com +``` + +完成设置后,再把带 `/mcp` 的公网地址配置到你的 MCP 客户端中。 + +当客户端连接时,DevSpace 会打开一个 Owner 密码确认页面。输入 `devspace init` 打印出来的 Owner 密码即可。这个密码也会保存到: + +```text +~/.devspace/auth.json +``` + +请妥善保管,不要泄露。 + +## 连接你的 MCP 客户端 + +默认的本地端点是: + +```text +http://127.0.0.1:7676/mcp +``` + +大多数用户应该通过公网 HTTPS 隧道连接: + +```text +https://your-tunnel-host.example.com/mcp +``` + +## 配置管理 + +你可以用这些简短命令更新本地服务配置: + +```bash +devspace config show +devspace config port 7676 +devspace config host 127.0.0.1 +devspace config domain devspace.example.com +devspace config key +``` + +配置修改后会立即保存。如果当前有由 DevSpace 管理的后台服务正在运行,DevSpace 会自动重启它,让新设置立刻生效。 + +`devspace config show` 会显示生效中的绑定地址、端口、MCP 路径、公网 URL、工作区列表、服务状态,以及打码后的访问密钥。如果当前 Owner 密码来自 `DEVSPACE_OAUTH_OWNER_TOKEN`,DevSpace 会显示打码后的实际值,而不是把它报告为缺失。 + +`devspace config key` 会轮换现有的 DevSpace Owner 密码、清除已保存的 OAuth 批准和令牌,并强制已连接客户端重新授权。 + +## 工作区管理 + +持久化保存 DevSpace 被允许打开的工作区根目录: + +```bash +devspace workspace add ~/workspace/project-a --default +devspace workspace add ~/workspace/project-b +devspace workspace list +devspace workspace default ~/workspace/project-b +devspace workspace remove ~/workspace/project-a +``` + +你也可以只在当前这次运行中临时允许额外路径: + +```bash +devspace serve --add-dir ~/scratch/project-c --workspace ~/workspace/project-b +``` + +工作区路径就是 DevSpace 与 MCP 文件工具的授权边界。添加某个工作区,只会授权这个路径及其子路径。 + +如果你启动 DevSpace 时既没有已配置工作区,也没有设置 `DEVSPACE_ALLOWED_ROOTS`,DevSpace 现在会默认拒绝访问:服务可以启动,但在你显式添加允许路径之前,工作区访问会被拒绝。 + +## 服务管理 + +DevSpace 的服务管理只负责管理 DevSpace 本身。`devspace service start` 是统一入口:如果后台服务不存在,DevSpace 会按当前平台创建并启动;如果已经存在,则只执行启动。它不会管理任意系统服务。 + +```bash +devspace service start +devspace service status +devspace service logs +devspace service restart +devspace service stop +devspace service disable +devspace service remove +devspace service doctor +``` + +平台行为如下: + +- macOS 使用按用户安装的 LaunchAgent +- Linux 和 Ubuntu 在可用时使用按用户安装的 systemd 服务 +- Windows 使用任务计划程序 +- WSL 优先使用用户级 systemd,否则会提示回退到 Windows 任务计划程序 + +DevSpace 不会自动帮你配置 DNS、反向代理、TLS 证书或防火墙规则。 + +## ChatGPT 能做什么 + +连接建立后,ChatGPT 可以把你已批准的某个项目目录作为工作区打开。之后它就可以检查仓库、做有限范围的修改、运行命令,并向你展示变更内容。 + +DevSpace 为 ChatGPT 提供了这些能力: + +- 读取、写入和编辑已打开工作区内的文件 +- 搜索代码并查看目录结构 +- 运行测试、构建、Git 和包管理脚本相关命令 +- 使用隔离的 Git worktree 并行处理多个编码会话 +- 遵循项目里的 `AGENTS.md` 和 `CLAUDE.md` 指令 +- 从你的技能目录中发现本地 agent skills +- 在兼容 ChatGPT Apps 的宿主中显示工具卡片和可选的变更摘要 + +DevSpace 还内置了一小组工作流与工程技能。这些技能的结构灵感来自 [alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills),后者采用 MIT 许可证发布。 + +项目技能目录按用途拆分为: + +- DevSpace 自带并随项目提交的 system 内置技能 +- `skills/local`:你希望随项目版本控制保存的项目自定义技能 +- `skills/installed`:用户安装的项目技能,默认被 git 忽略 + +网页版 ChatGPT Plus 不能原生安装或注册 Codex Skills。DevSpace 改为在 MCP 这一侧提供技能安装、发现和解析这一层能力。 + +`@devspace /plan` 和 `@devspace /goal` 只是别名风格的工作流约定,不是 ChatGPT 原生斜杠命令。 + +用这些命令管理已安装技能: + +```bash +devspace skills install --repo openai/skills --path skills/.curated/research +devspace skills install --workspace /path/to/project --repo openai/skills --path skills/.curated/research +devspace skills list +devspace skills list --workspace /path/to/project +devspace skills remove research +devspace skills remove --workspace /path/to/project research + +devspace skills install -g --repo openai/skills --path skills/.curated/research +devspace skills list -g +devspace skills remove -g research +``` + +`--repo/--path` 和 `--local-path` 必须直接指向一个标准技能目录,并且其中包含 `SKILL.md`。仓库根目录、插件根目录、命令目录和 agent-rules 目录都会被拒绝。 + +## 心智模型 + +DevSpace 本质上是对选定本地目录的远程访问。 + +你来决定哪些根目录被允许访问。MCP 客户端在已打开工作区内仍然具备很强的本地能力,包括执行 shell 命令。因此,你应该把一个已连接的客户端视为一位受信任的编程协作者,它能够访问你的机器。 + +对于一次普通的 ChatGPT 编程会话: + +1. 启动你的隧道。 +2. 运行 `devspace serve`。 +3. 把 MCP 客户端连接到你的公网 `/mcp` URL。 +4. 使用 Owner 密码批准连接。 +5. 让 ChatGPT 在你的某个允许根目录内打开项目。 + +## 平台支持 + +DevSpace 支持 Linux、macOS、Windows 环境下带 Bash 兼容 shell 的主 CLI,并支持在 macOS、Linux、Windows 和 WSL 上进行原生的按用户服务控制。 + +| 平台 | 状态 | 说明 | +| --- | --- | --- | +| Linux | 支持 | 需要 Node、npm、Git 和 Bash。 | +| macOS | 支持 | 需要 Node、npm、Git 和 Bash。 | +| Windows with Git Bash, WSL, MSYS2, or Cygwin Bash | 支持 | 原生 Windows 环境下最简单的是 Git Bash。 | +| Windows PowerShell or `cmd.exe` only | 暂不支持 | 请安装 Git Bash 或使用 WSL。 | + +你可以运行下面的命令检查本地环境: + +```bash +devspace doctor +``` + +## 文档 + +- [安装指南](docs/setup.md) +- [ChatGPT 编码工作流](docs/chatgpt-coding-workflow.md) +- [配置参考](docs/configuration.md) +- [安全模型](docs/security.md) +- [常见问题与排障](docs/gotchas.md) + +## 理念 + +每一类软件都正在变得可对话。自然语言正在重新定义我们与工具、工作流和系统交互的方式。 + +我的判断是,ChatGPT 会成为一切的操作系统。一旦抵达 AGI,我们大概只需要和 ChatGPT 对话,它就会替我们提示、协调、编排子代理,并搭建合适的执行闭环。 + +但现在还没到那一步。 + +DevSpace 是一次试图把那个未来往前拉近的尝试:让像 ChatGPT、Claude 这样支持 MCP 的宿主,可以通过显式、可检查的工具,直接操作本地项目文件。 + +## Built by Waishnav + +我是 Waishnav,[GitCMS](https://gitcms.dev/) 的创建者。GitCMS 是一个面向 Markdown 网站、基于 Git 的 CMS。 + +我喜欢做带有明确产品判断的工具,而 DevSpace 也是这样的产品之一。我正在尝试建立一家由单人运营、能做到数百万营收的公司。如果你想围观其中的失败、胜利、经验和过程,欢迎在 [X](https://x.com/wshxnv) 上关注我。 + +## 本地开发 + +如果你要开发 DevSpace 自身,可以使用: + +```bash +npm install --include=dev +npm run dev +npm run typecheck +npm test +npm run build +npm run start +``` From 6ff8941263f547cbe04d795d05ce2aafeb8d5a20 Mon Sep 17 00:00:00 2001 From: haloworker Date: Sun, 21 Jun 2026 21:56:30 +0000 Subject: [PATCH 25/41] feat(workflow): add exact goal metrics --- README.md | 24 +- docs/chatgpt-coding-workflow.md | 39 +- docs/openai-skills-vendor.md | 34 + package-lock.json | 2 +- package.json | 4 +- scripts/vendor-openai-skills.mjs | 137 ++ skills/.system/create-plan/SKILL.md | 64 +- skills/.system/define-goal/SKILL.md | 62 +- skills/.system/devspace-goal/SKILL.md | 60 + .../references/goal-conflicts.md | 18 + .../devspace-goal/references/goal-state.md | 47 + skills/.system/devspace-plan/SKILL.md | 70 + .../references/plan-conflicts.md | 17 + .../devspace-plan/references/plan-output.md | 11 + .../devspace-plan/references/plan-state.md | 26 + skills/.system/devspace-workflow/SKILL.md | 78 +- .../references/command-routing.md | 11 + .../devspace-workflow/references/commands.md | 84 +- .../devspace-workflow/references/examples.md | 52 +- .../devspace-workflow/references/style.md | 28 +- .../references/workflow-recovery.md | 10 + skills/.system/senior-architect-lite/SKILL.md | 56 +- .../references/decision-guide.md | 27 +- .../senior-architect-lite/references/style.md | 16 +- skills/.system/senior-architect/SKILL.md | 34 + skills/.system/skill-authoring-lite/SKILL.md | 46 +- .../references/structure-checklist.md | 20 +- skills/.system/skill-authoring/SKILL.md | 37 + src/cli-skills.test.ts | 6 +- src/db/schema.ts | 155 +- src/package-smoke.test.ts | 25 + src/prompting.test.ts | 12 +- src/prompting.ts | 12 +- src/server.ts | 1066 ++++++--- src/skill-manager.test.ts | 6 +- src/skill-manager.ts | 7 +- src/skills.test.ts | 242 +-- src/skills.ts | 160 +- src/workflow-migration.test.ts | 78 + src/workflow-store.test.ts | 206 ++ src/workspace-store.ts | 1904 +++++++++++++++-- src/workspaces.test.ts | 33 +- 42 files changed, 4003 insertions(+), 1023 deletions(-) create mode 100644 docs/openai-skills-vendor.md create mode 100644 scripts/vendor-openai-skills.mjs create mode 100644 skills/.system/devspace-goal/SKILL.md create mode 100644 skills/.system/devspace-goal/references/goal-conflicts.md create mode 100644 skills/.system/devspace-goal/references/goal-state.md create mode 100644 skills/.system/devspace-plan/SKILL.md create mode 100644 skills/.system/devspace-plan/references/plan-conflicts.md create mode 100644 skills/.system/devspace-plan/references/plan-output.md create mode 100644 skills/.system/devspace-plan/references/plan-state.md create mode 100644 skills/.system/devspace-workflow/references/command-routing.md create mode 100644 skills/.system/devspace-workflow/references/workflow-recovery.md create mode 100644 skills/.system/senior-architect/SKILL.md create mode 100644 skills/.system/skill-authoring/SKILL.md create mode 100644 src/package-smoke.test.ts create mode 100644 src/workflow-migration.test.ts create mode 100644 src/workflow-store.test.ts diff --git a/README.md b/README.md index c548a22..98e28f6 100644 --- a/README.md +++ b/README.md @@ -179,18 +179,18 @@ DevSpace gives ChatGPT tools to: - discover local agent skills from your skill folders - show tool cards and optional change summaries in ChatGPT Apps-compatible hosts -DevSpace also bundles a small set of built-in workflow and engineering skills. -Their structure is inspired by [alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills), which is released under the MIT license. +DevSpace bundles durable workflow Skills rather than short prompt examples. Core Skills cover project Plan recovery, Goal definition and status, workflow resumption, architecture review, and Skill authoring. -Project skill directories are split by purpose: +Project Skill directories are split by purpose: -- system built-in DevSpace skills, committed with DevSpace -- `skills/local`: project-defined skills you want to keep in version control -- `skills/installed`: user-installed project skills, ignored by git by default +- `skills/.system`: DevSpace core workflow Skills committed with DevSpace +- `skills/.system/openai/skills` (when vendored): reviewed OpenAI Skills, synchronized manually and never updated at runtime +- `skills/local`: project-defined Skills you want to keep in version control +- `skills/installed`: user-installed project Skills, ignored by git by default -ChatGPT Plus on the web cannot natively install or register Codex Skills. DevSpace provides the MCP-side skill installation, discovery, and resolution layer instead. +ChatGPT Plus on the web cannot natively install or register Codex Skills. DevSpace provides MCP-side discovery, resolution, and controlled `skill://` resource access instead. -`@devspace /plan` and `@devspace /goal` are alias-style workflow conventions. They are not native ChatGPT slash commands. +`@devspace /plan` and `@devspace /goal` are stable alias-style workflow conventions. `/plan` always resolves to `devspace-plan`; `/goal` always resolves to `devspace-goal`; local or vendored Skills cannot silently override them. See [vendored OpenAI Skills](docs/openai-skills-vendor.md) for the manual review and synchronization policy. Manage installed skills with: @@ -209,6 +209,14 @@ devspace skills remove -g research `--repo/--path` and `--local-path` must point directly at one standard skill directory that contains `SKILL.md`. Repository roots, plugin roots, command folders, and agent-rules directories are rejected. +## Project Workflow Store + +DevSpace stores compact project-scoped workflow state: the current Plan, Goal, Plan Mode, structured step state, and at most 100 concise workflow events. It does not store chat transcripts, raw tool output, shell logs, or file snapshots. Goal metrics are limited to exact provider-reported token records, an explicit server work timer, and progress derived from a Plan explicitly linked to that Goal. + +The same canonical project directory shares Plan and Goal state across ChatGPT sessions and DevSpace restarts. Different projects and different Git worktree roots remain isolated. `open_workspace` returns a small `workflowDigest`; call `get_plan`, `get_goal`, and paginated `get_workflow_history` only when full state is needed. + +Plan and Goal writes use optimistic concurrency. Read the current state first, then send `expectedRevision`; stale sessions receive a revision conflict instead of silently overwriting newer work. + ## Mental Model DevSpace is remote access to selected local folders. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index e35c88c..058013d 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -14,8 +14,7 @@ ChatGPT should call `open_workspace` once for a project folder: } ``` -The result includes a `workspaceId`. All later file, search, edit, show-changes, -and shell calls should reuse that same `workspaceId`. +The result includes a `workspaceId` and a compact `workflowDigest`. All later file, search, edit, show-changes, shell, Skill, Plan, and Goal calls should reuse that same `workspaceId`. Do not reopen the same folder unless: @@ -79,17 +78,18 @@ new context during later tool calls. Skills are enabled by default for coding-agent workflows. -DevSpace discovers skills from: +DevSpace discovers Skills from: -- built-in DevSpace skills -- workspace-local skills in `skills/local` -- workspace-installed skills in `skills/installed` +- DevSpace core workflow Skills in `skills/.system` +- vendored OpenAI Skills in `skills/.system/openai/skills` when present +- workspace-local Skills in `skills/local` +- workspace-installed Skills in `skills/installed` - `DEVSPACE_AGENT_DIR`, which defaults to `~/.codex` - optional paths from `DEVSPACE_SKILL_PATHS` ChatGPT Plus on the web cannot natively install or register Codex Skills. In this setup, DevSpace provides MCP-based skill installation, discovery, and resolution. -`@devspace /plan` and `@devspace /goal` are workflow aliases, not native ChatGPT slash commands. +`@devspace /plan` and `@devspace /goal` are workflow aliases, not native ChatGPT slash commands. `/plan` always resolves to `devspace-plan`; `/goal` always resolves to `devspace-goal`. Vendored and project-local Skills cannot silently override either alias. User-installed project skills can be managed through DevSpace itself: @@ -117,17 +117,30 @@ User-installed project skills can be managed through DevSpace itself: @devspace /goal 将 DevSpace 的第三方 Skill 安装流程收敛为可测试、可回滚、跨平台兼容的实现 ``` -When `open_workspace` returns matching skills, the model should read the -advertised `SKILL.md` before following that skill. +`open_workspace` returns core and project Skill metadata only, capped at 24 entries, plus a source-count summary. Use `resolve_skill` to load the full `SKILL.md` once a Skill is selected. Use `search_skills` to discover vendored or additional Skills without loading every Skill instruction into context. -Skill paths may be outside the workspace. DevSpace only permits reading: +Skill resources use `skill://` locators. DevSpace only permits reading: -- advertised `SKILL.md` files -- files under a skill directory after that skill's `SKILL.md` has been read +- a resolved `SKILL.md` +- files under an activated Skill directory Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. -The built-in skill layout is inspired by [alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills) under the MIT license, with DevSpace-specific adaptations for MCP workflow commands and local coding tasks. +DevSpace core Skills define the stable `/plan`, `/goal`, workflow recovery, and MCP Tool contracts. Vendored OpenAI Skills are an optional, manually reviewed source and never control the core aliases. + +## Project Workflow Store + +DevSpace keeps only a small project-scoped workflow state. It is shared by every DevSpace session opened on the same canonical directory, while different project roots and different Git worktree roots stay isolated. + +`open_workspace` returns only `workflowDigest`, not Plan history, Goal history, chat transcripts, tool output, or shell logs. Load full state on demand: + +- `get_plan`: current Plan, step states, validation, risks, and revision +- `get_goal`: current Goal, criteria, verification, stop conditions, summary, and revision +- `get_workflow_history`: concise paginated status events; default 20, maximum 50 + +Create a Plan with `update_plan(expectedRevision=0, ...)`. For an existing Plan or Goal, first call `get_plan` or `get_goal`, then pass the returned `expectedRevision`. A conflict means another session updated state first; reload and merge rather than overwriting it. + +`plan` mode is a planning preference, not a permission boundary. It permits `update_plan` but should not perform project file changes until the user approves execution. ## Tool Names diff --git a/docs/openai-skills-vendor.md b/docs/openai-skills-vendor.md new file mode 100644 index 0000000..ec81cb1 --- /dev/null +++ b/docs/openai-skills-vendor.md @@ -0,0 +1,34 @@ +# Vendored OpenAI Skills + +DevSpace can include a reviewed local copy of the upstream `openai/skills` `skills/` directory at `skills/.system/openai/skills/`. + +DevSpace never fetches this source while serving MCP requests. The core aliases stay independent: + +```text +/plan -> devspace-plan +/goal -> devspace-goal +``` + +Vendored Skills are optional discovery material. They cannot silently replace either core alias. + +## Manual synchronization + +Clone or update `https://github.com/openai/skills.git` outside the DevSpace runtime, review the target commit and every changed Skill, then run the maintainer-only helper: + +```bash +npm run vendor:openai-skills -- --source /absolute/path/to/openai-skills --check +npm run vendor:openai-skills -- --source /absolute/path/to/openai-skills --apply +``` + +The helper verifies that the reviewed clone has the official `openai/skills` origin, stages a local copy, swaps the vendor tree only after staging succeeds, and writes `skills/.system/openai/UPSTREAM.md` with the full commit SHA and sync date. + +Before committing a vendor update, inspect the diff, preserve every upstream `LICENSE.txt`, and run: + +```bash +npm run typecheck +npm run test +npm run build +npm pack --dry-run +``` + +Do not run the helper from DevSpace startup, an MCP Tool, a package-install hook, or a scheduled task. Upstream changes become active only after a maintainer reviews and commits them. diff --git a/package-lock.json b/package-lock.json index 0b240d1..8b34c05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -572,7 +572,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index 46ce1fc..c8c5d13 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dist", "docs", "scripts", + "skills", "README.md" ], "publishConfig": { @@ -25,7 +26,8 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/config-operations.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/skill-manager.test.ts && node --import tsx src/cli-skills.test.ts && node --import tsx src/goal-definition.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspace-operations.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts && node --import tsx src/service.test.ts", + "vendor:openai-skills": "node scripts/vendor-openai-skills.mjs", + "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/config-operations.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/skill-manager.test.ts && node --import tsx src/cli-skills.test.ts && node --import tsx src/goal-definition.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspace-operations.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/workflow-store.test.ts && node --import tsx src/workflow-migration.test.ts && node --import tsx src/package-smoke.test.ts && node --import tsx src/review-checkpoints.test.ts && node --import tsx src/service.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/scripts/vendor-openai-skills.mjs b/scripts/vendor-openai-skills.mjs new file mode 100644 index 0000000..494d426 --- /dev/null +++ b/scripts/vendor-openai-skills.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Maintainer-only helper for a reviewed local clone of openai/skills. + * DevSpace never invokes this at runtime. + * + * Usage: + * node scripts/vendor-openai-skills.mjs --source /path/to/openai-skills --check + * node scripts/vendor-openai-skills.mjs --source /path/to/openai-skills --apply + */ + +import { execFileSync } from "node:child_process"; +import { cp, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const vendorRoot = resolve(repositoryRoot, "skills", ".system", "openai"); +const expectedRemote = "https://github.com/openai/skills"; +const { source, apply } = parseArguments(process.argv.slice(2)); + +if (!source) { + fail("Usage: node scripts/vendor-openai-skills.mjs --source /path/to/openai-skills --check|--apply"); +} + +const sourceRoot = resolve(source); +const sourceSkills = resolve(sourceRoot, "skills"); +const sourceStat = await stat(sourceSkills).catch(() => undefined); +if (!sourceStat?.isDirectory()) { + fail(`Missing upstream skills directory: ${sourceSkills}`); +} + +const remote = git(sourceRoot, ["remote", "get-url", "origin"]); +if (!remote || normalizeRemote(remote) !== expectedRemote) { + fail(`Reviewed source must have origin ${expectedRemote}; got ${remote ?? ""}.`); +} +const commit = git(sourceRoot, ["rev-parse", "HEAD"]); +if (!commit || !/^[0-9a-f]{40}$/.test(commit)) { + fail("Could not resolve a full upstream commit SHA."); +} + +const fileCount = Number(git(sourceRoot, ["ls-files", "skills"])?.split("\n").filter(Boolean).length ?? 0); +if (!fileCount) fail("No tracked files found below upstream skills/."); + +const summary = [ + `Source: ${expectedRemote}`, + `Commit: ${commit}`, + `Tracked upstream skill files: ${fileCount}`, + `Destination: ${resolve(vendorRoot, "skills")}`, +].join("\n"); + +if (!apply) { + process.stdout.write(`${summary}\n\nCheck only. Review the upstream diff and licenses, then rerun with --apply.\n`); + process.exit(0); +} + +const stagingRoot = resolve(tmpdir(), `devspace-openai-skills-${process.pid}-${Date.now()}`); +const stagingSkills = resolve(stagingRoot, "skills"); +const destinationSkills = resolve(vendorRoot, "skills"); +const backupSkills = resolve(vendorRoot, `.skills-backup-${Date.now()}`); + +try { + await mkdir(stagingRoot, { recursive: true }); + await cp(sourceSkills, stagingSkills, { recursive: true, dereference: false, force: true }); + await mkdir(vendorRoot, { recursive: true }); + + if (existsSync(destinationSkills)) { + await rename(destinationSkills, backupSkills); + } + + try { + await rename(stagingSkills, destinationSkills); + await writeFile(resolve(vendorRoot, "UPSTREAM.md"), upstreamManifest(commit), "utf8"); + await rm(backupSkills, { recursive: true, force: true }); + } catch (error) { + await rm(destinationSkills, { recursive: true, force: true }); + if (existsSync(backupSkills)) await rename(backupSkills, destinationSkills); + throw error; + } + + process.stdout.write(`${summary}\n\nVendor copy updated. Review git diff and run npm run typecheck, npm run test, npm run build, and npm pack --dry-run.\n`); +} finally { + await rm(stagingRoot, { recursive: true, force: true }); +} + +function parseArguments(argumentsList) { + let sourcePath; + let apply = false; + for (let index = 0; index < argumentsList.length; index++) { + const value = argumentsList[index]; + if (value === "--source") sourcePath = argumentsList[++index]; + else if (value === "--apply") apply = true; + else if (value === "--check") apply = false; + else if (value === "--help" || value === "-h") { + process.stdout.write("Usage: node scripts/vendor-openai-skills.mjs --source /path/to/openai-skills --check|--apply\n"); + process.exit(0); + } else fail(`Unknown argument: ${value}`); + } + return { source: sourcePath, apply }; +} + +function git(cwd, argumentsList) { + try { + return execFileSync("git", argumentsList, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } catch { + return undefined; + } +} + +function normalizeRemote(value) { + return value + .replace(/^git@github\.com:/, "https://github.com/") + .replace(/\.git$/, ""); +} + +function upstreamManifest(commit) { + return [ + `Source repository: ${expectedRemote}.git`, + `Pinned commit: ${commit}`, + "Source branch: main", + `Last synced: ${new Date().toISOString().slice(0, 10)}`, + "", + "Copied paths:", + "- skills/.system", + "- skills/.curated", + "", + "Local modifications:", + "- None. DevSpace-specific behavior belongs in sibling devspace-* Skills.", + "", + ].join("\n"); +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} diff --git a/skills/.system/create-plan/SKILL.md b/skills/.system/create-plan/SKILL.md index 3bcb5e3..df0bd07 100644 --- a/skills/.system/create-plan/SKILL.md +++ b/skills/.system/create-plan/SKILL.md @@ -1,54 +1,64 @@ --- name: create-plan -description: Create a concise, execution-ready implementation plan for a coding task. Use when the user asks for /plan or wants a read-only planning pass before making code changes. +description: Legacy compatibility guidance for older DevSpace planning prompts. DevSpace now uses devspace-plan for the durable project-scoped /plan workflow. license: MIT metadata: - version: 1.0.0 + version: 2.0.0 author: DevSpace - category: workflow + category: legacy-workflow updated: 2026-06-21 --- -# Create Plan +# Legacy Create Plan Compatibility -## Purpose +This Skill is retained for projects or older prompts that still name `create-plan`. New DevSpace workflow resolution does not route `/plan` here; it routes to `devspace-plan`. -Use this skill to turn a coding request into a concrete implementation plan without editing files or claiming the work is already done. +## Required Migration Behavior -## Workflow +When this legacy Skill is intentionally selected, follow the same durable planning contract as `devspace-plan`: -1. Read the most relevant code, tests, docs, and entrypoints first. -2. Stay read-only for the full planning pass. -3. Ask at most one or two questions, and only when a real blocker remains after inspection. -4. Make reasonable assumptions when the missing detail does not materially change the implementation. -5. Produce one practical plan with ordered, atomic actions. +1. Open or reuse the current workspace and inspect its `workflowDigest`. +2. Call `get_plan` before replacing or editing any existing Plan. +3. Stay read-only while collecting repository evidence: read project instructions, public interfaces, tests, configuration, and relevant source files. +4. Ask questions only when an unresolved decision materially changes scope, compatibility, safety, or implementation order. +5. Present a finite Plan with explicit scope, ordered steps, validation, and risks. +6. Persist it with `update_plan`. Use `expectedRevision=0` only when no Plan exists; otherwise use the revision from `get_plan`. +7. On a revision conflict, reload the Plan and merge deliberately instead of retrying stale data. -## Output Requirements +## State Expectations -- Keep the plan finite and implementation-oriented. -- Default to 6-10 action items. -- Include validation or test steps. -- Include risks, boundaries, or rollback notes when they matter. -- Do not output large code blocks. -- Do not write files, run mutations, or say the work is complete. +A Plan is project-scoped shared state. It survives a new ChatGPT session for the same canonical directory. It is not a chat log, a token budget, or a progress dashboard. -## Recommended Shape +A complete Plan uses these states: + +- Plan: `draft`, `active`, `completed`, `archived` +- Steps: `pending`, `in_progress`, `blocked`, `completed`, `skipped` + +Only one step may be `in_progress`. A blocked or skipped step must preserve the reason in its note. + +## Output Contract ```markdown # Plan -Short summary of the goal and the path. +## Goal + + +## Existing state + ## Scope -- In: -- Out: +- In: ... +- Out: ... ## Action items -- [ ] ... +- [ ] ## Validation -- ... +- -## Risks -- ... +## Risks / rollback +- ``` + +For the current DevSpace workflow contract, resolve `devspace-plan` and read its references. \ No newline at end of file diff --git a/skills/.system/define-goal/SKILL.md b/skills/.system/define-goal/SKILL.md index 155664a..3945c31 100644 --- a/skills/.system/define-goal/SKILL.md +++ b/skills/.system/define-goal/SKILL.md @@ -1,53 +1,45 @@ --- name: define-goal -description: Rewrite a vague request into a concrete, verifiable goal with scope and acceptance criteria. Use when the user asks for /goal or needs a measurable target before execution. +description: Legacy compatibility guidance for older DevSpace goal-definition prompts. DevSpace now uses devspace-goal for the durable project-scoped /goal workflow. license: MIT metadata: - version: 1.0.0 + version: 2.0.0 author: DevSpace - category: workflow + category: legacy-workflow updated: 2026-06-21 --- -# Define Goal +# Legacy Define Goal Compatibility -## Purpose +This Skill is retained for compatibility with earlier prompts that request `define-goal`. New DevSpace alias resolution maps `/goal` to `devspace-goal`. -Use this skill to convert an ambiguous request into a specific goal that can be verified in the current workspace. +## Required Goal Lifecycle -## Workflow +1. Inspect the current project `workflowDigest` and call `get_goal` before creating or changing a Goal. +2. Create a Goal only when the user explicitly needs a persistent, cross-session outcome. Routine coding requests do not need one. +3. When no active Goal exists, use `create_goal` with an objective, scope, acceptance criteria, verification, stop conditions, and concise current summary. +4. When a Goal already exists and matches the request, preserve it and update only the parts that changed. +5. When a Goal conflicts with the new request, ask the user whether to archive it, mark it completed, mark it blocked, or keep it unchanged. Never silently replace an active Goal. +6. Use `update_goal(expectedRevision=...)` for every change to an existing Goal. Reload with `get_goal` after a revision conflict. -1. Identify what should be true when the work is done. -2. Limit the scope to the systems, modules, or behaviors that actually matter. -3. Define how success will be verified with concrete evidence, thresholds, or commands when possible. -4. State what is explicitly out of scope. -5. If a critical scope or verification detail is missing, ask one short question. Otherwise, make a reasonable assumption and continue. +## Goal Standard -## Output Requirements +A durable Goal must be verifiable rather than aspirational: -- Keep the goal measurable and bounded. -- Prefer real verification evidence over vague quality language. -- Do not invent long-running lifecycle mechanics, dashboards, or progress logs. -- Do not simulate native Codex goal commands. +- `objective`: one user-visible outcome. +- `scope.in` / `scope.out`: what is included and excluded. +- `successCriteria`: conditions that prove success. +- `verification`: tests, review checks, or manual validation. +- `stopConditions`: reasons to pause, escalate, or abandon the approach. +- `currentSummary`: compact completed/current/blocked state for the next session. -## Recommended Shape +Use statuses deliberately: -```markdown -# Goal +- `active`: work can continue. +- `blocked`: a specific decision, dependency, or permission is missing. +- `completed`: success criteria were satisfied and verified. +- `archived`: no longer current, while history remains available. -## Objective -... +Do not invent token counts, clock time, activity seconds, or percentage progress. Store evidence and blockers instead. -## Scope -- In: -- Out: - -## Success criteria -- ... - -## Verification -- ... - -## Stop / escalation conditions -- ... -``` +For the active DevSpace contract, resolve `devspace-goal` and read its references. \ No newline at end of file diff --git a/skills/.system/devspace-goal/SKILL.md b/skills/.system/devspace-goal/SKILL.md new file mode 100644 index 0000000..741d29b --- /dev/null +++ b/skills/.system/devspace-goal/SKILL.md @@ -0,0 +1,60 @@ +--- +name: devspace-goal +description: Define and maintain a durable, verifiable project Goal in DevSpace. Use for /goal when the user explicitly wants a goal to persist across sessions. +license: MIT +metadata: + version: 2.0.0 + author: DevSpace + category: workflow + updated: 2026-06-21 +--- + +# DevSpace Goal Workflow + +Use this Skill when the user asks for `/goal`, asks to preserve a project outcome across sessions, or needs explicit success and stop conditions. Do not create a Goal for every routine coding request. + +## Required Tool Lifecycle + +1. Call `get_goal` before creating or changing a Goal. +2. If no current Goal exists, clarify only material ambiguity and call `create_goal`. +3. If the current Goal matches, continue it and use `update_goal` only when the definition, summary, verification, or status changes. +4. If the current Goal conflicts with the new request, show the conflict and ask the user to choose one action: + - replace the old Goal by archiving it, + - mark the old Goal `completed`, + - mark the old Goal `blocked`, or + - keep the old Goal unchanged. +5. Before updating, use the Goal revision from `get_goal` as `expectedRevision`. +6. On a revision conflict, reload with `get_goal` and merge deliberately. Never overwrite a different session's Goal state blindly. +7. When execution starts, call `start_goal_work`; pause it with `pause_goal_work` before waiting for approval, changing tasks, or ending work. This is the only source of Goal work-duration data. +8. Record tokens only with `record_goal_token_usage` when an upstream provider/API has returned exact counts and a stable request ID. Never infer tokens from text length, elapsed time, or model name. +9. For exact percentage progress, link the current Plan to the Goal through `update_plan(goalId=...)`. Goal progress is then derived from completed Plan steps, not guessed. + +Read [references/goal-state.md](references/goal-state.md) for field definitions and [references/goal-conflicts.md](references/goal-conflicts.md) for conflict handling. + +## Goal Quality Standard + +A Goal must describe a user-visible outcome that can be verified. It is not a task dump and it is not a time or token budget. + +Include: + +- `objective`: a one-sentence intended outcome. +- `scope.in` and `scope.out`: boundaries. +- `successCriteria`: what must be true when done. +- `verification`: commands, tests, review steps, or manual checks. +- `stopConditions`: conditions that justify stopping or escalating. +- `currentSummary`: compact state of completed work, current work, and blockers. + +## Status Rules + +- `active`: the current target is being pursued. +- `blocked`: progress needs an external decision, dependency, or access change. +- `completed`: the defined criteria were met and verified. +- `archived`: intentionally removed from current workflow state; history remains available. + +The Goal has three measurable fields only under explicit evidence rules: + +- Provider tokens: append-only counts returned by a provider/API response, deduplicated by provider request ID. +- Work duration: server wall-clock milliseconds while `start_goal_work` is running; it is paused explicitly and automatically when a Goal leaves `active`. +- Percentage progress: exact completed-step ratio from the current Plan only when that Plan is explicitly linked to this Goal. The stored numerator/denominator is canonical; display percentages are rounded for humans. + +Never invent or backfill any of these values from chat text, elapsed conversation time, or intuition. diff --git a/skills/.system/devspace-goal/references/goal-conflicts.md b/skills/.system/devspace-goal/references/goal-conflicts.md new file mode 100644 index 0000000..5cc4514 --- /dev/null +++ b/skills/.system/devspace-goal/references/goal-conflicts.md @@ -0,0 +1,18 @@ +# Goal Conflict Procedure + +A conflicting Goal is one whose objective, scope, or success criteria would cause the project to pursue a different outcome than the active Goal. + +## Required user choice + +When a conflict exists, present the current Goal and the requested Goal in one compact comparison, then ask the user to choose: + +1. Archive the current Goal and create the new one. +2. Mark the current Goal completed after verifying its criteria. +3. Mark the current Goal blocked and explain the blocker. +4. Keep the current Goal and treat the new request as ordinary work. + +Do not silently replace an active Goal. Do not create competing active Goals. + +## Revision conflict + +A revision conflict means another session changed the Goal after it was read. Reload the Goal, preserve valid updates, and write again with the refreshed revision. diff --git a/skills/.system/devspace-goal/references/goal-state.md b/skills/.system/devspace-goal/references/goal-state.md new file mode 100644 index 0000000..d7ad23f --- /dev/null +++ b/skills/.system/devspace-goal/references/goal-state.md @@ -0,0 +1,47 @@ +# Goal State Contract + +A Goal is project-scoped shared workflow state. It is not a chat transcript. It can expose measured token, work-duration, and Plan-progress fields only when their exact evidence rules are satisfied. + +## Fields + +- `objective`: required concrete outcome. +- `scope.in` / `scope.out`: boundaries. +- `successCriteria`: observable outcome checks. +- `verification`: tests, builds, inspection, or manual checks. +- `stopConditions`: conditions for pause, escalation, or intentional stop. +- `currentSummary`: concise completed / current / blocked information. +- `status`: `active`, `blocked`, `completed`, `archived`. +- `revision`: optimistic concurrency version. +- `metrics.tokenUsage`: append-only provider-reported usage totals, deduplicated by provider request ID. +- `metrics.workDuration`: server-measured milliseconds accumulated only while the explicit Goal timer is running. +- `metrics.progress`: completed Plan step ratio only when the current Plan is explicitly linked to this Goal. + +## Exact Metric Rules + +Use `record_goal_token_usage` only with fields from an actual API or provider usage response. Do not derive token values from message text, bytes, model context limits, or elapsed time. + +`start_goal_work` stores a server timestamp and `pause_goal_work` persists elapsed wall-clock milliseconds. A transition from `active` to `blocked`, `completed`, or `archived` pauses a running timer automatically. This measures an explicit timer interval, not unobservable human or model thinking time. + +Link a Plan through `update_plan` by sending the Goal ID as `goalId`. The canonical percentage progress fields are `completedSteps/totalSteps` and `percentageNumerator/percentageDenominator`; `displayPercent` is a rounded human presentation. + +## Lifecycle + +- `create_goal` creates an `active` Goal only when no active Goal exists. +- `update_goal` changes the current Goal and requires `expectedRevision`. +- `archived` removes a Goal from current hot state and retains its events in history. +- A later `create_goal` can start a new active Goal after a previous Goal has become blocked, completed, or archived. + +## Current summary pattern + +```text +Completed: +- ... + +Current: +- ... + +Blocked: +- ... +``` + +Keep this summary short enough to be useful in `workflowDigest`; do not paste tool output or chat history into it. diff --git a/skills/.system/devspace-plan/SKILL.md b/skills/.system/devspace-plan/SKILL.md new file mode 100644 index 0000000..a1d0e46 --- /dev/null +++ b/skills/.system/devspace-plan/SKILL.md @@ -0,0 +1,70 @@ +--- +name: devspace-plan +description: Create, resume, and maintain a durable DevSpace implementation Plan for the current project. Use for /plan and for requests that need a read-only analysis before code changes. +license: MIT +metadata: + version: 2.0.0 + author: DevSpace + category: workflow + updated: 2026-06-21 +--- + +# DevSpace Plan Workflow + +Use this Skill when the user explicitly asks for `/plan`, asks to plan before implementation, or needs a durable execution checklist shared across future DevSpace sessions. + +## Required Tool Lifecycle + +1. Call `get_plan` first. Reuse the existing Plan when it matches the task instead of silently replacing it. +2. Call `set_collaboration_mode` with `mode="plan"` when entering an intentional planning pass. +3. Read relevant `AGENTS.md`, docs, source files, tests, and configuration. Planning is read-only: do not edit files, run destructive commands, or claim implementation is complete. +4. Ask with `request_user_input` only when a real decision changes scope, architecture, compatibility, safety, or rollout. Do not ask questions whose answer can be established by inspection. +5. Produce one finite implementation Plan with clear boundaries, ordered actions, validation, and risks. +6. Persist the Plan with `update_plan`: + - Use `expectedRevision=0` when no current Plan exists. + - Otherwise use the revision returned by `get_plan`. + - If DevSpace reports a revision conflict, call `get_plan` again and reconcile; never overwrite blindly. +7. Keep Plan Mode enabled until the user explicitly starts implementation or switches back to default mode. + +Read [references/plan-state.md](references/plan-state.md) for Plan fields and transitions. Read [references/plan-conflicts.md](references/plan-conflicts.md) before resolving concurrent updates. + +## Planning Standard + +A Plan must be specific enough that another session can continue it without reconstructing the project context. Every step must be an observable action, not a vague goal. + +Use statuses deliberately: + +- `pending`: not started. +- `in_progress`: the one active step, if work has started. +- `blocked`: cannot proceed; add a concise `note` with the blocker and the decision needed. +- `completed`: verified done. +- `skipped`: intentionally not doing this step; add a reason in `note`. + +Do not use fake percentages, token budgets, elapsed-time estimates, or dashboard-style reporting. + +## Required Output Shape + +```markdown +# Plan + +## Goal + + +## Existing state + + +## Scope +- In: ... +- Out: ... + +## Action items +- [ ] + +## Validation +- + +## Risks / rollback +- +``` + +The human-readable response must match the persisted Plan. Keep it concise but do not omit validation or risks when they matter. diff --git a/skills/.system/devspace-plan/references/plan-conflicts.md b/skills/.system/devspace-plan/references/plan-conflicts.md new file mode 100644 index 0000000..b943bd2 --- /dev/null +++ b/skills/.system/devspace-plan/references/plan-conflicts.md @@ -0,0 +1,17 @@ +# Plan Revision Conflicts + +Multiple ChatGPT sessions can open the same project. DevSpace prevents silent loss by versioning the current Plan. + +## Conflict procedure + +1. A Plan update returns a revision conflict. +2. Stop; do not retry the old payload. +3. Call `get_plan` and inspect the current revision, changed steps, notes, and summary. +4. Merge only compatible changes. +5. Call `update_plan` with the refreshed `expectedRevision`. + +## Do not + +- Do not assume your in-memory Plan is current. +- Do not overwrite a blocker, validation result, or completed step without checking why it changed. +- Do not turn a revision conflict into a user-facing implementation failure when it can be resolved by reloading state. diff --git a/skills/.system/devspace-plan/references/plan-output.md b/skills/.system/devspace-plan/references/plan-output.md new file mode 100644 index 0000000..dbb62bd --- /dev/null +++ b/skills/.system/devspace-plan/references/plan-output.md @@ -0,0 +1,11 @@ +# Plan Writing Checklist + +Before persisting a Plan, verify all of the following: + +- The title names the actual user outcome, not a generic activity. +- Existing-state claims are backed by files, tests, or configuration that were inspected. +- Scope includes explicit exclusions where likely misunderstandings exist. +- Each action item names a module, interface, behavior, or test target. +- Validation is executable or observable. +- Risks identify a real failure mode, migration concern, security concern, or rollback path. +- The response does not promise edits while Plan Mode is active. diff --git a/skills/.system/devspace-plan/references/plan-state.md b/skills/.system/devspace-plan/references/plan-state.md new file mode 100644 index 0000000..29b249f --- /dev/null +++ b/skills/.system/devspace-plan/references/plan-state.md @@ -0,0 +1,26 @@ +# Plan State Contract + +A DevSpace Plan is project-scoped state. It survives a new `open_workspace` call for the same canonical project directory and is isolated from other projects and Git worktrees. + +## Plan fields + +- `title`: concise name for the work. +- `summary`: current implementation context and decision record. +- `scope.in` / `scope.out`: explicit boundaries. +- `steps`: ordered executable work. +- `validation`: evidence required before completion. +- `risks`: operational, compatibility, or rollback concerns. +- `status`: `draft`, `active`, `completed`, or `archived`. +- `revision`: optimistic-concurrency version. + +## Update rules + +- Call `get_plan` before modifying a Plan. +- Pass `expectedRevision=0` only to create a Plan when none exists. +- Pass the current `revision` for every update to an existing Plan. +- `archived` removes the Plan from the current hot state but retains its event history. +- `completed` remains readable as the current Plan until a new Plan is explicitly created after archiving it. + +## Step rules + +Keep the step list complete on every `update_plan` call. At most one step can be `in_progress`. A `blocked` or `skipped` step needs a short `note` that makes the future decision clear. diff --git a/skills/.system/devspace-workflow/SKILL.md b/skills/.system/devspace-workflow/SKILL.md index 486d974..ebfc10b 100644 --- a/skills/.system/devspace-workflow/SKILL.md +++ b/skills/.system/devspace-workflow/SKILL.md @@ -1,75 +1,45 @@ --- name: devspace-workflow -description: Run concise DevSpace planning, goal, and answer workflows with minimal narration. +description: Recover and coordinate project-scoped DevSpace workflow state across sessions, including Plan, Goal, mode, and concise history. license: MIT metadata: - version: 1.0.0 + version: 2.0.0 author: DevSpace category: workflow - updated: 2026-06-20 + updated: 2026-06-21 --- -# DevSpace Workflow +# DevSpace Workflow Recovery -## What This Skill Does +DevSpace stores a small project-scoped workflow state keyed by the workspace's canonical real path. This lets a new ChatGPT session continue the current project without loading chat transcripts, tool output, or all historical Plans. -Use this skill when the user drives DevSpace with concise workflow messages such as `/plan`, `/goal`, skill names, or compact answers to pending questions. +## Start or Resume -## Before Starting +After `open_workspace`, inspect `workflowDigest` first. -1. Confirm you already have a `workspaceId` for the active project. -2. If there is no open workspace, call `open_workspace` first. -3. Reuse the same `workspaceId`; do not reopen the same folder unless it stops working or the user explicitly asks. -4. Keep replies short and operational unless the user asks for explanation. +- No active state: work normally. Create a Plan or Goal only when the user asks for durable workflow state. +- Existing Goal or Plan: call `get_goal` or `get_plan` only when the current task needs its complete definition. +- Relevant older decision: call `get_workflow_history` with a small page size. Do not load history by default. +- A matching user request can resume the current workflow; an incompatible request requires the Goal conflict procedure. -## Workflow Modes +## Scope and Isolation -### Plan Workflow +- Same canonical project root: shared across DevSpace sessions and restarts. +- Different project roots: isolated. +- Different Git worktree roots: isolated by default. +- `workspaceId` is a session handle, never the durable Plan or Goal identity. -Trigger on messages like: +## Mode -- `@dev /plan ...` -- `/plan ...` +`plan` is a workflow preference, not a permission boundary. -Use `resolve_skill("/plan")` first, then follow the returned `create-plan` instructions. Treat `/plan` as an alias, not a native slash command. +- `plan`: read, inspect, ask material questions, write or update the Plan, then wait for implementation approval. +- `default`: make approved changes, test, and maintain the Plan or Goal when relevant. -### Goal Workflow +Plan Mode never grants extra filesystem, shell, Git, or Skill permissions. -Trigger on messages like: +## History -- `@dev /goal ...` -- `/goal ...` +Workflow history contains only compact events such as `plan.updated`, `goal.blocked`, and `mode.changed`. It must never contain full chats, raw tool output, secrets, or command logs. -Use `resolve_skill("/goal")` first, then follow the returned `define-goal` instructions. If the user explicitly wants a lightweight persisted goal record, use `create_goal`, `get_goal`, and `update_goal` after the goal is well-defined. - -### Compact Answers - -Trigger when there is pending `request_user_input` state and the user replies with compact text such as `1B,2A`, `1B, 2A`, or `1b 2a`. - -Prefer passing the raw reply through `handle_workspace_command` only for compact answer parsing, or directly through `answer_user_input(text)`, instead of paraphrasing it. - -### Batch File Changes - -When the user asks for broad or multi-file modifications, prefer `apply_workspace_patch` with a unified diff patch instead of shell redirection, heredocs, generated scripts, or ad-hoc write commands. - -### Git Push - -When the user explicitly asks to push commits, prefer `git_push` with structured `remote`, `branch`, and `setUpstream` arguments instead of `bash` with a raw `git push` command. - -## Response Standard - -- Bottom line first. -- Prefer action over explanation. -- For simple workflow steps, return a short status. -- Do not explain slash semantics or MCP mechanics unless the user asks. - -## References - -- [Command Mapping](references/commands.md) -- [Response Style](references/style.md) -- [Examples](references/examples.md) - -## Related Skills - -- `senior-architect-lite` for architecture decisions before or during `/plan` -- `skill-authoring-lite` for creating or refactoring DevSpace skills with the same structure +Read [references/workflow-recovery.md](references/workflow-recovery.md) for the resume sequence and [references/command-routing.md](references/command-routing.md) for alias behavior. diff --git a/skills/.system/devspace-workflow/references/command-routing.md b/skills/.system/devspace-workflow/references/command-routing.md new file mode 100644 index 0000000..f41fecb --- /dev/null +++ b/skills/.system/devspace-workflow/references/command-routing.md @@ -0,0 +1,11 @@ +# Command Routing + +The `/plan` and `/goal` strings are stable DevSpace aliases, not native host slash commands. + +- `/plan` resolves only to `devspace-plan`. +- `/goal` resolves only to `devspace-goal`. +- Project-local, installed, global, and vendored OpenAI Skills cannot silently override either alias. +- `resolve_skill` returns the full selected `SKILL.md` in one explicit call and activates its resource directory. +- `search_skills` discovers optional Skills without loading their instructions. + +`handle_workspace_command` remains a compatibility helper for raw `/plan`, `/goal`, and compact pending-input replies. New workflows should call `resolve_skill`, `get_plan` / `get_goal`, and state tools directly. diff --git a/skills/.system/devspace-workflow/references/commands.md b/skills/.system/devspace-workflow/references/commands.md index 47ba3cd..70a90fe 100644 --- a/skills/.system/devspace-workflow/references/commands.md +++ b/skills/.system/devspace-workflow/references/commands.md @@ -1,77 +1,23 @@ -# Command Mapping +# DevSpace Workflow Commands -## `/plan` - -Inputs: - -- `@dev /plan ...` -- `/plan ...` +DevSpace command strings are routing conventions used by the model, not shell commands and not native ChatGPT slash commands. -Expected behavior: +## `/plan` -1. Call `resolve_skill("/plan")`. -2. Read and follow the returned `create-plan` instructions. -3. Keep the full `/plan` pass read-only. -4. Keep replies short unless the user explicitly asks for detail. +1. Resolve `devspace-plan` through `resolve_skill("/plan")`. +2. Read the current Plan with `get_plan`. +3. Set collaboration mode to `plan` when entering a planning pass. +4. Inspect the repository read-only, ask material questions only, and persist the complete Plan with `update_plan(expectedRevision=...)`. +5. Do not write project files while Plan Mode remains active. ## `/goal` -Inputs: - -- `@dev /goal ...` -- `/goal ...` - -Expected behavior: - -1. Call `resolve_skill("/goal")`. -2. Read and follow the returned `define-goal` instructions. -3. Only use `create_goal`, `get_goal`, or `update_goal` if the user explicitly wants a lightweight persisted goal record. - -## Compact Answers - -Inputs: - -- `1B,2A` -- `1B, 2A` -- `1b 2a` - -Expected behavior: - -1. Check that a pending `request_user_input` exists. -2. Pass the raw text to `handle_workspace_command` or `answer_user_input(text)`. -3. Do not rewrite the user's answer into prose before submitting it. - -## Failure Handling - -- If there is no open workspace, open one first. -- If there is no pending user input, do not pretend the answer was accepted. -- If the compact answer is incomplete or invalid, return the specific validation error. - -## Batch File Changes - -Inputs: - -- "Modify these files ..." -- "Apply this patch ..." -- "Do the same change across the project ..." - -Expected behavior: - -1. Inspect the files first. -2. Use `apply_workspace_patch` for coordinated multi-file changes. -3. Avoid `bash` redirection, heredocs, `sed -i`, `perl -i`, or generated scripts for project writes. -4. Call `show_changes` after the related change set when available. - -## Git Push - -Inputs: - -- "Push this branch" -- "git push" -- "Push origin main" +1. Resolve `devspace-goal` through `resolve_skill("/goal")`. +2. Read the current Goal with `get_goal`. +3. Create one only when the user asks for a durable, cross-session objective. +4. For a conflicting active Goal, ask before archiving, completing, blocking, or keeping it. +5. Use `expectedRevision` on every existing Goal update. -Expected behavior: +## Optional Skills -1. Use `git status` or the git inspection tools to verify what will be pushed. -2. Use `git_push` with structured arguments. -3. Do not use generic `bash` for raw `git push` unless `git_push` is unavailable. +Use `search_skills` to discover Skills without injecting all instructions into context. Once selected, call `resolve_skill` with the returned qualified ID. A Skill reference can be read only after the Skill has been resolved and activated. \ No newline at end of file diff --git a/skills/.system/devspace-workflow/references/examples.md b/skills/.system/devspace-workflow/references/examples.md index 60e21ea..6f5f7a6 100644 --- a/skills/.system/devspace-workflow/references/examples.md +++ b/skills/.system/devspace-workflow/references/examples.md @@ -1,42 +1,28 @@ -# Examples +# Workflow State Examples -## Planning +## Resume an existing Plan -User: +`open_workspace` reports an active Plan in `workflowDigest`. -```text -@dev /plan 修复国家列表节点数量显示 -``` +1. Call `get_plan`. +2. Confirm the requested work still matches the Plan scope. +3. When implementation starts, set the relevant pending step to `in_progress` using the returned revision. +4. After validation, mark the step `completed` and record a concise note only when the result matters to the next session. -Expected behavior: +## Handle a concurrent update -- enter plan mode -- continue planning -- keep the immediate status brief +A Plan update fails with a revision conflict. -## Goal +1. Call `get_plan` again. +2. Compare the new step statuses, validation, and blockers against the intended update. +3. Preserve verified work from the other session. +4. Send one merged full step list with the new revision. -User: +## Recover a blocked Goal -```text -@dev /goal 修复国家列表节点数量显示 -``` +A Goal has `status=blocked` after an external decision is needed. -Expected behavior: - -- create or continue the goal -- report a short status - -## Compact Answer - -User: - -```text -1B,2A -``` - -Expected behavior: - -- treat the reply as answer payload -- complete pending user input if valid -- return a short status +1. Do not update the non-active Goal in place. +2. Resolve the blocker with the user. +3. Create a new Goal that captures the resumed outcome, or keep the blocked Goal as historical context. +4. Link the new Plan to the new Goal when the relationship is useful. diff --git a/skills/.system/devspace-workflow/references/style.md b/skills/.system/devspace-workflow/references/style.md index 30eee9c..d1cf0cc 100644 --- a/skills/.system/devspace-workflow/references/style.md +++ b/skills/.system/devspace-workflow/references/style.md @@ -1,22 +1,12 @@ -# Response Style +# Workflow Response Style -## Core Rules +Workflow responses should be compact, evidence-based, and resumable. -- Bottom line first. -- Prefer action over explanation. -- Use short status messages for straightforward workflow steps. -- Avoid long tutorials, architecture essays, or repeated background. +- State what was inspected before making an architectural claim. +- Show the current Plan or Goal status only when it matters to the request. +- Prefer concrete validation evidence over percentage completion. +- Keep `currentSummary` to completed work, current work, and real blockers. +- Do not paste full chat history, raw shell output, full file contents, or credentials into workflow state. +- Explain a revision conflict as a normal concurrent-edit condition and reload state before deciding how to merge. -## Good Status Examples - -- `Resolved /plan to create-plan` -- `Resolved /goal to define-goal` -- `Answer recorded` -- `No workflow command recognized` - -## Avoid - -- Re-explaining what `/plan` means after already acting on it -- Explaining MCP mechanics unless the user asks -- Repeating already-confirmed choices -- Turning one-line results into long summaries +A good Plan has enough detail for a future session to continue. A good Goal identifies success and stopping conditions without becoming a project-management dashboard. \ No newline at end of file diff --git a/skills/.system/devspace-workflow/references/workflow-recovery.md b/skills/.system/devspace-workflow/references/workflow-recovery.md new file mode 100644 index 0000000..b5d976b --- /dev/null +++ b/skills/.system/devspace-workflow/references/workflow-recovery.md @@ -0,0 +1,10 @@ +# Workflow Recovery Sequence + +1. Call `open_workspace` once for the project root or worktree. +2. Read the compact `workflowDigest` returned by DevSpace. +3. Load `get_goal` only when the task depends on its objective, success criteria, verification, stop conditions, status, or revision. +4. Load `get_plan` only when the task needs its steps, validation, risks, status, or revision. +5. Use `get_workflow_history` only to inspect a specific past decision. Use the cursor rather than requesting unbounded history. +6. When updating Plan or Goal, pass the revision that was read. On conflict, reload and merge. + +Do not automatically create a Goal merely because a workflowDigest is empty. Do not treat the digest as enough detail to execute an old Plan without loading it. diff --git a/skills/.system/senior-architect-lite/SKILL.md b/skills/.system/senior-architect-lite/SKILL.md index c33102e..7ff0ab9 100644 --- a/skills/.system/senior-architect-lite/SKILL.md +++ b/skills/.system/senior-architect-lite/SKILL.md @@ -1,44 +1,48 @@ --- name: senior-architect-lite -description: Evaluate architecture options, tradeoffs, and implementation direction for coding tasks inside DevSpace. +description: Legacy compatibility architecture-review workflow. Use senior-architect for the active DevSpace core Skill. license: MIT metadata: - version: 1.0.0 + version: 2.0.0 author: DevSpace - category: engineering - updated: 2026-06-20 + category: legacy-engineering + updated: 2026-06-21 --- -# Senior Architect Lite +# Legacy Architecture Review Compatibility -## What This Skill Does +This Skill remains for compatibility with older prompts. The active core Skill is `senior-architect`. -Use this skill when the task needs architecture guidance, solution framing, design tradeoff analysis, or implementation direction before code changes. +## Evidence First -## Before Starting +Before recommending an architectural change, inspect the project instructions, public interfaces, schema and persistence model, tests, deployment configuration, and current failure paths. Separate observed facts from assumptions. -1. Read the relevant code, types, configs, and entrypoints first. -2. Ground recommendations in the current repository, not generic best practices. -3. Keep conclusions concise and decision-oriented. +## Required Review Dimensions -## Workflow +Evaluate each relevant dimension explicitly: -1. Identify the current architecture and constraints. -2. Compare only the viable options. -3. Recommend one approach with clear reasoning. -4. Surface the main risks, compatibility concerns, and validation needs. -5. If the task is still ambiguous, use `request_user_input` for the missing product or tradeoff decision. +- ownership and lifecycle of state; +- API and storage compatibility; +- concurrency, retries, and data-loss failure modes; +- authorization, filesystem, shell, and network boundaries; +- migration and rollback; +- observability and operator recovery; +- tests that prove the proposed behavior. -## Deliverable +Do not propose a subsystem because it sounds broadly useful. Tie every recommendation to concrete files, interfaces, user flows, and operational cost. -Return: +## Workflow State + +When reviewing Plan or Goal work, remember that DevSpace state is scoped to a canonical project root. It is shared across new sessions for the same root but isolated from other projects and Git worktrees. Plans and Goals use revisions to prevent silent concurrent overwrite. -- recommended approach -- why it fits this codebase -- key implementation implications -- tests or checks needed to validate it +## Output + +Return: -## References +1. constraints and evidence; +2. a recommended implementation boundary; +3. alternatives rejected and why; +4. migration, rollback, and security effects; +5. validation steps. -- [Decision Guide](references/decision-guide.md) -- [Response Style](references/style.md) +Use `devspace-plan` when the user wants a persisted implementation Plan. \ No newline at end of file diff --git a/skills/.system/senior-architect-lite/references/decision-guide.md b/skills/.system/senior-architect-lite/references/decision-guide.md index 8039c32..1e3e555 100644 --- a/skills/.system/senior-architect-lite/references/decision-guide.md +++ b/skills/.system/senior-architect-lite/references/decision-guide.md @@ -1,14 +1,21 @@ -# Decision Guide +# Architecture Decision Guide -When comparing options, prefer: +Use an architecture recommendation only after documenting the relevant constraints. -- the smallest change that still fully satisfies the requirement -- compatibility with existing project patterns -- explicit interfaces and testability -- approaches that reduce follow-up ambiguity for the implementer +## Minimum evidence -Always name: +- current public API or command contract; +- data schema and migration path; +- relevant tests and failure behavior; +- deployment and operator boundary; +- compatibility expectations for existing clients and stored data. -- the recommended option -- the main rejected option -- the key risk or migration concern +## Decision questions + +1. What concrete user or operator failure does the change solve? +2. Which module owns the new state or policy? +3. What happens during partial failure, restart, retry, or concurrent access? +4. Which existing callers could break? +5. How can the change be verified and rolled back? + +Prefer a narrow adapter or migration over a new framework when the existing architecture already has a suitable boundary. \ No newline at end of file diff --git a/skills/.system/senior-architect-lite/references/style.md b/skills/.system/senior-architect-lite/references/style.md index 70b9dec..1e4a9c2 100644 --- a/skills/.system/senior-architect-lite/references/style.md +++ b/skills/.system/senior-architect-lite/references/style.md @@ -1,6 +1,12 @@ -# Response Style +# Architecture Review Style -- Lead with the recommendation. -- Keep tradeoff discussion focused on the actual repo. -- Avoid long framework lectures. -- Do not list speculative future architecture unless asked. +Use direct language. Separate facts from assumptions. Name the exact file, interface, table, or external contract that supports an important claim. + +Do not: + +- propose a subsystem without showing its ownership and lifecycle; +- treat a generalized future need as evidence for present complexity; +- call an unverified behavior safe, compatible, or complete; +- hide migration, security, or rollback implications behind broad wording. + +End with validation that a maintainer can actually run or observe. \ No newline at end of file diff --git a/skills/.system/senior-architect/SKILL.md b/skills/.system/senior-architect/SKILL.md new file mode 100644 index 0000000..99ec653 --- /dev/null +++ b/skills/.system/senior-architect/SKILL.md @@ -0,0 +1,34 @@ +--- +name: senior-architect +description: Perform evidence-driven architecture review for a DevSpace workspace without bypassing project instructions, tests, or workflow state. +license: MIT +metadata: + version: 2.0.0 + author: DevSpace + category: engineering + updated: 2026-06-21 +--- + +# Senior Architect Review + +Use this Skill for design decisions that span modules, persistent state, compatibility, security boundaries, or rollout risk. + +## Method + +1. Read `AGENTS.md`, entry points, data schema, public API, tests, and relevant configuration before making design claims. +2. Distinguish observed facts from assumptions and unresolved questions. +3. Prefer the smallest compatible change that preserves security boundaries and migration safety. +4. Evaluate data ownership, lifecycle, failure recovery, concurrency, backwards compatibility, observability, and release validation. +5. When the user is asking for a Plan, follow `devspace-plan` and persist only a verified, implementation-ready Plan. + +## Output + +Give a decision with: + +- current constraints and evidence, +- recommended approach, +- rejected alternatives and why, +- migration and rollback impact, +- tests that prove the decision. + +Do not produce generic architecture slogans. Do not propose a new subsystem without identifying the concrete code boundaries and operational cost. diff --git a/skills/.system/skill-authoring-lite/SKILL.md b/skills/.system/skill-authoring-lite/SKILL.md index 9117015..2e82a05 100644 --- a/skills/.system/skill-authoring-lite/SKILL.md +++ b/skills/.system/skill-authoring-lite/SKILL.md @@ -1,27 +1,45 @@ --- name: skill-authoring-lite -description: Create or refactor DevSpace skills using a structured SKILL.md plus references layout. +description: Legacy compatibility Skill-authoring guidance. Use skill-authoring for the active DevSpace core Skill. license: MIT metadata: - version: 1.0.0 + version: 2.0.0 author: DevSpace - category: meta - updated: 2026-06-20 + category: legacy-engineering + updated: 2026-06-21 --- -# Skill Authoring Lite +# Legacy Skill Authoring Compatibility -## What This Skill Does +This Skill is retained for older callers. The current core authoring workflow is `skill-authoring`. -Use this skill when creating or refactoring skills for DevSpace so they stay structured, concise, and reference-driven. +## Authoring Contract -## Workflow +A DevSpace Skill must describe a real task workflow rather than act as a one-line persona prompt. Its `SKILL.md` should state: -1. Keep `SKILL.md` focused on workflow and decisions. -2. Move detail-heavy material into `references/`. -3. Prefer examples and checklists over long prose. -4. Keep the response standard explicit. +- when the Skill applies; +- what repository evidence to inspect; +- which DevSpace Tools to call and in what order; +- write, shell, and security boundaries; +- validation and recovery behavior; +- where detailed references live. -## References +## Structure -- [Structure Checklist](references/structure-checklist.md) +```text +skills/.system// +├── SKILL.md +└── references/ +``` + +Use `references/` for state contracts, API constraints, examples, checklists, or conflict procedures. Do not hide executable behavior in prose; Skills never grant additional filesystem, shell, Git, network, or credential access. + +## Routing and Sources + +`/plan` and `/goal` are reserved DevSpace aliases and must not be overridden by project-local, installed, global, or vendored Skills. `resolve_skill` selects and activates a Skill. `search_skills` discovers optional Skills without injecting their entire contents into context. + +Use `skill://` locators for Skill files and activated resources; do not expose server absolute paths as the public protocol. + +## Validation + +When adding or revising a Skill, update discovery and alias tests, test controlled resource access, ensure package contents include the Skill, and verify the Tool contracts described by the workflow. \ No newline at end of file diff --git a/skills/.system/skill-authoring-lite/references/structure-checklist.md b/skills/.system/skill-authoring-lite/references/structure-checklist.md index b2e3977..d472f29 100644 --- a/skills/.system/skill-authoring-lite/references/structure-checklist.md +++ b/skills/.system/skill-authoring-lite/references/structure-checklist.md @@ -1,10 +1,14 @@ -# Structure Checklist +# Skill Structure Checklist -A DevSpace skill should usually include: +Before accepting a Skill change, verify: -- frontmatter with `name`, `description`, `license`, and `metadata` -- a short purpose section -- `Before Starting` -- a compact workflow -- response style guidance -- `references/` for detailed rules and examples +- frontmatter has a stable name and an accurate description; +- the Skill says when it applies and when it does not; +- required Tool calls and their order are explicit; +- file, shell, Git, network, and credential boundaries are explicit; +- supporting procedures live in `references/` rather than bloating the main Skill; +- reserved aliases such as `/plan` and `/goal` are not overridden; +- the Skill can recover from missing state or a revision conflict; +- discovery, resolution, resource access, and packaging tests cover the change. + +A Skill must guide a dependable workflow, not merely request a tone or role. \ No newline at end of file diff --git a/skills/.system/skill-authoring/SKILL.md b/skills/.system/skill-authoring/SKILL.md new file mode 100644 index 0000000..8a12446 --- /dev/null +++ b/skills/.system/skill-authoring/SKILL.md @@ -0,0 +1,37 @@ +--- +name: skill-authoring +description: Create or revise DevSpace Skills with stable aliases, controlled resource access, test coverage, and no hidden execution behavior. +license: MIT +metadata: + version: 2.0.0 + author: DevSpace + category: engineering + updated: 2026-06-21 +--- + +# DevSpace Skill Authoring + +Use this Skill when creating or revising a Skill bundled with DevSpace or installed in a workspace. + +## Requirements + +- Every Skill must have valid frontmatter with `name` and `description`. +- Instructions must describe a real workflow, tool lifecycle, safety boundary, validation standard, and recovery path where relevant. +- Put supporting detail in `references/`; do not make `SKILL.md` a one-line prompt or an unbounded manual. +- Skills must never imply automatic execution of scripts, shell commands, Git operations, or file writes. +- A Skill can only expose its resources after `resolve_skill` or a direct read of its `SKILL.md` activates it. +- `/plan` and `/goal` are reserved DevSpace aliases. Do not create a local Skill that expects to override them. + +## Bundled Skill Layout + +```text +skills/.system// +├── SKILL.md +└── references/ +``` + +DevSpace core Skills live directly under `skills/.system/`. Vendored OpenAI Skills live under `skills/.system/openai/skills/` and must remain unmodified except during a documented upstream sync. + +## Validation + +When changing Skills, add or update tests for discovery, source priority, alias routing, `skill://` access, packaging, and any tool contract the Skill depends on. diff --git a/src/cli-skills.test.ts b/src/cli-skills.test.ts index 9b0b893..fc1cc43 100644 --- a/src/cli-skills.test.ts +++ b/src/cli-skills.test.ts @@ -6,11 +6,11 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; const root = mkdtempSync(join(tmpdir(), "devspace-cli-skills-test-")); -const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); try { - const help = execFileSync("node", ["--import", "tsx", "src/cli.ts", "help"], { - cwd: repoRoot, + const help = execFileSync(process.execPath, ["--import", "tsx", "src/cli.ts", "help"], { + cwd: projectRoot, encoding: "utf8", }); assert.match(help, /devspace skills install/); diff --git a/src/db/schema.ts b/src/db/schema.ts index f0da772..0c47837 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; export const workspaceSessions = sqliteTable( "workspace_sessions", @@ -81,6 +81,151 @@ export const workspaceModes = sqliteTable( }, ); +export const projectWorkflows = sqliteTable( + "project_workflows", + { + projectWorkflowKey: text("project_workflow_key").primaryKey(), + canonicalRoot: text("canonical_root").notNull(), + workspaceKind: text("workspace_kind").notNull(), + gitCommonDir: text("git_common_dir"), + gitRemoteOrigin: text("git_remote_origin"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + }, + (table) => [uniqueIndex("project_workflows_root_idx").on(table.canonicalRoot)], +); + +export const workflowPlans = sqliteTable( + "workflow_plans", + { + id: text("id").primaryKey(), + projectWorkflowKey: text("project_workflow_key") + .notNull() + .references(() => projectWorkflows.projectWorkflowKey, { onDelete: "cascade" }), + goalId: text("goal_id"), + title: text("title").notNull(), + summary: text("summary"), + scopeInJson: text("scope_in_json").notNull(), + scopeOutJson: text("scope_out_json").notNull(), + validationJson: text("validation_json").notNull(), + risksJson: text("risks_json").notNull(), + status: text("status").notNull(), + revision: integer("revision").notNull(), + isCurrent: integer("is_current").notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + archivedAt: text("archived_at"), + }, + (table) => [index("workflow_plans_history_idx").on(table.projectWorkflowKey, table.updatedAt)], +); + +export const workflowPlanSteps = sqliteTable( + "workflow_plan_steps", + { + id: text("id").primaryKey(), + planId: text("plan_id") + .notNull() + .references(() => workflowPlans.id, { onDelete: "cascade" }), + position: integer("position").notNull(), + content: text("content").notNull(), + status: text("status").notNull(), + note: text("note"), + updatedAt: text("updated_at").notNull(), + }, + (table) => [index("workflow_plan_steps_plan_idx").on(table.planId, table.position)], +); + +export const workflowGoals = sqliteTable( + "workflow_goals", + { + id: text("id").primaryKey(), + projectWorkflowKey: text("project_workflow_key") + .notNull() + .references(() => projectWorkflows.projectWorkflowKey, { onDelete: "cascade" }), + objective: text("objective").notNull(), + scopeInJson: text("scope_in_json").notNull(), + scopeOutJson: text("scope_out_json").notNull(), + successCriteriaJson: text("success_criteria_json").notNull(), + verificationJson: text("verification_json").notNull(), + stopConditionsJson: text("stop_conditions_json").notNull(), + currentSummary: text("current_summary"), + status: text("status").notNull(), + revision: integer("revision").notNull(), + isCurrent: integer("is_current").notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + archivedAt: text("archived_at"), + }, + (table) => [index("workflow_goals_history_idx").on(table.projectWorkflowKey, table.updatedAt)], +); + +export const workflowGoalMetrics = sqliteTable( + "workflow_goal_metrics", + { + goalId: text("goal_id") + .primaryKey() + .references(() => workflowGoals.id, { onDelete: "cascade" }), + activeWorkStartedAt: text("active_work_started_at"), + accumulatedWorkMs: integer("accumulated_work_ms").notNull(), + updatedAt: text("updated_at").notNull(), + }, +); + +export const workflowGoalTokenUsage = sqliteTable( + "workflow_goal_token_usage", + { + id: text("id").primaryKey(), + goalId: text("goal_id") + .notNull() + .references(() => workflowGoals.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + providerRequestId: text("provider_request_id").notNull(), + model: text("model"), + inputTokens: integer("input_tokens").notNull(), + outputTokens: integer("output_tokens").notNull(), + reasoningTokens: integer("reasoning_tokens").notNull(), + totalTokens: integer("total_tokens").notNull(), + providerReportedAt: text("provider_reported_at"), + recordedAt: text("recorded_at").notNull(), + }, + (table) => [ + uniqueIndex("workflow_goal_token_usage_dedupe_idx").on( + table.goalId, + table.provider, + table.providerRequestId, + ), + index("workflow_goal_token_usage_history_idx").on(table.goalId, table.recordedAt), + ], +); + +export const workflowModes = sqliteTable( + "workflow_modes", + { + projectWorkflowKey: text("project_workflow_key") + .primaryKey() + .references(() => projectWorkflows.projectWorkflowKey, { onDelete: "cascade" }), + mode: text("mode").notNull(), + updatedAt: text("updated_at").notNull(), + }, +); + +export const workflowEvents = sqliteTable( + "workflow_events", + { + id: text("id").primaryKey(), + projectWorkflowKey: text("project_workflow_key") + .notNull() + .references(() => projectWorkflows.projectWorkflowKey, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + entityId: text("entity_id").notNull(), + eventType: text("event_type").notNull(), + summary: text("summary").notNull(), + revision: integer("revision"), + createdAt: text("created_at").notNull(), + }, + (table) => [index("workflow_events_history_idx").on(table.projectWorkflowKey, table.createdAt)], +); + export const workspaceUserInputs = sqliteTable( "workspace_user_inputs", { @@ -166,3 +311,11 @@ export type WorkspaceModeRow = typeof workspaceModes.$inferSelect; export type NewWorkspaceModeRow = typeof workspaceModes.$inferInsert; export type WorkspaceUserInputRow = typeof workspaceUserInputs.$inferSelect; export type NewWorkspaceUserInputRow = typeof workspaceUserInputs.$inferInsert; +export type ProjectWorkflowRow = typeof projectWorkflows.$inferSelect; +export type WorkflowPlanRow = typeof workflowPlans.$inferSelect; +export type WorkflowPlanStepRow = typeof workflowPlanSteps.$inferSelect; +export type WorkflowGoalRow = typeof workflowGoals.$inferSelect; +export type WorkflowGoalMetricsRow = typeof workflowGoalMetrics.$inferSelect; +export type WorkflowGoalTokenUsageRow = typeof workflowGoalTokenUsage.$inferSelect; +export type WorkflowModeRow = typeof workflowModes.$inferSelect; +export type WorkflowEventRow = typeof workflowEvents.$inferSelect; diff --git a/src/package-smoke.test.ts b/src/package-smoke.test.ts new file mode 100644 index 0000000..303ea30 --- /dev/null +++ b/src/package-smoke.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse(readFileSync(resolve(projectRoot, "package.json"), "utf8")) as { + files?: unknown; +}; + +assert.equal(Array.isArray(packageJson.files), true); +assert.equal((packageJson.files as string[]).includes("skills"), true); + +for (const path of [ + "skills/.system/devspace-plan/SKILL.md", + "skills/.system/devspace-plan/references/plan-state.md", + "skills/.system/devspace-goal/SKILL.md", + "skills/.system/devspace-goal/references/goal-state.md", + "skills/.system/devspace-workflow/SKILL.md", + "skills/.system/devspace-workflow/references/workflow-recovery.md", + "skills/.system/senior-architect/SKILL.md", + "skills/.system/skill-authoring/SKILL.md", +]) { + assert.equal(existsSync(resolve(projectRoot, path)), true, `Missing bundled Skill asset: ${path}`); +} diff --git a/src/prompting.test.ts b/src/prompting.test.ts index 610b7c4..6693f16 100644 --- a/src/prompting.test.ts +++ b/src/prompting.test.ts @@ -26,14 +26,18 @@ assert.match(instructions, /Prefer action over explanation\./); assert.match(instructions, /Keep responses terse and operational\./); assert.match(instructions, /Do not add long design discussion, repeated background, or speculative future improvements unless the user explicitly asks for them\./); assert.match(instructions, /When the user sends a short reply such as '1B, 2A', treat it as workflow input and continue instead of explaining the mechanism back to them\./); -assert.match(instructions, /When the user mentions a skill name, \/plan, or \/goal, prefer resolve_skill to load the relevant SKILL\.md instructions\./); +assert.match(instructions, /When the user mentions a skill name, \/plan, or \/goal, use resolve_skill to load the relevant SKILL\.md instructions\./); +assert.match(instructions, /Plan and Goal as project-scoped shared workflow state/); +assert.match(instructions, /open_workspace returns only workflowDigest/); +assert.match(instructions, /update_plan is allowed in plan mode/); +assert.match(instructions, /\/plan always resolves to DevSpace's devspace-plan Skill/); assert.match(instructions, /Treat \/plan and \/goal as aliases, not native ChatGPT slash commands\./); assert.match(instructions, /Use handle_workspace_command only for compact pending-input replies or legacy workflow compatibility\./); const planInstruction = workspaceInstruction("plan", false); -assert.match(planInstruction, /ask clarifying questions with request_user_input only when they materially affect the plan/); -assert.match(planInstruction, /Keep the plan decision complete but compact\./); -assert.match(planInstruction, /Do not repeat already-confirmed choices, do not add long design essays/); +assert.match(planInstruction, /ask clarifying questions with request_user_input only when they materially affect the Plan/); +assert.match(planInstruction, /use update_plan with its expectedRevision to persist the revised Plan/); +assert.match(planInstruction, /Do not modify project files while plan mode is active\./); const defaultInstruction = workspaceInstruction("default", false); assert.match(defaultInstruction, /execute work directly, keep status updates brief/); diff --git a/src/prompting.ts b/src/prompting.ts index 995dc9d..18480e7 100644 --- a/src/prompting.ts +++ b/src/prompting.ts @@ -17,7 +17,7 @@ export function serverInstructions( : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; const skills = context.skillsEnabled - ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` + ? `When a task matches a Skill, use resolve_skill to load its SKILL.md instructions. Use search_skills to discover vendored OpenAI Skills without loading all of them. Skill resources use skill:// locators; ${toolNames.read} only permits the resolved SKILL.md and resources under an activated Skill directory. ` : ""; const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; @@ -27,13 +27,13 @@ export function serverInstructions( : ""; const planning = - " Use get_collaboration_mode to inspect the workspace collaboration mode. Use set_collaboration_mode only when a lightweight collaboration toggle is useful. In default mode, use update_plan for a concise execution checklist when helpful. In plan mode, prefer request_user_input, repository exploration, and concrete specification work; do not use update_plan while plan mode is active. Treat create_goal, get_goal, and update_goal as lightweight, verifiable goal records for the current workspace rather than a long-running project-management system."; + " Treat Plan and Goal as project-scoped shared workflow state, not chat memory or a project-management system. open_workspace returns only workflowDigest; call get_plan or get_goal only when their full state is needed. Before changing a Plan or Goal, read its revision and pass expectedRevision to update_plan or update_goal. In plan mode, inspect and ask material questions first, then persist the approved Plan with update_plan; update_plan is allowed in plan mode. Use get_workflow_history only when a concise historical event is relevant."; const style = " Prefer action over explanation. Keep responses terse and operational. For mode switches, goal updates, confirmations, cancellations, pending answers, and other straightforward workflow steps, return only the necessary status or next action. Do not add long design discussion, repeated background, or speculative future improvements unless the user explicitly asks for them. When the user sends a short reply such as '1B, 2A', treat it as workflow input and continue instead of explaining the mechanism back to them."; const commands = - " When the user mentions a skill name, /plan, or /goal, prefer resolve_skill to load the relevant SKILL.md instructions. Treat /plan and /goal as aliases, not native ChatGPT slash commands. Use handle_workspace_command only for compact pending-input replies or legacy workflow compatibility. For concise pending-input replies, prefer answer_user_input(text) over paraphrasing the user's message."; + " When the user mentions a skill name, /plan, or /goal, use resolve_skill to load the relevant SKILL.md instructions. /plan always resolves to DevSpace's devspace-plan Skill and /goal always resolves to devspace-goal; vendored OpenAI Skills do not override these aliases. Treat /plan and /goal as aliases, not native ChatGPT slash commands. Use handle_workspace_command only for compact pending-input replies or legacy workflow compatibility. For concise pending-input replies, prefer answer_user_input(text) over paraphrasing the user's message."; return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, shell, skill, plan, and goal tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}${planning}${style}${commands} Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, apply_workspace_patch for coordinated multi-file patches, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Use git_push for explicit push requests instead of raw git push through ${toolNames.shell}. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; } @@ -43,12 +43,12 @@ export function workspaceInstruction( skillsEnabled: boolean, ): string { const base = skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." + ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. Use resolve_skill for task-matched Skills and search_skills for vendored Skill discovery." : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; if (mode === "plan") { - return `${base} This workspace is currently in plan mode: explore first, ask clarifying questions with request_user_input only when they materially affect the plan, and produce a concrete implementation plan before execution. Keep the plan decision complete but compact. Do not repeat already-confirmed choices, do not add long design essays, and do not use update_plan while plan mode is active.`; + return `${base} This workspace is currently in plan mode: explore first, ask clarifying questions with request_user_input only when they materially affect the Plan, and produce a concrete implementation plan before execution. Read get_plan when a prior Plan exists, then use update_plan with its expectedRevision to persist the revised Plan. Do not modify project files while plan mode is active.`; } - return `${base} This workspace is currently in default mode: execute work directly, keep status updates brief, and use update_plan only when a concise execution checklist would help. Do not add unnecessary explanation for straightforward actions or results.`; + return `${base} This workspace is currently in default mode: execute work directly, keep status updates brief, and keep the current Plan and Goal accurate when they are relevant. Do not add unnecessary explanation for straightforward actions or results.`; } diff --git a/src/server.ts b/src/server.ts index 32d8acd..1c1d7b1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -42,9 +42,9 @@ import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { validateShellCommand } from "./shell-policy.js"; import { - formatPathForPrompt, resolveSkillDefinition, skillSourceLabel, + type DevSpaceSkill, type SkillResolveMode, type SkillSource, } from "./skills.js"; @@ -55,12 +55,7 @@ import { type InstalledSkillRecord, type SkillInstallSource, } from "./skill-manager.js"; -import { - normalizeGoalDefinition, - parseGoalDefinition, - serializeGoalDefinition, - type GoalDefinition, -} from "./goal-definition.js"; +import { normalizeGoalDefinition } from "./goal-definition.js"; import { contentStats, contentText, toolError, type ToolContent } from "./tool-result.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; @@ -68,9 +63,12 @@ import { serverInstructions as buildServerInstructions, workspaceInstruction } f import { parseAnswerTextOrThrow, parseWorkspaceCommand } from "./workspace-commands.js"; import { applyWorkspacePatch, gitPush } from "./workspace-operations.js"; import type { - WorkspaceStore, + WorkflowDigest, + WorkspaceGoal, + WorkspacePlan, WorkspacePlanStep, WorkspaceQuestion, + WorkspaceStore, WorkspaceUserInputAnswer, WorkspaceUserInputRecord, } from "./workspace-store.js"; @@ -78,6 +76,7 @@ import type { type Transport = StreamableHTTPServerTransport; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html"; +const MAX_OPEN_WORKSPACE_SKILLS = 24; const WRITE_TOOL_ANNOTATIONS = { readOnlyHint: false, destructiveHint: true, @@ -232,11 +231,20 @@ function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { }; } +const skillSourceOutputSchema = z.enum([ + "devspace_system", + "local", + "legacy_core", + "installed", + "official_vendored", + "global", +]); + const workspaceSkillOutputSchema = z.object({ name: z.string(), description: z.string(), path: z.string(), - source: z.enum(["system", "local", "installed", "global"]), + source: skillSourceOutputSchema, }); const installedSkillOutputSchema = z.object({ @@ -250,23 +258,113 @@ const installedSkillOutputSchema = z.object({ const resolvedSkillOutputSchema = z.object({ name: z.string(), - source: z.enum(["system", "local", "installed", "global"]), + qualifiedId: z.string(), + source: skillSourceOutputSchema, path: z.string(), alias: z.string().optional(), mode: z.enum(["read_only", "normal"]), instructions: z.string(), }); -const goalDefinitionOutputSchema = z.object({ +const workflowScopeOutputSchema = z.object({ + in: z.array(z.string()), + out: z.array(z.string()), +}); + +const workflowPlanStepOutputSchema = z.object({ + id: z.string().optional(), + step: z.string(), + status: z.enum(["pending", "in_progress", "blocked", "completed", "skipped"]), + note: z.string().optional(), + updatedAt: z.string().optional(), +}); + +const workflowPlanOutputSchema = z.object({ + id: z.string(), + projectWorkflowKey: z.string(), + goalId: z.string().optional(), + title: z.string(), + summary: z.string().optional(), + scope: workflowScopeOutputSchema, + validation: z.array(z.string()), + risks: z.array(z.string()), + status: z.enum(["draft", "active", "completed", "archived"]), + revision: z.number().int().positive(), + steps: z.array(workflowPlanStepOutputSchema), + createdAt: z.string(), + updatedAt: z.string(), + archivedAt: z.string().optional(), +}); + +const goalTokenUsageOutputSchema = z.object({ + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + reasoningTokens: z.number().int().nonnegative(), + totalTokens: z.number().int().nonnegative(), + reportCount: z.number().int().nonnegative(), + lastReportedAt: z.string().optional(), +}); + +const goalWorkDurationOutputSchema = z.object({ + running: z.boolean(), + startedAt: z.string().optional(), + accumulatedMilliseconds: z.number().int().nonnegative(), + liveMilliseconds: z.number().int().nonnegative(), + totalMilliseconds: z.number().int().nonnegative(), + measuredAt: z.string(), +}); + +const goalProgressOutputSchema = z.object({ + source: z.enum(["linked_plan_steps", "unlinked"]), + completedSteps: z.number().int().nonnegative(), + totalSteps: z.number().int().nonnegative(), + exactFraction: z.string().optional(), + percentageNumerator: z.number().int().nonnegative().optional(), + percentageDenominator: z.number().int().positive().optional(), + displayPercent: z.string().optional(), +}); + +const goalMetricsOutputSchema = z.object({ + tokenUsage: goalTokenUsageOutputSchema, + workDuration: goalWorkDurationOutputSchema, + progress: goalProgressOutputSchema, + updatedAt: z.string().optional(), +}); + +const workflowGoalOutputSchema = z.object({ + id: z.string(), + projectWorkflowKey: z.string(), objective: z.string(), - scope: z + scope: workflowScopeOutputSchema, + successCriteria: z.array(z.string()), + verification: z.array(z.string()), + stopConditions: z.array(z.string()), + currentSummary: z.string().optional(), + status: z.enum(["active", "blocked", "completed", "archived"]), + revision: z.number().int().positive(), + metrics: goalMetricsOutputSchema, + createdAt: z.string(), + updatedAt: z.string(), + archivedAt: z.string().optional(), +}); + +const workflowDigestOutputSchema = z.object({ + projectWorkflowKey: z.string(), + hasActiveGoal: z.boolean(), + goalStatus: z.enum(["active", "blocked", "completed", "archived"]).optional(), + goalTitle: z.string().optional(), + hasActivePlan: z.boolean(), + planStatus: z.enum(["draft", "active", "completed", "archived"]).optional(), + planRevision: z.number().int().positive().optional(), + steps: z .object({ - in: z.array(z.string()), - out: z.array(z.string()), + total: z.number().int().nonnegative(), + completed: z.number().int().nonnegative(), + inProgress: z.number().int().nonnegative(), + blocked: z.number().int().nonnegative(), }) .optional(), - verification: z.array(z.string()).optional(), - stopConditions: z.array(z.string()).optional(), + lastUpdatedAt: z.string().optional(), }); const workspaceAgentsFileOutputSchema = z.object({ @@ -635,9 +733,15 @@ function createMcpServer( agentsFiles: z.array(workspaceAgentsFileOutputSchema), availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), skills: z.array(workspaceSkillOutputSchema), + skillsTruncated: z.boolean(), skillDiagnostics: z.array(z.unknown()), instruction: z.string(), collaborationMode: z.enum(["default", "plan"]), + workflowDigest: workflowDigestOutputSchema, + skillSummary: z.object({ + total: z.number().int().nonnegative(), + bySource: z.record(skillSourceOutputSchema, z.number().int().nonnegative()), + }), }, ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, @@ -651,14 +755,25 @@ function createMcpServer( root: workspace.root, }); } - const visibleSkills = workspace.skills + const discoverableSkills = workspace.skills .filter((skill) => !skill.disableModelInvocation) + .filter((skill) => ( + skill.source === "devspace_system" || + skill.source === "local" || + skill.source === "legacy_core" || + skill.source === "installed" + )) + .sort((left, right) => left.name.localeCompare(right.name)); + const skillsTruncated = discoverableSkills.length > MAX_OPEN_WORKSPACE_SKILLS; + const visibleSkills = discoverableSkills + .slice(0, MAX_OPEN_WORKSPACE_SKILLS) .map((skill) => ({ name: skill.name, description: skill.description, - path: formatPathForPrompt(skill.filePath), + path: skill.locator, source: skill.source, })); + const skillSummary = summarizeSkills(workspace.skills); const loadedAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, @@ -667,6 +782,7 @@ function createMcpServer( path: formatAgentsPath(file.path, workspace.root), })); const collaboration = workspaceStore.getCollaborationMode(workspace.id); + const workflowDigest = workspaceStore.getWorkflowDigest(workspace.id); const instruction = workspaceInstruction(collaboration.mode, config.skillsEnabled); const resultContent: ToolContent[] = [ { @@ -682,8 +798,9 @@ function createMcpServer( ? `Available nested instructions: ${availableAgentsFileOutputs.map((file) => file.path).join(", ")}` : undefined, visibleSkills.length > 0 - ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` + ? `Available core and project skills: ${visibleSkills.map((skill) => skill.name).join(", ")}${skillsTruncated ? ` (showing first ${MAX_OPEN_WORKSPACE_SKILLS}; use search_skills for more)` : ""}` : undefined, + `Workflow: ${formatWorkflowDigest(workflowDigest)}`, instruction, ].filter(Boolean).join("\n"), }, @@ -707,8 +824,11 @@ function createMcpServer( summary: { agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, - skills: visibleSkills.length, + skills: skillSummary.total, + visibleSkills: visibleSkills.length, + skillsTruncated, skillDiagnostics: workspace.skillDiagnostics.length, + workflow: workflowDigest, }, }, }, @@ -721,9 +841,12 @@ function createMcpServer( agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, + skillsTruncated, skillDiagnostics: workspace.skillDiagnostics, instruction, collaborationMode: collaboration.mode, + workflowDigest, + skillSummary, }, }; }, @@ -738,7 +861,7 @@ function createMcpServer( "Resolve a skill name or alias such as /plan or /goal for the current workspace. This tool only reads and returns skill instructions; it does not execute installation, file changes, or commands.", inputSchema: { workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), - nameOrAlias: z.string().describe("Skill name or alias such as create-plan, define-goal, /plan, or /goal."), + nameOrAlias: z.string().describe("Skill name, qualifiedId, or alias such as devspace-plan, openai:.curated/define-goal, /plan, or /goal."), }, outputSchema: { result: z.string(), @@ -780,6 +903,7 @@ function createMcpServer( result: contentText(content), skill: { name: resolved.name, + qualifiedId: resolved.qualifiedId, source: resolved.source, path: resolved.path, alias: resolved.alias, @@ -799,6 +923,66 @@ function createMcpServer( }, ); + registerAppTool( + server, + "search_skills", + { + title: "Search skills", + description: + "Search available DevSpace, project, installed, global, and vendored OpenAI Skills without loading their full instructions. Resolve a returned qualifiedId only when the task needs that Skill.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + query: z.string().optional().describe("Case-insensitive name or description search."), + source: skillSourceOutputSchema.optional(), + limit: z.number().int().positive().max(50).optional(), + cursor: z.string().optional(), + }, + outputSchema: { + result: z.string(), + skills: z.array(z.object({ + qualifiedId: z.string(), + name: z.string(), + description: z.string(), + source: skillSourceOutputSchema, + locator: z.string(), + })), + nextCursor: z.string().optional(), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, query, source, limit, cursor }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + try { + const page = searchWorkspaceSkills(workspace.skills, { query, source, limit, cursor }); + const content = [textBlock( + page.skills.length === 0 + ? "No matching skills." + : page.skills.map((skill) => `${skill.qualifiedId} — ${skill.description}`).join("\n"), + )]; + logToolCall(config, { + tool: "search_skills", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + skills: page.skills, + nextCursor: page.nextCursor, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "search_skills", workspaceId }, response.content, startedAt); + return response; + } + }, + ); + registerAppTool( server, "get_collaboration_mode", @@ -1171,7 +1355,8 @@ function createMcpServer( if (parsed.kind === "plan") { const resolved = await resolveSkillDefinition(workspace.skills, "/plan"); - const content = [textBlock(`Resolved /plan to ${resolved.name} (${skillSourceLabel(resolved.source)}).`)]; + workspaceStore.setCollaborationMode({ workspaceSessionId: workspaceId, mode: "plan" }); + const content = [textBlock(`Resolved /plan to ${resolved.name} (${skillSourceLabel(resolved.source)}) and enabled plan mode.`)]; logToolCall(config, { tool: "handle_workspace_command", workspaceId, @@ -1186,6 +1371,7 @@ function createMcpServer( command: "plan" as const, skill: { name: resolved.name, + qualifiedId: resolved.qualifiedId, source: resolved.source, path: resolved.path, alias: resolved.alias, @@ -1213,6 +1399,7 @@ function createMcpServer( command: "goal" as const, skill: { name: resolved.name, + qualifiedId: resolved.qualifiedId, source: resolved.source, path: resolved.path, alias: resolved.alias, @@ -1594,82 +1781,118 @@ function createMcpServer( }, ); + registerAppTool( + server, + "get_plan", + { + title: "Get plan", + description: + "Get the current project-scoped Plan. Use this after opening a workspace or before changing a persisted plan so you have the latest revision.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + plan: workflowPlanOutputSchema.nullable(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const plan = workspaceStore.getPlan(workspaceId); + const content = [textBlock(plan ? formatPlanResult(plan) : "No current Plan for this project.")]; + logToolCall(config, { + tool: "get_plan", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + plan: plan ? toStructuredPlan(plan) : null, + }, + }; + }, + ); + registerAppTool( server, "update_plan", { title: "Update plan", description: - "Store or replace a workspace-scoped execution plan. Use this when the task benefits from a short checklist with pending, in-progress, and completed steps.", + "Create or update the current project-scoped Plan. Pass expectedRevision=0 to create a Plan; otherwise pass the revision returned by get_plan. This works in both default and plan mode.", inputSchema: { - workspaceId: z - .string() - .describe("Workspace identifier returned by open_workspace."), - explanation: z - .string() - .optional() - .describe("Optional short explanation for this plan update."), + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + expectedRevision: z.number().int().nonnegative().describe("0 creates a new Plan; otherwise the current Plan revision."), + title: z.string().optional(), + summary: z.string().optional(), + scope: workflowScopeOutputSchema.optional(), + validation: z.array(z.string()).optional(), + risks: z.array(z.string()).optional(), + status: z.enum(["draft", "active", "completed", "archived"]).optional(), + goalId: z.string().optional(), plan: z .array( z.object({ + id: z.string().optional(), step: z.string().describe("Concrete plan step."), - status: z.enum(["pending", "in_progress", "completed"]), + status: z.enum(["pending", "in_progress", "blocked", "completed", "skipped"]), + note: z.string().optional(), }), ) .min(1) - .describe("Current workspace plan. At most one step may be in_progress."), + .max(100) + .describe("The complete current Plan step list. At most one step may be in_progress."), }, outputSchema: { result: z.string(), - explanation: z.string().optional(), - plan: z.array( - z.object({ - step: z.string(), - status: z.enum(["pending", "in_progress", "completed"]), - }), - ), - updatedAt: z.string(), + plan: workflowPlanOutputSchema, }, ...toolWidgetDescriptorMeta(config, "plan"), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, - async ({ workspaceId, explanation, plan }) => { + async ({ workspaceId, expectedRevision, title, summary, scope, validation, risks, status, goalId, plan }) => { const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); - const collaboration = workspaceStore.getCollaborationMode(workspaceId); - if (collaboration.mode === "plan") { - const response = toolError("update_plan is unavailable while the workspace is in plan mode. Use request_user_input, repository exploration, and concrete planning instead."); - logFailedToolResponse(config, { + try { + validatePlanSteps(plan); + const saved = workspaceStore.savePlan({ + workspaceSessionId: workspaceId, + expectedRevision, + title, + summary, + scopeIn: scope?.in, + scopeOut: scope?.out, + validation, + risks, + status, + goalId, + steps: plan, + }); + const content = [textBlock(formatPlanResult(saved))]; + logToolCall(config, { tool: "update_plan", workspaceId, - }, response.content, startedAt); + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + plan: toStructuredPlan(saved), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "update_plan", workspaceId }, response.content, startedAt); return response; } - - validatePlanSteps(plan); - const saved = workspaceStore.savePlan({ - workspaceSessionId: workspaceId, - explanation, - steps: plan, - }); - const content = [textBlock(formatPlanResult(saved.steps, saved.explanation))]; - - logToolCall(config, { - tool: "update_plan", - workspaceId, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - content, - structuredContent: { - result: contentText(content), - explanation: saved.explanation, - plan: saved.steps, - updatedAt: saved.updatedAt, - }, - }; }, ); @@ -1679,30 +1902,13 @@ function createMcpServer( { title: "Get goal", description: - "Get the current workspace-scoped goal if one exists, including objective, status, and timestamps.", + "Get the current project-scoped Goal, including its scope, acceptance criteria, verification, summary, status, and revision.", inputSchema: { - workspaceId: z - .string() - .describe("Workspace identifier returned by open_workspace."), + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), }, outputSchema: { result: z.string(), - goal: z - .object({ - objective: z.string(), - scope: goalDefinitionOutputSchema.shape.scope, - verification: goalDefinitionOutputSchema.shape.verification, - stopConditions: goalDefinitionOutputSchema.shape.stopConditions, - legacy: z.boolean().optional(), - status: z.enum(["active", "complete", "blocked"]), - tokenBudget: z.number().int().positive().optional(), - createdAt: z.string(), - updatedAt: z.string(), - timeUsedSeconds: z.number().int().nonnegative(), - completedAt: z.string().optional(), - blockedAt: z.string().optional(), - }) - .nullable(), + goal: workflowGoalOutputSchema.nullable(), }, ...toolWidgetDescriptorMeta(config, "goal"), annotations: { readOnlyHint: true }, @@ -1711,36 +1917,18 @@ function createMcpServer( const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); const goal = workspaceStore.getGoal(workspaceId); - const parsedGoal = goal ? parseGoalDefinition(goal.objective) : null; - const content = [textBlock(goal ? formatGoalResult(goal) : "No active or historical goal for this workspace.")]; - + const content = [textBlock(goal ? formatGoalResult(goal) : "No current Goal for this project.")]; logToolCall(config, { tool: "get_goal", workspaceId, success: true, durationMs: Math.round(performance.now() - startedAt), }); - return { content, structuredContent: { result: contentText(content), - goal: goal - ? { - objective: parsedGoal?.definition.objective ?? goal.objective, - scope: parsedGoal?.definition.scope, - verification: parsedGoal?.definition.verification, - stopConditions: parsedGoal?.definition.stopConditions, - legacy: parsedGoal?.legacy, - status: goal.status, - tokenBudget: goal.tokenBudget, - createdAt: goal.createdAt, - updatedAt: goal.updatedAt, - timeUsedSeconds: goal.timeUsedSeconds, - completedAt: goal.completedAt, - blockedAt: goal.blockedAt, - } - : null, + goal: goal ? toStructuredGoal(goal) : null, }, }; }, @@ -1752,79 +1940,62 @@ function createMcpServer( { title: "Create goal", description: - "Create a new workspace-scoped goal. Fails if an active goal already exists for that workspace.", + "Create a new current Goal for this project. It fails when an active Goal already exists; inspect and explicitly update or archive that Goal first.", inputSchema: { - workspaceId: z - .string() - .describe("Workspace identifier returned by open_workspace."), + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), objective: z.string().describe("Concrete objective to pursue."), - scope: goalDefinitionOutputSchema.shape.scope.optional(), - verification: goalDefinitionOutputSchema.shape.verification, - stopConditions: goalDefinitionOutputSchema.shape.stopConditions, - tokenBudget: z - .number() - .int() - .positive() - .optional() - .describe("Optional positive token budget for the goal."), + scope: workflowScopeOutputSchema.optional(), + successCriteria: z.array(z.string()).optional(), + verification: z.array(z.string()).optional(), + stopConditions: z.array(z.string()).optional(), + currentSummary: z.string().optional(), }, outputSchema: { result: z.string(), - goal: z.object({ - objective: z.string(), - scope: goalDefinitionOutputSchema.shape.scope, - verification: goalDefinitionOutputSchema.shape.verification, - stopConditions: goalDefinitionOutputSchema.shape.stopConditions, - status: z.literal("active"), - tokenBudget: z.number().int().positive().optional(), - createdAt: z.string(), - updatedAt: z.string(), - timeUsedSeconds: z.number().int().nonnegative(), - }), + goal: workflowGoalOutputSchema, }, ...toolWidgetDescriptorMeta(config, "goal"), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, - async ({ workspaceId, objective, scope, verification, stopConditions, tokenBudget }) => { + async ({ workspaceId, objective, scope, successCriteria, verification, stopConditions, currentSummary }) => { const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); - const definition = normalizeGoalDefinition({ - objective, - scope, - verification, - stopConditions, - }); - const goal = workspaceStore.saveGoal({ - workspaceSessionId: workspaceId, - objective: serializeGoalDefinition(definition), - tokenBudget, - }); - const content = [textBlock(formatGoalResult(goal))]; - - logToolCall(config, { - tool: "create_goal", - workspaceId, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - content, - structuredContent: { - result: contentText(content), - goal: { - objective: definition.objective, - scope: definition.scope, - verification: definition.verification, - stopConditions: definition.stopConditions, - status: goal.status, - tokenBudget: goal.tokenBudget, - createdAt: goal.createdAt, - updatedAt: goal.updatedAt, - timeUsedSeconds: goal.timeUsedSeconds, + try { + const definition = normalizeGoalDefinition({ + objective, + scope, + verification, + stopConditions, + }); + const goal = workspaceStore.saveGoal({ + workspaceSessionId: workspaceId, + objective: definition.objective, + scopeIn: definition.scope?.in, + scopeOut: definition.scope?.out, + successCriteria, + verification: definition.verification, + stopConditions: definition.stopConditions, + currentSummary, + }); + const content = [textBlock(formatGoalResult(goal))]; + logToolCall(config, { + tool: "create_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + goal: toStructuredGoal(goal), }, - }, - }; + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "create_goal", workspaceId }, response.content, startedAt); + return response; + } }, ); @@ -1834,90 +2005,285 @@ function createMcpServer( { title: "Update goal", description: - "Update the current workspace-scoped goal with lightweight objective, scope, verification, or status changes.", + "Update the current project-scoped Goal. Pass the revision returned by get_goal to prevent another session from silently overwriting this Goal.", inputSchema: { - workspaceId: z - .string() - .describe("Workspace identifier returned by open_workspace."), + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + expectedRevision: z.number().int().positive(), objective: z.string().optional(), - scope: goalDefinitionOutputSchema.shape.scope.optional(), - verification: goalDefinitionOutputSchema.shape.verification, - stopConditions: goalDefinitionOutputSchema.shape.stopConditions, - status: z.enum(["active", "complete", "blocked"]).optional(), + scope: workflowScopeOutputSchema.optional(), + successCriteria: z.array(z.string()).optional(), + verification: z.array(z.string()).optional(), + stopConditions: z.array(z.string()).optional(), + currentSummary: z.string().optional(), + status: z.enum(["active", "blocked", "completed", "archived"]).optional(), }, outputSchema: { result: z.string(), - goal: z.object({ - objective: z.string(), - scope: goalDefinitionOutputSchema.shape.scope, - verification: goalDefinitionOutputSchema.shape.verification, - stopConditions: goalDefinitionOutputSchema.shape.stopConditions, - legacy: z.boolean().optional(), - status: z.enum(["active", "complete", "blocked"]), - tokenBudget: z.number().int().positive().optional(), - createdAt: z.string(), - updatedAt: z.string(), - timeUsedSeconds: z.number().int().nonnegative(), - completedAt: z.string().optional(), - blockedAt: z.string().optional(), - }), + goal: workflowGoalOutputSchema, }, ...toolWidgetDescriptorMeta(config, "goal"), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, - async ({ workspaceId, objective, scope, verification, stopConditions, status }) => { + async ({ workspaceId, expectedRevision, objective, scope, successCriteria, verification, stopConditions, currentSummary, status }) => { const startedAt = performance.now(); workspaces.getWorkspace(workspaceId); - const existing = workspaceStore.getGoal(workspaceId); - if (!existing) { - const response = toolError("No goal exists for this workspace."); - logFailedToolResponse(config, { + try { + const goal = workspaceStore.updateGoal({ + workspaceSessionId: workspaceId, + expectedRevision, + objective, + scopeIn: scope?.in, + scopeOut: scope?.out, + successCriteria, + verification, + stopConditions, + currentSummary, + status, + }); + const content = [textBlock(formatGoalResult(goal))]; + logToolCall(config, { tool: "update_goal", workspaceId, - }, response.content, startedAt); + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + goal: toStructuredGoal(goal), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "update_goal", workspaceId }, response.content, startedAt); return response; } - const current = parseGoalDefinition(existing.objective); - const definition = normalizeGoalDefinition({ - objective: objective ?? current.definition.objective, - scope: scope ?? current.definition.scope, - verification: verification ?? current.definition.verification, - stopConditions: stopConditions ?? current.definition.stopConditions, - }); - const goal = workspaceStore.updateGoal({ - workspaceSessionId: workspaceId, - objective: serializeGoalDefinition(definition), - status, - }); - const content = [textBlock(formatGoalResult(goal, status === "complete"))]; + }, + ); - logToolCall(config, { - tool: "update_goal", - workspaceId, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); + registerAppTool( + server, + "start_goal_work", + { + title: "Start goal work timer", + description: + "Start the server-authoritative work timer for the active Goal. The duration measures only wall-clock time while this explicit timer is running; it is not inferred from chat activity.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + started: z.boolean(), + metrics: goalMetricsOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + const result = workspaceStore.startGoalWork({ workspaceSessionId: workspaceId }); + const content = [textBlock( + result.started + ? `Started Goal work timer. Exact tracked duration is now ${result.metrics.workDuration.totalMilliseconds} ms.` + : `Goal work timer is already running since ${result.metrics.workDuration.startedAt ?? "an unknown time"}.`, + )]; + logToolCall(config, { + tool: "start_goal_work", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + started: result.started, + metrics: result.metrics, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "start_goal_work", workspaceId }, response.content, startedAt); + return response; + } + }, + ); - return { - content, - structuredContent: { - result: contentText(content), - goal: { - objective: definition.objective, - scope: definition.scope, - verification: definition.verification, - stopConditions: definition.stopConditions, - legacy: false, - status: goal.status, - tokenBudget: goal.tokenBudget, - createdAt: goal.createdAt, - updatedAt: goal.updatedAt, - timeUsedSeconds: goal.timeUsedSeconds, - completedAt: goal.completedAt, - blockedAt: goal.blockedAt, + registerAppTool( + server, + "pause_goal_work", + { + title: "Pause goal work timer", + description: + "Pause the server-authoritative work timer for the current Goal and persist the exact elapsed milliseconds. This is safe to call when the timer is already paused.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + paused: z.boolean(), + metrics: goalMetricsOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + const result = workspaceStore.pauseGoalWork({ workspaceSessionId: workspaceId }); + const content = [textBlock( + result.paused + ? `Paused Goal work timer at ${result.metrics.workDuration.totalMilliseconds} ms.` + : `Goal work timer was already paused at ${result.metrics.workDuration.totalMilliseconds} ms.`, + )]; + logToolCall(config, { + tool: "pause_goal_work", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + paused: result.paused, + metrics: result.metrics, }, - }, - }; + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "pause_goal_work", workspaceId }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "record_goal_token_usage", + { + title: "Record provider token usage", + description: + "Append exact provider-reported token usage to the current Goal. Call only with counts and request IDs returned by the model provider or API; never estimate tokens from text, timing, or context length. Duplicate provider request IDs are ignored.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + provider: z.string().min(1).max(512).describe("Provider that returned the usage record."), + providerRequestId: z.string().min(1).max(2048).describe("Stable provider request or response ID used for deduplication."), + model: z.string().max(512).optional(), + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + reasoningTokens: z.number().int().nonnegative().optional(), + totalTokens: z.number().int().nonnegative().describe("Exact total reported by the provider."), + providerReportedAt: z.string().datetime().optional(), + }, + outputSchema: { + result: z.string(), + recorded: z.boolean(), + metrics: goalMetricsOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async ({ workspaceId, provider, providerRequestId, model, inputTokens, outputTokens, reasoningTokens, totalTokens, providerReportedAt }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + const result = workspaceStore.recordGoalTokenUsage({ + workspaceSessionId: workspaceId, + provider, + providerRequestId, + model, + inputTokens, + outputTokens, + reasoningTokens, + totalTokens, + providerReportedAt, + }); + const content = [textBlock( + result.recorded + ? `Recorded exact provider-reported usage. Goal total is ${result.metrics.tokenUsage.totalTokens} tokens across ${result.metrics.tokenUsage.reportCount} reports.` + : "This provider request ID was already recorded; Goal token totals were not changed.", + )]; + logToolCall(config, { + tool: "record_goal_token_usage", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + recorded: result.recorded, + metrics: result.metrics, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "record_goal_token_usage", workspaceId }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "get_workflow_history", + { + title: "Get workflow history", + description: + "Read concise project workflow events without loading Plan, Goal, chat, or tool-output history. Results are paginated and capped at 50 events.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + limit: z.number().int().positive().max(50).optional(), + cursor: z.string().optional(), + }, + outputSchema: { + result: z.string(), + events: z.array(z.object({ + id: z.string(), + projectWorkflowKey: z.string(), + entityType: z.enum(["plan", "goal", "mode"]), + entityId: z.string(), + eventType: z.string(), + summary: z.string(), + revision: z.number().int().positive().optional(), + createdAt: z.string(), + })), + nextCursor: z.string().optional(), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, limit, cursor }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + const history = workspaceStore.getWorkflowHistory({ workspaceSessionId: workspaceId, limit, cursor }); + const content = [textBlock(formatWorkflowHistory(history.events))]; + logToolCall(config, { + tool: "get_workflow_history", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + events: history.events, + nextCursor: history.nextCursor, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "get_workflow_history", workspaceId }, response.content, startedAt); + return response; + } }, ); @@ -2927,11 +3293,68 @@ function validateSubmittedAnswers( } } -function formatPlanResult(steps: WorkspacePlanStep[], explanation: string | undefined): string { - const summary = steps - .map((step) => `${step.status === "completed" ? "[done]" : step.status === "in_progress" ? "[doing]" : "[todo]"} ${step.step}`) - .join("\n"); - return explanation ? `${explanation}\n${summary}` : summary; +function formatPlanResult(plan: WorkspacePlan): string { + const lines = [ + `Plan: ${plan.title}`, + plan.summary, + `Status: ${plan.status} (revision ${plan.revision})`, + plan.scopeIn.length || plan.scopeOut.length + ? `Scope: In(${plan.scopeIn.join("; ") || "none"}) / Out(${plan.scopeOut.join("; ") || "none"})` + : undefined, + plan.validation.length ? `Validation: ${plan.validation.join("; ")}` : undefined, + plan.risks.length ? `Risks: ${plan.risks.join("; ")}` : undefined, + ...plan.steps.map((step) => `${planStepMarker(step.status)} ${step.step}${step.note ? ` — ${step.note}` : ""}`), + ]; + return lines.filter(Boolean).join("\n"); +} + +function planStepMarker(status: WorkspacePlanStep["status"]): string { + switch (status) { + case "completed": + return "[done]"; + case "in_progress": + return "[doing]"; + case "blocked": + return "[blocked]"; + case "skipped": + return "[skipped]"; + default: + return "[todo]"; + } +} + +function toStructuredPlan(plan: WorkspacePlan): { + id: string; + projectWorkflowKey: string; + goalId?: string; + title: string; + summary?: string; + scope: { in: string[]; out: string[] }; + validation: string[]; + risks: string[]; + status: WorkspacePlan["status"]; + revision: number; + steps: WorkspacePlanStep[]; + createdAt: string; + updatedAt: string; + archivedAt?: string; +} { + return { + id: plan.id, + projectWorkflowKey: plan.projectWorkflowKey, + goalId: plan.goalId, + title: plan.title, + summary: plan.summary, + scope: { in: plan.scopeIn, out: plan.scopeOut }, + validation: plan.validation, + risks: plan.risks, + status: plan.status, + revision: plan.revision, + steps: plan.steps, + createdAt: plan.createdAt, + updatedAt: plan.updatedAt, + archivedAt: plan.archivedAt, + }; } function toElicitationSchema(questions: WorkspaceQuestion[]): { @@ -3001,36 +3424,145 @@ function summarizeSubmittedAnswers( .join("\n"); } -function formatGoalResult(goal: { - objective: string; - status: "active" | "complete" | "blocked"; - tokenBudget?: number; - createdAt: string; - updatedAt: string; - timeUsedSeconds: number; - completedAt?: string; - blockedAt?: string; -}, includeCompletionNote = false): string { - const parsed = parseGoalDefinition(goal.objective); +function formatGoalResult(goal: WorkspaceGoal): string { const lines = [ - `Goal: ${parsed.definition.objective}`, - parsed.definition.scope - ? `Scope: In(${parsed.definition.scope.in.join("; ") || "none"}) / Out(${parsed.definition.scope.out.join("; ") || "none"})` + `Goal: ${goal.objective}`, + goal.scopeIn.length || goal.scopeOut.length + ? `Scope: In(${goal.scopeIn.join("; ") || "none"}) / Out(${goal.scopeOut.join("; ") || "none"})` : undefined, - parsed.definition.verification?.length - ? `Verification: ${parsed.definition.verification.join("; ")}` - : undefined, - `Status: ${goal.status}`, - goal.tokenBudget !== undefined ? `Token budget: ${goal.tokenBudget}` : undefined, - `Time used seconds: ${goal.timeUsedSeconds}`, - goal.completedAt ? `Completed: ${goal.completedAt}` : undefined, - goal.blockedAt ? `Blocked: ${goal.blockedAt}` : undefined, - includeCompletionNote ? "Report the final budget and usage summary back to the user if the host tracks it." : undefined, + goal.successCriteria.length ? `Success criteria: ${goal.successCriteria.join("; ")}` : undefined, + goal.verification.length ? `Verification: ${goal.verification.join("; ")}` : undefined, + goal.stopConditions.length ? `Stop conditions: ${goal.stopConditions.join("; ")}` : undefined, + goal.currentSummary ? `Current summary: ${goal.currentSummary}` : undefined, + `Status: ${goal.status} (revision ${goal.revision})`, + `Provider-reported tokens: ${goal.metrics.tokenUsage.totalTokens} across ${goal.metrics.tokenUsage.reportCount} reports`, + `Exact work timer: ${goal.metrics.workDuration.totalMilliseconds} ms${goal.metrics.workDuration.running ? " (running)" : ""}`, + goal.metrics.progress.source === "linked_plan_steps" + ? `Plan progress: ${goal.metrics.progress.displayPercent} (${goal.metrics.progress.exactFraction}; exact % = ${goal.metrics.progress.percentageNumerator}/${goal.metrics.progress.percentageDenominator})` + : "Plan progress: unavailable until a current Plan is explicitly linked to this Goal", ]; - return lines.filter(Boolean).join("\n"); } +function toStructuredGoal(goal: WorkspaceGoal): { + id: string; + projectWorkflowKey: string; + objective: string; + scope: { in: string[]; out: string[] }; + successCriteria: string[]; + verification: string[]; + stopConditions: string[]; + currentSummary?: string; + status: WorkspaceGoal["status"]; + revision: number; + metrics: WorkspaceGoal["metrics"]; + createdAt: string; + updatedAt: string; + archivedAt?: string; +} { + return { + id: goal.id, + projectWorkflowKey: goal.projectWorkflowKey, + objective: goal.objective, + scope: { in: goal.scopeIn, out: goal.scopeOut }, + successCriteria: goal.successCriteria, + verification: goal.verification, + stopConditions: goal.stopConditions, + currentSummary: goal.currentSummary, + status: goal.status, + revision: goal.revision, + metrics: goal.metrics, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + archivedAt: goal.archivedAt, + }; +} + +function formatWorkflowDigest(digest: WorkflowDigest): string { + const goal = digest.goalStatus + ? `Goal ${digest.goalStatus}${digest.goalTitle ? `: ${digest.goalTitle}` : ""}` + : "No current Goal"; + const plan = digest.planStatus + ? `Plan ${digest.planStatus} r${digest.planRevision ?? 0}${digest.steps ? ` (${digest.steps.completed}/${digest.steps.total} complete)` : ""}` + : "No current Plan"; + return `${goal}; ${plan}.`; +} + +function formatWorkflowHistory(events: Array<{ + createdAt: string; + eventType: string; + summary: string; +}>): string { + if (events.length === 0) return "No workflow history for this project."; + return events.map((event) => `${event.createdAt} ${event.eventType}: ${event.summary}`).join("\n"); +} + +function summarizeSkills(skills: Array<{ source: SkillSource }>): { + total: number; + bySource: Record; +} { + const bySource: Record = { + devspace_system: 0, + local: 0, + legacy_core: 0, + installed: 0, + official_vendored: 0, + global: 0, + }; + for (const skill of skills) bySource[skill.source]++; + return { total: skills.length, bySource }; +} + +function searchWorkspaceSkills( + skills: DevSpaceSkill[], + input: { + query?: string; + source?: SkillSource; + limit?: number; + cursor?: string; + }, +): { + skills: Array<{ + qualifiedId: string; + name: string; + description: string; + source: SkillSource; + locator: string; + }>; + nextCursor?: string; +} { + const query = input.query?.trim().toLocaleLowerCase(); + const matching = skills + .filter((skill) => !input.source || skill.source === input.source) + .filter((skill) => { + if (!query) return true; + return [skill.qualifiedId, skill.name, skill.description] + .join("\n") + .toLocaleLowerCase() + .includes(query); + }) + .sort((left, right) => left.qualifiedId.localeCompare(right.qualifiedId)); + + const start = input.cursor === undefined ? 0 : Number.parseInt(input.cursor, 10); + if (!Number.isSafeInteger(start) || start < 0 || start > matching.length) { + throw new Error("Invalid skills search cursor."); + } + const limit = Math.max(1, Math.min(input.limit ?? 20, 50)); + const page = matching.slice(start, start + limit); + const nextOffset = start + page.length; + + return { + skills: page.map((skill) => ({ + qualifiedId: skill.qualifiedId, + name: skill.name, + description: skill.description, + source: skill.source, + locator: skill.locator, + })), + nextCursor: nextOffset < matching.length ? String(nextOffset) : undefined, + }; +} + function formatUserInputRecordResult(record: WorkspaceUserInputRecord): string { const lines = [ `Status: ${record.status}`, diff --git a/src/skill-manager.test.ts b/src/skill-manager.test.ts index d4d44a8..ac89a1e 100644 --- a/src/skill-manager.test.ts +++ b/src/skill-manager.test.ts @@ -21,7 +21,7 @@ try { const agentDir = join(root, "agent"); const localSkill = join(root, "local-installed-skill"); const remoteRepo = join(root, "remote-skill-repo"); - const conflictingLocal = join(root, "create-plan"); + const conflictingLocal = join(root, "devspace-plan"); const invalidDirSkill = join(root, "mismatched-dir"); const symlinkSkill = join(root, "symlink-skill"); const pluginLikeRoot = join(root, "plugin-like-root"); @@ -68,7 +68,7 @@ try { join(conflictingLocal, "SKILL.md"), [ "---", - "name: create-plan", + "name: devspace-plan", "description: Should conflict with system skill.", "---", "", @@ -191,7 +191,7 @@ try { source: { kind: "local", path: conflictingLocal }, localPathResolver: (path) => path, }), - /系统内置/, + /DevSpace 核心/, ); await assert.rejects( diff --git a/src/skill-manager.ts b/src/skill-manager.ts index 0a0b511..19c9dd2 100644 --- a/src/skill-manager.ts +++ b/src/skill-manager.ts @@ -283,7 +283,12 @@ async function assertInstallConflicts( const existing = loaded.skills.find((skill) => skill.name === skillName); if (!existing) return; - if (existing.source === "system" || existing.source === "local") { + if ( + existing.source === "devspace_system" || + existing.source === "official_vendored" || + existing.source === "local" || + existing.source === "legacy_core" + ) { throw new Error( `Skill ${skillName} conflicts with an existing ${skillSourceLabel(existing.source)} skill.`, ); diff --git a/src/skills.test.ts b/src/skills.test.ts index 30cb0ea..111f0d2 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -4,8 +4,8 @@ import { join } from "node:path"; import assert from "node:assert/strict"; import { loadConfig } from "./config.js"; import { - formatPathForPrompt, loadWorkspaceSkills, + markSkillActivated, resolveSkillDefinition, resolveSkillReadPath, } from "./skills.js"; @@ -16,115 +16,47 @@ try { const projectRoot = join(root, "project"); const agentDir = join(root, "agent"); const explicitSkills = join(root, "explicit-skills"); - await mkdir(join(projectRoot, "skills", "local", "project-skill"), { recursive: true }); - await mkdir(join(projectRoot, "skills", "installed", "installed-skill"), { recursive: true }); - await mkdir(join(projectRoot, "skills", "core", "skill-authoring-lite"), { recursive: true }); - await mkdir(join(projectRoot, "skills", "local", "duplicate-priority-skill"), { recursive: true }); - await mkdir(join(projectRoot, "skills", "installed", "duplicate-priority-skill"), { recursive: true }); - await mkdir(join(agentDir, "skills", "duplicate-priority-skill"), { recursive: true }); - await mkdir(join(agentDir, "skills", "global-only-skill"), { recursive: true }); - await mkdir(join(projectRoot, "skills", "local", "create-plan"), { recursive: true }); - await mkdir(join(explicitSkills, "external-global-skill"), { recursive: true }); - await writeFile( - join(projectRoot, "skills", "local", "project-skill", "SKILL.md"), - [ - "---", - "name: project-skill", - "description: Project skill description.", - "---", - "", - "# Project Skill", - ].join("\n"), - ); - await writeFile( - join(projectRoot, "skills", "installed", "installed-skill", "SKILL.md"), - [ - "---", - "name: installed-skill", - "description: Installed skill description.", - "---", - "", - "# Installed Skill", - ].join("\n"), - ); - await writeFile( - join(projectRoot, "skills", "core", "skill-authoring-lite", "SKILL.md"), - [ - "---", - "name: skill-authoring-lite", - "description: Legacy core should lose to system.", - "---", - "", - "# Legacy Core Skill", - ].join("\n"), - ); - await writeFile( - join(projectRoot, "skills", "local", "duplicate-priority-skill", "SKILL.md"), - [ - "---", - "name: duplicate-priority-skill", - "description: Local wins over installed and global.", - "---", - "", - "# Duplicate Local", - ].join("\n"), - ); - await writeFile( - join(projectRoot, "skills", "installed", "duplicate-priority-skill", "SKILL.md"), - [ - "---", - "name: duplicate-priority-skill", - "description: Installed loses to local.", - "---", - "", - "# Duplicate Installed", - ].join("\n"), - ); - await writeFile( - join(agentDir, "skills", "duplicate-priority-skill", "SKILL.md"), - [ - "---", - "name: duplicate-priority-skill", - "description: Global loses to local and installed.", - "---", - "", - "# Duplicate Global", - ].join("\n"), - ); - await writeFile( - join(agentDir, "skills", "global-only-skill", "SKILL.md"), - [ - "---", - "name: global-only-skill", - "description: Global-only skill.", - "---", - "", - "# Global Only Skill", - ].join("\n"), - ); - await writeFile( - join(projectRoot, "skills", "local", "create-plan", "SKILL.md"), - [ - "---", - "name: create-plan", - "description: Local create-plan should lose to system.", - "---", - "", - "# Local Create Plan", - ].join("\n"), - ); - await writeFile( - join(explicitSkills, "external-global-skill", "SKILL.md"), - [ - "---", - "name: external-global-skill", - "description: External skill paths map into global source semantics.", - "---", - "", - "# External Global Skill", - ].join("\n"), - ); + await writeSkill(join(projectRoot, "skills", "local", "project-skill"), { + name: "project-skill", + description: "Project-local Skill.", + body: "# Project Skill", + }); + await writeSkill(join(projectRoot, "skills", "installed", "installed-skill"), { + name: "installed-skill", + description: "Installed Skill.", + body: "# Installed Skill", + }); + await writeSkill(join(projectRoot, "skills", "core", "duplicate-priority-skill"), { + name: "duplicate-priority-skill", + description: "Legacy core loses to local.", + body: "# Legacy Duplicate", + }); + await writeSkill(join(projectRoot, "skills", "local", "duplicate-priority-skill"), { + name: "duplicate-priority-skill", + description: "Local wins over legacy, installed, and global.", + body: "# Local Duplicate", + }); + await writeSkill(join(projectRoot, "skills", "installed", "duplicate-priority-skill"), { + name: "duplicate-priority-skill", + description: "Installed loses to local.", + body: "# Installed Duplicate", + }); + await writeSkill(join(projectRoot, "skills", "local", "devspace-plan"), { + name: "devspace-plan", + description: "Attempted local override that must lose.", + body: "# Local Plan Override", + }); + await writeSkill(join(agentDir, "skills", "global-only-skill"), { + name: "global-only-skill", + description: "Global Skill.", + body: "# Global Skill", + }); + await writeSkill(join(explicitSkills, "external-global-skill"), { + name: "external-global-skill", + description: "Explicit global Skill path.", + body: "# Explicit Global Skill", + }); const disabledConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, @@ -148,54 +80,78 @@ try { assert.equal(loaded.skills.some((skill) => skill.name === "project-skill" && skill.source === "local"), true); assert.equal(loaded.skills.some((skill) => skill.name === "installed-skill" && skill.source === "installed"), true); assert.equal(loaded.skills.some((skill) => skill.name === "global-only-skill" && skill.source === "global"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "create-plan" && skill.source === "system"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "define-goal" && skill.source === "system"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "skill-authoring-lite" && skill.source === "system"), true); assert.equal(loaded.skills.some((skill) => skill.name === "external-global-skill" && skill.source === "global"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "devspace-plan" && skill.source === "devspace_system"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "devspace-goal" && skill.source === "devspace_system"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "devspace-workflow" && skill.source === "devspace_system"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "senior-architect" && skill.source === "devspace_system"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "skill-authoring" && skill.source === "devspace_system"), true); - const duplicatePrioritySkill = loaded.skills.find((skill) => skill.name === "duplicate-priority-skill"); - assert.ok(duplicatePrioritySkill); - assert.equal(duplicatePrioritySkill.source, "local"); - assert.match(duplicatePrioritySkill.filePath, /skills\/local\/duplicate-priority-skill\/SKILL\.md$/); - - const legacySystemSkill = loaded.skills.find((skill) => skill.name === "skill-authoring-lite"); - assert.ok(legacySystemSkill); - assert.doesNotMatch(legacySystemSkill.filePath, /skills\/core\/skill-authoring-lite\/SKILL\.md$/); - assert.equal(loaded.diagnostics.some((diagnostic) => String(diagnostic.message).includes("skills/core is deprecated")), true); - assert.equal(loaded.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true); - - const projectSkill = loaded.skills.find((skill) => skill.name === "project-skill"); - assert.ok(projectSkill); - assert.match(formatPathForPrompt(projectSkill.filePath), /SKILL\.md$/); - - const skillFileRead = resolveSkillReadPath(loaded.skills, new Set(), projectSkill.filePath); - assert.equal(skillFileRead?.isSkillFile, true); - assert.equal(skillFileRead?.absolutePath, projectSkill.filePath); - - const resourcePath = join(projectSkill.baseDir, "references.md"); - await writeFile(resourcePath, "reference\n"); - assert.equal(resolveSkillReadPath(loaded.skills, new Set(), resourcePath), undefined); + const duplicate = loaded.skills.find((skill) => skill.name === "duplicate-priority-skill"); + assert.ok(duplicate); + assert.equal(duplicate.source, "local"); + assert.match(duplicate.filePath, /skills\/local\/duplicate-priority-skill\/SKILL\.md$/); assert.equal( - resolveSkillReadPath(loaded.skills, new Set([projectSkill.baseDir]), resourcePath)?.isSkillFile, - false, + loaded.diagnostics.some((diagnostic) => String(diagnostic.message).includes("skills/core is deprecated")), + true, ); + const plan = loaded.skills.find((skill) => skill.name === "devspace-plan"); + assert.ok(plan); + assert.equal(plan.source, "devspace_system"); + assert.doesNotMatch(plan.filePath, /skills\/local\/devspace-plan\/SKILL\.md$/); + assert.match(plan.locator, /^skill:\/\/devspace-system\/devspace-plan\/SKILL\.md$/); + const resolvedPlan = await resolveSkillDefinition(loaded.skills, "/plan"); - assert.equal(resolvedPlan.name, "create-plan"); - assert.equal(resolvedPlan.source, "system"); + assert.equal(resolvedPlan.name, "devspace-plan"); + assert.equal(resolvedPlan.qualifiedId, "devspace-plan"); + assert.equal(resolvedPlan.source, "devspace_system"); assert.equal(resolvedPlan.alias, "/plan"); assert.equal(resolvedPlan.mode, "read_only"); - assert.match(resolvedPlan.instructions, /# Create Plan/); + assert.match(resolvedPlan.instructions, /# DevSpace Plan Workflow/); + assert.match(resolvedPlan.path, /^skill:\/\//); const resolvedGoal = await resolveSkillDefinition(loaded.skills, "/goal"); - assert.equal(resolvedGoal.name, "define-goal"); - assert.equal(resolvedGoal.source, "system"); + assert.equal(resolvedGoal.name, "devspace-goal"); + assert.equal(resolvedGoal.source, "devspace_system"); assert.equal(resolvedGoal.alias, "/goal"); assert.equal(resolvedGoal.mode, "normal"); + assert.match(resolvedGoal.instructions, /# DevSpace Goal Workflow/); + + const skillFileRead = resolveSkillReadPath(loaded.skills, new Set(), resolvedPlan.path); + assert.equal(skillFileRead?.isSkillFile, true); + assert.equal(skillFileRead?.skill.name, "devspace-plan"); - const resolvedExplicit = await resolveSkillDefinition(loaded.skills, "global-only-skill"); - assert.equal(resolvedExplicit.name, "global-only-skill"); + const resourceLocator = resolvedPlan.path.replace("SKILL.md", "references/plan-state.md"); + assert.equal(resolveSkillReadPath(loaded.skills, new Set(), resourceLocator), undefined); + const activated = new Set(); + markSkillActivated(activated, resolvedPlan.skill); + assert.equal( + resolveSkillReadPath(loaded.skills, activated, resourceLocator)?.isSkillFile, + false, + ); + + const resolvedExplicit = await resolveSkillDefinition(loaded.skills, "external-global-skill"); assert.equal(resolvedExplicit.source, "global"); } finally { await rm(root, { recursive: true, force: true }); } + +async function writeSkill( + directory: string, + input: { name: string; description: string; body: string }, +): Promise { + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "SKILL.md"), + [ + "---", + `name: ${input.name}`, + `description: ${input.description}`, + "---", + "", + input.body, + "", + ].join("\n"), + ); +} diff --git a/src/skills.ts b/src/skills.ts index c69709d..996eab9 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,6 +1,7 @@ +import { existsSync, readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, resolve, sep } from "node:path"; +import { dirname, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { loadSkills, @@ -11,11 +12,19 @@ import { import type { ServerConfig } from "./config.js"; import { expandHomePath, isPathInsideRoot } from "./roots.js"; -export type SkillSource = "system" | "local" | "installed" | "global"; +export type SkillSource = + | "devspace_system" + | "local" + | "legacy_core" + | "installed" + | "official_vendored" + | "global"; export type SkillResolveMode = "read_only" | "normal"; export interface DevSpaceSkill extends Skill { source: SkillSource; + qualifiedId: string; + locator: string; aliases?: string[]; resolveMode: SkillResolveMode; legacyCore?: boolean; @@ -34,6 +43,7 @@ export interface SkillReadResolution { export interface ResolvedSkillDefinition { name: string; + qualifiedId: string; source: SkillSource; path: string; alias?: string; @@ -47,6 +57,11 @@ interface SkillBatch { diagnostics: LoadSkillsResult["diagnostics"]; } +interface SkillSourceOptions { + legacyCore?: boolean; + qualifiedPrefix?: string; +} + const PLAN_ALIAS = "/plan"; const GOAL_ALIAS = "/goal"; @@ -54,10 +69,11 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; const batches: SkillBatch[] = [ - loadSkillsFromSourceDir(bundledSystemSkillPath(), "system"), - loadSkillsFromSourceDir(legacyWorkspaceCorePath(cwd), "system", { legacyCore: true }), + ...loadDevSpaceSystemSkillBatches(), loadSkillsFromSourceDir(workspaceLocalSkillPath(cwd), "local"), + loadSkillsFromSourceDir(legacyWorkspaceCorePath(cwd), "legacy_core", { legacyCore: true }), loadSkillsFromSourceDir(workspaceInstalledSkillPath(cwd), "installed"), + ...loadOfficialVendoredSkillBatches(), loadSkillsFromSourceDir(globalSkillPath(config.agentDir), "global"), loadExplicitSkillPaths(config, cwd), ]; @@ -71,16 +87,16 @@ export async function resolveSkillDefinition( ): Promise { const lookup = normalizeSkillLookup(nameOrAlias); const alias = lookup === PLAN_ALIAS || lookup === GOAL_ALIAS ? lookup : undefined; - const skillName = alias === PLAN_ALIAS - ? "create-plan" + const fixedName = alias === PLAN_ALIAS + ? "devspace-plan" : alias === GOAL_ALIAS - ? "define-goal" + ? "devspace-goal" : lookup; - const skill = skills.find((candidate) => { - if (candidate.name === skillName) return true; - return candidate.aliases?.includes(lookup) ?? false; - }); + const skill = alias + ? skills.find((candidate) => candidate.name === fixedName && candidate.source === "devspace_system") + : skills.find((candidate) => candidate.qualifiedId === fixedName) + ?? skills.find((candidate) => candidate.name === fixedName); if (!skill) { throw new Error(`Skill not found: ${nameOrAlias}`); @@ -88,8 +104,9 @@ export async function resolveSkillDefinition( return { name: skill.name, + qualifiedId: skill.qualifiedId, source: skill.source, - path: resolve(skill.filePath), + path: skill.locator, alias, mode: skill.resolveMode, instructions: await readFile(skill.filePath, "utf8"), @@ -102,6 +119,9 @@ export function resolveSkillReadPath( activatedSkillDirs: Set, inputPath: string, ): SkillReadResolution | undefined { + const locatorMatch = resolveLocatorReadPath(skills, activatedSkillDirs, inputPath); + if (locatorMatch) return locatorMatch; + const absolutePath = resolve(expandHomePath(inputPath)); for (const skill of skills) { @@ -143,21 +163,56 @@ export function formatPathForPrompt(path: string): string { export function skillSourceLabel(source: SkillSource): string { switch (source) { - case "system": - return "系统内置"; + case "devspace_system": + return "DevSpace 核心"; case "local": return "项目自定义"; + case "legacy_core": + return "项目 legacy core"; case "installed": return "项目已安装"; + case "official_vendored": + return "OpenAI 官方副本"; case "global": return "全局已安装"; } } +function loadDevSpaceSystemSkillBatches(): SkillBatch[] { + const root = bundledSystemSkillPath(); + if (!existsSync(root)) return []; + + const coreDirectories = new Set([ + "devspace-plan", + "devspace-goal", + "devspace-workflow", + "senior-architect", + "skill-authoring", + ]); + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && coreDirectories.has(entry.name)) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((entry) => loadSkillsFromSourceDir(resolve(root, entry.name), "devspace_system")); +} + +function loadOfficialVendoredSkillBatches(): SkillBatch[] { + const root = officialVendoredSkillsPath(); + const channels = [".system", ".curated", ".experimental"]; + return channels.map((channel) => + loadSkillsFromSourceDir(resolve(root, channel), "official_vendored", { + qualifiedPrefix: `openai:${channel}`, + }), + ); +} + function bundledSystemSkillPath(): string { return resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", ".system"); } +function officialVendoredSkillsPath(): string { + return resolve(bundledSystemSkillPath(), "openai", "skills"); +} + function legacyWorkspaceCorePath(cwd: string): string { return resolve(cwd, "skills", "core"); } @@ -177,25 +232,27 @@ function globalSkillPath(agentDir: string): string { function loadSkillsFromSourceDir( dir: string, source: SkillSource, - options: { legacyCore?: boolean } = {}, + options: SkillSourceOptions = {}, ): SkillBatch { + if (!existsSync(dir)) return { skills: [], diagnostics: [] }; + const loaded = loadSkillsFromDir({ dir, - source: source === "global" ? "user" : source, + source: source === "global" ? "user" : "system", }); const diagnostics = [...loaded.diagnostics]; if (options.legacyCore && loaded.skills.length > 0) { diagnostics.push({ type: "warning", - message: `skills/core is deprecated; migrate these skills to skills/.system.`, + message: "skills/core is deprecated; migrate these skills to skills/local or skills/installed.", path: dir, }); } return { diagnostics, - skills: loaded.skills.map((skill) => decorateSkill(skill, source, options)), + skills: loaded.skills.map((skill) => decorateSkill(skill, source, dir, options)), }; } @@ -213,32 +270,42 @@ function loadExplicitSkillPaths(config: ServerConfig, cwd: string): SkillBatch { return { diagnostics: loaded.diagnostics, - skills: loaded.skills.map((skill) => decorateSkill(skill, "global")), + skills: loaded.skills.map((skill) => decorateSkill(skill, "global", dirname(skill.filePath))), }; } function decorateSkill( skill: Skill, source: SkillSource, - options: { legacyCore?: boolean } = {}, + sourceRoot: string, + options: SkillSourceOptions = {}, ): DevSpaceSkill { + const relativePath = relative(sourceRoot, skill.baseDir).split(sep).join("/"); + const qualifiedId = options.qualifiedPrefix + ? `${options.qualifiedPrefix}/${relativePath || skill.name}` + : skill.name; + const locator = skillLocator(source, qualifiedId); + return { ...skill, source, - aliases: aliasesForSkill(skill.name), + qualifiedId, + locator, + aliases: aliasesForSkill(skill.name, source), resolveMode: resolveModeForSkill(skill.name), legacyCore: options.legacyCore, }; } -function aliasesForSkill(name: string): string[] | undefined { - if (name === "create-plan") return [PLAN_ALIAS]; - if (name === "define-goal") return [GOAL_ALIAS]; +function aliasesForSkill(name: string, source: SkillSource): string[] | undefined { + if (source !== "devspace_system") return undefined; + if (name === "devspace-plan") return [PLAN_ALIAS]; + if (name === "devspace-goal") return [GOAL_ALIAS]; return undefined; } function resolveModeForSkill(name: string): SkillResolveMode { - return name === "create-plan" ? "read_only" : "normal"; + return name === "devspace-plan" ? "read_only" : "normal"; } function mergeLoadedSkills(batches: SkillBatch[]): LoadedSkills { @@ -248,9 +315,10 @@ function mergeLoadedSkills(batches: SkillBatch[]): LoadedSkills { for (const batch of batches) { diagnostics.push(...batch.diagnostics); for (const skill of batch.skills) { - const existing = winners.get(skill.name); + const key = skill.source === "official_vendored" ? skill.qualifiedId : skill.name; + const existing = winners.get(key); if (!existing) { - winners.set(skill.name, skill); + winners.set(key, skill); continue; } @@ -274,11 +342,47 @@ function mergeLoadedSkills(batches: SkillBatch[]): LoadedSkills { }; } +function resolveLocatorReadPath( + skills: DevSpaceSkill[], + activatedSkillDirs: Set, + inputPath: string, +): SkillReadResolution | undefined { + if (!inputPath.startsWith("skill://")) return undefined; + + for (const skill of skills) { + if (inputPath === skill.locator) { + return { absolutePath: resolve(skill.filePath), skill, isSkillFile: true }; + } + + const prefix = `${skill.locator.slice(0, -"SKILL.md".length)}`; + if (!inputPath.startsWith(prefix)) continue; + if (!activatedSkillDirs.has(resolve(skill.baseDir))) continue; + + const relativePath = inputPath.slice(prefix.length); + if (!relativePath || relativePath === "SKILL.md") { + return { absolutePath: resolve(skill.filePath), skill, isSkillFile: true }; + } + const absolutePath = resolve(skill.baseDir, relativePath); + if (!isPathInsideRoot(absolutePath, resolve(skill.baseDir))) return undefined; + return { absolutePath, skill, isSkillFile: false }; + } + + return undefined; +} + +function skillLocator(source: SkillSource, qualifiedId: string): string { + const namespace = source === "devspace_system" + ? "devspace-system" + : source === "official_vendored" + ? "official-vendored" + : source; + return `skill://${namespace}/${qualifiedId}/SKILL.md`; +} + function normalizeSkillLookup(nameOrAlias: string): string { const trimmed = nameOrAlias.trim().replace(/^@\S+\s+/, ""); if (trimmed.startsWith("/")) { return trimmed.split(/\s+/)[0] ?? trimmed; } - return trimmed; } diff --git a/src/workflow-migration.test.ts b/src/workflow-migration.test.ts new file mode 100644 index 0000000..2c31480 --- /dev/null +++ b/src/workflow-migration.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { databasePath } from "./db/client.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-workflow-migration-test-")); + +try { + const stateDir = join(root, "state"); + const projectRoot = join(root, "project"); + await mkdir(projectRoot, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + + const sqlite = new Database(databasePath(stateDir)); + sqlite.exec(` + create table workspace_sessions ( + id text primary key, root text not null, status text not null, mode text not null, + source_root text, base_ref text, base_sha text, managed text not null, + created_at text not null, last_used_at text not null + ); + create table workspace_plans ( + workspace_session_id text primary key, explanation text, steps_json text not null, updated_at text not null + ); + create table workspace_goals ( + workspace_session_id text primary key, objective text not null, status text not null, + token_budget text, created_at text not null, updated_at text not null, + active_seconds text not null, completed_at text, blocked_at text + ); + create table workspace_modes ( + workspace_session_id text primary key, mode text not null, updated_at text not null + ); + `); + + const older = "2026-06-20T00:00:00.000Z"; + const newer = "2026-06-21T00:00:00.000Z"; + const insertSession = sqlite.prepare( + "insert into workspace_sessions values (?, ?, 'active', 'checkout', null, null, null, 'false', ?, ?)", + ); + insertSession.run("old", projectRoot, older, older); + insertSession.run("new", projectRoot, newer, newer); + sqlite.prepare("insert into workspace_plans values (?, ?, ?, ?)").run( + "old", "Older plan", JSON.stringify([{ step: "Old work", status: "completed" }]), older, + ); + sqlite.prepare("insert into workspace_plans values (?, ?, ?, ?)").run( + "new", "Newer plan", JSON.stringify([{ step: "New work", status: "in_progress" }]), newer, + ); + sqlite.prepare("insert into workspace_goals values (?, ?, ?, null, ?, ?, '0', null, null)").run( + "old", "Older goal", "completed", older, older, + ); + sqlite.prepare("insert into workspace_goals values (?, ?, ?, null, ?, ?, '0', null, null)").run( + "new", "Newer goal", "active", newer, newer, + ); + sqlite.prepare("insert into workspace_modes values (?, ?, ?)").run("old", "default", older); + sqlite.prepare("insert into workspace_modes values (?, ?, ?)").run("new", "plan", newer); + sqlite.close(); + + const store = new SqliteWorkspaceStore(stateDir); + assert.equal(store.getPlan("old")?.summary, "Newer plan"); + assert.equal(store.getGoal("old")?.objective, "Newer goal"); + assert.equal(store.getCollaborationMode("old").mode, "plan"); + + const history = store.getWorkflowHistory({ workspaceSessionId: "new", limit: 50 }); + assert.equal(history.events.some((event) => event.eventType === "plan.migrated"), true); + assert.equal(history.events.some((event) => event.eventType === "plan.archived_migrated"), true); + assert.equal(history.events.some((event) => event.eventType === "goal.migrated"), true); + assert.equal(history.events.some((event) => event.eventType === "goal.archived_migrated"), true); + const eventCount = history.events.length; + store.close(); + + const reopened = new SqliteWorkspaceStore(stateDir); + assert.equal(reopened.getWorkflowHistory({ workspaceSessionId: "new", limit: 50 }).events.length, eventCount); + reopened.close(); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts new file mode 100644 index 0000000..cd0b1db --- /dev/null +++ b/src/workflow-store.test.ts @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { SqliteWorkspaceStore, WorkflowRevisionConflictError } from "./workspace-store.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-workflow-store-test-")); + +try { + const stateDir = join(root, "state"); + const projectRoot = join(root, "project"); + const worktreeRoot = join(root, "project-worktree"); + await mkdir(projectRoot, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + + const store = new SqliteWorkspaceStore(stateDir); + store.createSession({ id: "ws_a", root: projectRoot, mode: "checkout" }); + store.createSession({ id: "ws_b", root: projectRoot, mode: "checkout" }); + store.createSession({ id: "ws_worktree", root: worktreeRoot, mode: "worktree" }); + + const projectKey = store.getProjectWorkflowKey("ws_a"); + assert.equal(store.getProjectWorkflowKey("ws_b"), projectKey); + assert.notEqual(store.getProjectWorkflowKey("ws_worktree"), projectKey); + + const plan = store.savePlan({ + workspaceSessionId: "ws_a", + expectedRevision: 0, + title: "Shared workflow state", + summary: "Persist Plan across sessions.", + scopeIn: ["workflow store"], + scopeOut: ["chat transcript storage"], + validation: ["npm test"], + risks: ["stale session writes"], + steps: [ + { step: "Create durable tables", status: "completed" }, + { step: "Expose MCP tools", status: "in_progress" }, + ], + }); + assert.equal(plan.revision, 1); + assert.equal(store.getPlan("ws_b")?.title, "Shared workflow state"); + assert.equal(store.getPlan("ws_worktree"), undefined); + + const updatedPlan = store.savePlan({ + workspaceSessionId: "ws_b", + expectedRevision: 1, + title: plan.title, + summary: plan.summary, + scopeIn: plan.scopeIn, + scopeOut: plan.scopeOut, + validation: plan.validation, + risks: plan.risks, + steps: [ + { id: plan.steps[0]?.id, step: "Create durable tables", status: "completed" }, + { id: plan.steps[1]?.id, step: "Expose MCP tools", status: "completed" }, + ], + }); + assert.equal(updatedPlan.revision, 2); + assert.throws( + () => store.savePlan({ workspaceSessionId: "ws_a", expectedRevision: 1, title: "Stale plan", steps: [{ step: "No overwrite", status: "pending" }] }), + (error: unknown) => error instanceof WorkflowRevisionConflictError && error.entity === "plan" && error.currentRevision === 2, + ); + + const goal = store.saveGoal({ + workspaceSessionId: "ws_a", + objective: "Make workflow state recoverable across sessions", + scopeIn: ["Plan", "Goal", "mode"], + scopeOut: ["chat history"], + successCriteria: ["New sessions read the same Plan and Goal"], + verification: ["workflow store test"], + stopConditions: ["database migration fails"], + currentSummary: "Current: exercise revision conflicts.", + }); + assert.equal(goal.status, "active"); + assert.equal(store.getGoal("ws_b")?.objective, goal.objective); + assert.equal("tokenBudget" in goal, false); + assert.equal("timeUsedSeconds" in goal, false); + + const updatedGoal = store.updateGoal({ + workspaceSessionId: "ws_b", + expectedRevision: goal.revision, + currentSummary: "Completed: shared state. Current: validate conflict detection.", + }); + assert.equal(updatedGoal.revision, 2); + assert.throws( + () => store.updateGoal({ workspaceSessionId: "ws_a", expectedRevision: 1, objective: "Stale goal write" }), + (error: unknown) => error instanceof WorkflowRevisionConflictError && error.entity === "goal" && error.currentRevision === 2, + ); + const blockedGoal = store.updateGoal({ + workspaceSessionId: "ws_a", + expectedRevision: updatedGoal.revision, + status: "blocked", + }); + assert.equal(blockedGoal.status, "blocked"); + assert.throws( + () => store.updateGoal({ workspaceSessionId: "ws_b", expectedRevision: blockedGoal.revision, currentSummary: "Should fail" }), + /Only an active Goal can be updated/, + ); + const replacementGoal = store.saveGoal({ + workspaceSessionId: "ws_a", + objective: "Continue after resolving the blocker", + }); + assert.equal(replacementGoal.status, "active"); + + const linkedPlan = store.savePlan({ + workspaceSessionId: "ws_a", + expectedRevision: updatedPlan.revision, + goalId: replacementGoal.id, + title: updatedPlan.title, + summary: updatedPlan.summary, + scopeIn: updatedPlan.scopeIn, + scopeOut: updatedPlan.scopeOut, + validation: updatedPlan.validation, + risks: updatedPlan.risks, + steps: [ + { id: updatedPlan.steps[0]?.id, step: "Create durable tables", status: "completed" }, + { id: updatedPlan.steps[1]?.id, step: "Expose MCP tools", status: "in_progress" }, + ], + }); + assert.equal(linkedPlan.revision, 3); + const linkedGoal = store.getGoal("ws_b"); + assert.equal(linkedGoal?.metrics.progress.source, "linked_plan_steps"); + assert.equal(linkedGoal?.metrics.progress.exactFraction, "1/2"); + assert.equal(linkedGoal?.metrics.progress.percentageNumerator, 100); + assert.equal(linkedGoal?.metrics.progress.percentageDenominator, 2); + assert.equal(linkedGoal?.metrics.progress.displayPercent, "50.00%"); + + const workStart = store.startGoalWork({ workspaceSessionId: "ws_a" }); + assert.equal(workStart.started, true); + assert.equal(store.startGoalWork({ workspaceSessionId: "ws_b" }).started, false); + await delay(15); + const workPause = store.pauseGoalWork({ workspaceSessionId: "ws_b" }); + assert.equal(workPause.paused, true); + assert.equal(workPause.metrics.workDuration.running, false); + assert.equal(workPause.metrics.workDuration.totalMilliseconds >= 10, true); + assert.equal(store.pauseGoalWork({ workspaceSessionId: "ws_a" }).paused, false); + + const tokenUsage = store.recordGoalTokenUsage({ + workspaceSessionId: "ws_a", + provider: "openai-api", + providerRequestId: "req_001", + model: "gpt-test", + inputTokens: 120, + outputTokens: 80, + reasoningTokens: 20, + totalTokens: 200, + providerReportedAt: "2026-06-22T00:00:00.000Z", + }); + assert.equal(tokenUsage.recorded, true); + assert.deepEqual(tokenUsage.metrics.tokenUsage, { + inputTokens: 120, + outputTokens: 80, + reasoningTokens: 20, + totalTokens: 200, + reportCount: 1, + lastReportedAt: tokenUsage.metrics.tokenUsage.lastReportedAt, + }); + assert.equal( + store.recordGoalTokenUsage({ + workspaceSessionId: "ws_b", + provider: "openai-api", + providerRequestId: "req_001", + inputTokens: 120, + outputTokens: 80, + totalTokens: 200, + }).recorded, + false, + ); + assert.equal(store.getGoal("ws_a")?.metrics.tokenUsage.totalTokens, 200); + + store.setCollaborationMode({ workspaceSessionId: "ws_a", mode: "plan" }); + assert.equal(store.getCollaborationMode("ws_b").mode, "plan"); + const digest = store.getWorkflowDigest("ws_b"); + assert.equal(digest.projectWorkflowKey, projectKey); + assert.equal(digest.hasActiveGoal, true); + assert.equal(digest.hasActivePlan, true); + assert.equal(digest.planRevision, 3); + assert.deepEqual(digest.steps, { total: 2, completed: 1, inProgress: 1, blocked: 0 }); + assert.equal(Buffer.byteLength(JSON.stringify(digest), "utf8") <= 2 * 1024, true); + + for (let index = 0; index < 120; index++) { + store.setCollaborationMode({ workspaceSessionId: "ws_a", mode: index % 2 === 0 ? "default" : "plan" }); + } + + const firstPage = store.getWorkflowHistory({ workspaceSessionId: "ws_a", limit: 50 }); + const secondPage = store.getWorkflowHistory({ workspaceSessionId: "ws_a", limit: 50, cursor: firstPage.nextCursor }); + assert.equal(firstPage.events.length, 50); + assert.equal(secondPage.events.length, 50); + assert.equal(secondPage.nextCursor, undefined); + assert.equal(firstPage.events.every((event) => event.summary.length <= 2048), true); + + assert.equal(store.startGoalWork({ workspaceSessionId: "ws_a" }).started, true); + await delay(5); + const completedReplacementGoal = store.updateGoal({ + workspaceSessionId: "ws_a", + expectedRevision: replacementGoal.revision, + status: "completed", + }); + assert.equal(completedReplacementGoal.status, "completed"); + assert.equal(completedReplacementGoal.metrics.workDuration.running, false); + assert.equal(completedReplacementGoal.metrics.workDuration.totalMilliseconds >= workPause.metrics.workDuration.totalMilliseconds, true); + + store.close(); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 2ffe31f..b033e60 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,23 +1,31 @@ +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; import { eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { workspaceSessions, - workspacePlans, - workspaceGoals, - workspaceModes, workspaceUserInputs, type WorkspaceSessionRow, - type WorkspacePlanRow, - type WorkspaceGoalRow, - type WorkspaceModeRow, type WorkspaceUserInputRow, } from "./db/schema.js"; +import { parseGoalDefinition } from "./goal-definition.js"; export type WorkspaceMode = "checkout" | "worktree"; export type CollaborationMode = "default" | "plan"; +export type PlanStatus = "draft" | "active" | "completed" | "archived"; +export type PlanStepStatus = "pending" | "in_progress" | "blocked" | "completed" | "skipped"; +export type GoalStatus = "active" | "blocked" | "completed" | "archived"; +export type WorkflowEntityType = "plan" | "goal" | "mode"; export type UserInputStatus = "pending" | "completed" | "declined" | "cancelled"; export type UserInputDeliveryMode = "elicitation" | "tool" | "ui"; +const MAX_WORKFLOW_TEXT_BYTES = 32 * 1024; +const MAX_SUMMARY_BYTES = 4 * 1024; +const MAX_EVENT_SUMMARY_BYTES = 2 * 1024; +const MAX_WORKFLOW_EVENTS = 100; + export interface WorkspaceSession { id: string; root: string; @@ -32,27 +40,121 @@ export interface WorkspaceSession { } export interface WorkspacePlanStep { + id?: string; step: string; - status: "pending" | "in_progress" | "completed"; + status: PlanStepStatus; + note?: string; + updatedAt?: string; } export interface WorkspacePlan { - workspaceSessionId: string; - explanation?: string; + id: string; + projectWorkflowKey: string; + goalId?: string; + title: string; + summary?: string; + scopeIn: string[]; + scopeOut: string[]; + validation: string[]; + risks: string[]; + status: PlanStatus; + revision: number; steps: WorkspacePlanStep[]; + createdAt: string; updatedAt: string; + archivedAt?: string; +} + +export interface GoalTokenUsage { + /** Exact values reported by an upstream provider, never inferred from text length. */ + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + totalTokens: number; + reportCount: number; + lastReportedAt?: string; +} + +export interface GoalWorkDuration { + /** True only while an explicit goal work timer is running on this server. */ + running: boolean; + startedAt?: string; + accumulatedMilliseconds: number; + liveMilliseconds: number; + totalMilliseconds: number; + measuredAt: string; +} + +export interface GoalProgress { + /** Progress is exact only when the current Plan is explicitly linked to this Goal. */ + source: "linked_plan_steps" | "unlinked"; + completedSteps: number; + totalSteps: number; + /** Exact canonical completion ratio, for example `2/3`. */ + exactFraction?: string; + /** Exact rational percentage: percentageNumerator / percentageDenominator. */ + percentageNumerator?: number; + percentageDenominator?: number; + /** Rounded display only; use the numerator and denominator for machine accuracy. */ + displayPercent?: string; +} + +export interface GoalMetrics { + tokenUsage: GoalTokenUsage; + workDuration: GoalWorkDuration; + progress: GoalProgress; + updatedAt?: string; } export interface WorkspaceGoal { - workspaceSessionId: string; + id: string; + projectWorkflowKey: string; objective: string; - status: "active" | "complete" | "blocked"; - tokenBudget?: number; + scopeIn: string[]; + scopeOut: string[]; + successCriteria: string[]; + verification: string[]; + stopConditions: string[]; + currentSummary?: string; + status: GoalStatus; + revision: number; + metrics: GoalMetrics; createdAt: string; updatedAt: string; - timeUsedSeconds: number; - completedAt?: string; - blockedAt?: string; + archivedAt?: string; +} + +export interface WorkflowDigest { + projectWorkflowKey: string; + hasActiveGoal: boolean; + goalStatus?: GoalStatus; + goalTitle?: string; + hasActivePlan: boolean; + planStatus?: PlanStatus; + planRevision?: number; + steps?: { + total: number; + completed: number; + inProgress: number; + blocked: number; + }; + lastUpdatedAt?: string; +} + +export interface WorkflowEvent { + id: string; + projectWorkflowKey: string; + entityType: WorkflowEntityType; + entityId: string; + eventType: string; + summary: string; + revision?: number; + createdAt: string; +} + +export interface WorkflowHistoryPage { + events: WorkflowEvent[]; + nextCursor?: string; } export interface WorkspaceQuestionOption { @@ -91,6 +193,18 @@ export interface WorkspaceUserInputRecord { answeredAt?: string; } +export class WorkflowRevisionConflictError extends Error { + readonly entity: "plan" | "goal"; + readonly currentRevision: number; + + constructor(entity: "plan" | "goal", currentRevision: number) { + super(`${entity} revision conflict: current revision is ${currentRevision}. Reload the ${entity} before updating it.`); + this.name = "WorkflowRevisionConflictError"; + this.entity = entity; + this.currentRevision = currentRevision; + } +} + export interface WorkspaceStore { createSession(input: { id: string; @@ -103,38 +217,94 @@ export interface WorkspaceStore { }): WorkspaceSession; getSession(id: string): WorkspaceSession | undefined; touchSession(id: string): void; + getProjectWorkflowKey(workspaceSessionId: string): string; + getWorkflowDigest(workspaceSessionId: string): WorkflowDigest; + getWorkflowHistory(input: { + workspaceSessionId: string; + limit?: number; + cursor?: string; + }): WorkflowHistoryPage; savePlan(input: { workspaceSessionId: string; - explanation?: string; + expectedRevision: number; + title?: string; + summary?: string; + scopeIn?: string[]; + scopeOut?: string[]; + validation?: string[]; + risks?: string[]; + status?: Exclude | "archived"; + goalId?: string; steps: WorkspacePlanStep[]; }): WorkspacePlan; getPlan(workspaceSessionId: string): WorkspacePlan | undefined; saveGoal(input: { workspaceSessionId: string; objective: string; - tokenBudget?: number; + scopeIn?: string[]; + scopeOut?: string[]; + successCriteria?: string[]; + verification?: string[]; + stopConditions?: string[]; + currentSummary?: string; }): WorkspaceGoal; getGoal(workspaceSessionId: string): WorkspaceGoal | undefined; + getGoalMetrics(workspaceSessionId: string): GoalMetrics | undefined; + startGoalWork(input: { + workspaceSessionId: string; + }): { + metrics: GoalMetrics; + started: boolean; + }; + pauseGoalWork(input: { + workspaceSessionId: string; + }): { + metrics: GoalMetrics; + paused: boolean; + }; + recordGoalTokenUsage(input: { + workspaceSessionId: string; + provider: string; + providerRequestId: string; + model?: string; + inputTokens: number; + outputTokens: number; + reasoningTokens?: number; + totalTokens: number; + providerReportedAt?: string; + }): { + metrics: GoalMetrics; + recorded: boolean; + }; updateGoal(input: { workspaceSessionId: string; + expectedRevision: number; objective?: string; - tokenBudget?: number; - status?: "active" | "complete" | "blocked"; + scopeIn?: string[]; + scopeOut?: string[]; + successCriteria?: string[]; + verification?: string[]; + stopConditions?: string[]; + currentSummary?: string; + status?: GoalStatus; }): WorkspaceGoal; updateGoalStatus(input: { workspaceSessionId: string; - status: "complete" | "blocked"; + status: "completed" | "complete" | "blocked" | "archived"; + expectedRevision?: number; }): WorkspaceGoal; setCollaborationMode(input: { workspaceSessionId: string; mode: CollaborationMode; }): { workspaceSessionId: string; + projectWorkflowKey: string; mode: CollaborationMode; updatedAt: string; }; getCollaborationMode(workspaceSessionId: string): { workspaceSessionId: string; + projectWorkflowKey: string; mode: CollaborationMode; updatedAt: string; }; @@ -207,6 +377,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { }) .run(); + this.ensureProjectWorkflow(session.root, session.mode); return session; } @@ -228,166 +399,552 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + getProjectWorkflowKey(workspaceSessionId: string): string { + return this.workflowForSession(workspaceSessionId).key; + } + + getWorkflowDigest(workspaceSessionId: string): WorkflowDigest { + const workflow = this.workflowForSession(workspaceSessionId); + const plan = this.getPlan(workspaceSessionId); + const goal = this.getGoal(workspaceSessionId); + const updatedAt = [plan?.updatedAt, goal?.updatedAt, goal?.metrics.updatedAt] + .filter((value): value is string => Boolean(value)) + .sort() + .at(-1); + + return { + projectWorkflowKey: workflow.key, + hasActiveGoal: goal?.status === "active", + goalStatus: goal?.status, + goalTitle: goal ? truncateText(goal.objective, 160) : undefined, + hasActivePlan: Boolean(plan && (plan.status === "draft" || plan.status === "active")), + planStatus: plan?.status, + planRevision: plan?.revision, + steps: plan + ? { + total: plan.steps.length, + completed: plan.steps.filter((step) => step.status === "completed").length, + inProgress: plan.steps.filter((step) => step.status === "in_progress").length, + blocked: plan.steps.filter((step) => step.status === "blocked").length, + } + : undefined, + lastUpdatedAt: updatedAt, + }; + } + + getWorkflowHistory(input: { + workspaceSessionId: string; + limit?: number; + cursor?: string; + }): WorkflowHistoryPage { + const workflow = this.workflowForSession(input.workspaceSessionId); + const limit = Math.max(1, Math.min(input.limit ?? 20, 50)); + const cursor = decodeHistoryCursor(input.cursor); + const rows = cursor + ? this.database.sqlite + .prepare( + `select id, project_workflow_key, entity_type, entity_id, event_type, summary, revision, created_at + from workflow_events + where project_workflow_key = ? + and (created_at < ? or (created_at = ? and id < ?)) + order by created_at desc, id desc + limit ?`, + ) + .all(workflow.key, cursor.createdAt, cursor.createdAt, cursor.id, limit + 1) + : this.database.sqlite + .prepare( + `select id, project_workflow_key, entity_type, entity_id, event_type, summary, revision, created_at + from workflow_events + where project_workflow_key = ? + order by created_at desc, id desc + limit ?`, + ) + .all(workflow.key, limit + 1); + + const pageRows = (rows as WorkflowEventRow[]).slice(0, limit); + const events = pageRows.map(rowToWorkflowEvent); + const lastReturned = pageRows.at(-1); + const hasMore = (rows as WorkflowEventRow[]).length > pageRows.length; + + return { + events, + nextCursor: hasMore && lastReturned + ? encodeHistoryCursor({ createdAt: lastReturned.created_at, id: lastReturned.id }) + : undefined, + }; + } + savePlan(input: { workspaceSessionId: string; - explanation?: string; + expectedRevision: number; + title?: string; + summary?: string; + scopeIn?: string[]; + scopeOut?: string[]; + validation?: string[]; + risks?: string[]; + status?: PlanStatus; + goalId?: string; steps: WorkspacePlanStep[]; }): WorkspacePlan { - const updatedAt = new Date().toISOString(); - const plan: WorkspacePlan = { - workspaceSessionId: input.workspaceSessionId, - explanation: input.explanation, - steps: input.steps, - updatedAt, - }; + validatePlanSteps(input.steps); + const workflow = this.workflowForSession(input.workspaceSessionId); + const existing = this.getPlan(input.workspaceSessionId); + const now = new Date().toISOString(); - this.database.db - .insert(workspacePlans) - .values({ - workspaceSessionId: plan.workspaceSessionId, - explanation: plan.explanation ?? null, - stepsJson: JSON.stringify(plan.steps), - updatedAt: plan.updatedAt, - }) - .onConflictDoUpdate({ - target: workspacePlans.workspaceSessionId, - set: { - explanation: plan.explanation ?? null, - stepsJson: JSON.stringify(plan.steps), - updatedAt: plan.updatedAt, - }, - }) - .run(); + if (!existing && input.expectedRevision !== 0) { + throw new WorkflowRevisionConflictError("plan", 0); + } + if (existing && input.expectedRevision !== existing.revision) { + throw new WorkflowRevisionConflictError("plan", existing.revision); + } - return plan; + return this.database.sqlite.transaction(() => { + const isCreate = !existing; + const status = input.status ?? existing?.status ?? "active"; + const plan: WorkspacePlan = { + id: existing?.id ?? randomUUID(), + projectWorkflowKey: workflow.key, + goalId: input.goalId ?? existing?.goalId, + title: normalizeRequiredText(input.title ?? existing?.title ?? "Project plan", "Plan title"), + summary: normalizeOptionalText(input.summary ?? existing?.summary, MAX_WORKFLOW_TEXT_BYTES), + scopeIn: normalizeStringList(input.scopeIn ?? existing?.scopeIn ?? []), + scopeOut: normalizeStringList(input.scopeOut ?? existing?.scopeOut ?? []), + validation: normalizeStringList(input.validation ?? existing?.validation ?? []), + risks: normalizeStringList(input.risks ?? existing?.risks ?? []), + status, + revision: (existing?.revision ?? 0) + 1, + steps: normalizePlanSteps(input.steps, now), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + archivedAt: status === "archived" ? now : undefined, + }; + + if (isCreate) { + const inserted = this.database.sqlite + .prepare( + `insert into workflow_plans ( + id, project_workflow_key, goal_id, title, summary, + scope_in_json, scope_out_json, validation_json, risks_json, + status, revision, is_current, created_at, updated_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict do nothing`, + ) + .run( + plan.id, + plan.projectWorkflowKey, + plan.goalId ?? null, + plan.title, + plan.summary ?? null, + JSON.stringify(plan.scopeIn), + JSON.stringify(plan.scopeOut), + JSON.stringify(plan.validation), + JSON.stringify(plan.risks), + plan.status, + plan.revision, + plan.status === "archived" ? 0 : 1, + plan.createdAt, + plan.updatedAt, + plan.archivedAt ?? null, + ); + if (inserted.changes !== 1) { + throw new WorkflowRevisionConflictError("plan", this.currentPlanRevision(workflow.key)); + } + } else { + const updated = this.database.sqlite + .prepare( + `update workflow_plans + set goal_id = ?, title = ?, summary = ?, scope_in_json = ?, scope_out_json = ?, + validation_json = ?, risks_json = ?, status = ?, revision = ?, + is_current = ?, updated_at = ?, archived_at = ? + where id = ? and revision = ? and is_current = 1`, + ) + .run( + plan.goalId ?? null, + plan.title, + plan.summary ?? null, + JSON.stringify(plan.scopeIn), + JSON.stringify(plan.scopeOut), + JSON.stringify(plan.validation), + JSON.stringify(plan.risks), + plan.status, + plan.revision, + plan.status === "archived" ? 0 : 1, + plan.updatedAt, + plan.archivedAt ?? null, + plan.id, + input.expectedRevision, + ); + if (updated.changes !== 1) { + throw new WorkflowRevisionConflictError("plan", this.currentPlanRevision(workflow.key)); + } + this.database.sqlite.prepare("delete from workflow_plan_steps where plan_id = ?").run(plan.id); + } + + this.insertPlanSteps(plan); + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "plan", + entityId: plan.id, + eventType: isCreate ? "plan.created" : plan.status === "archived" ? "plan.archived" : "plan.updated", + summary: truncateText(`${isCreate ? "Created" : "Updated"} plan: ${plan.title}`, MAX_EVENT_SUMMARY_BYTES), + revision: plan.revision, + createdAt: now, + }); + return plan; + })(); } getPlan(workspaceSessionId: string): WorkspacePlan | undefined { - const row = this.database.db - .select() - .from(workspacePlans) - .where(eq(workspacePlans.workspaceSessionId, workspaceSessionId)) - .get(); - - return row ? rowToWorkspacePlan(row) : undefined; + const workflow = this.workflowForSession(workspaceSessionId); + return this.getCurrentPlanForWorkflow(workflow.key); } saveGoal(input: { workspaceSessionId: string; objective: string; - tokenBudget?: number; + scopeIn?: string[]; + scopeOut?: string[]; + successCriteria?: string[]; + verification?: string[]; + stopConditions?: string[]; + currentSummary?: string; }): WorkspaceGoal { - const existing = this.getGoal(input.workspaceSessionId); - if (existing && existing.status === "active") { - throw new Error("An active goal already exists for this workspace."); - } - + const workflow = this.workflowForSession(input.workspaceSessionId); const now = new Date().toISOString(); const goal: WorkspaceGoal = { - workspaceSessionId: input.workspaceSessionId, - objective: input.objective, + id: randomUUID(), + projectWorkflowKey: workflow.key, + objective: normalizeRequiredText(input.objective, "Goal objective"), + scopeIn: normalizeStringList(input.scopeIn ?? []), + scopeOut: normalizeStringList(input.scopeOut ?? []), + successCriteria: normalizeStringList(input.successCriteria ?? []), + verification: normalizeStringList(input.verification ?? []), + stopConditions: normalizeStringList(input.stopConditions ?? []), + currentSummary: normalizeOptionalText(input.currentSummary, MAX_SUMMARY_BYTES), status: "active", - tokenBudget: input.tokenBudget, + revision: 1, + metrics: emptyGoalMetrics(now), createdAt: now, updatedAt: now, - timeUsedSeconds: 0, }; - this.database.db - .insert(workspaceGoals) - .values({ - workspaceSessionId: goal.workspaceSessionId, - objective: goal.objective, - status: goal.status, - tokenBudget: goal.tokenBudget === undefined ? null : String(goal.tokenBudget), - createdAt: goal.createdAt, - updatedAt: goal.updatedAt, - activeSeconds: "0", - completedAt: null, - blockedAt: null, - }) - .onConflictDoUpdate({ - target: workspaceGoals.workspaceSessionId, - set: { - objective: goal.objective, - status: goal.status, - tokenBudget: goal.tokenBudget === undefined ? null : String(goal.tokenBudget), - createdAt: goal.createdAt, - updatedAt: goal.updatedAt, - activeSeconds: "0", - completedAt: null, - blockedAt: null, - }, - }) - .run(); + return this.database.sqlite.transaction(() => { + const current = this.database.sqlite + .prepare("select id, status from workflow_goals where project_workflow_key = ? and is_current = 1 limit 1") + .get(workflow.key) as { id: string; status: string } | undefined; + if (current?.status === "active") { + throw new Error("An active goal already exists for this project workflow."); + } + if (current) { + this.database.sqlite + .prepare("update workflow_goals set is_current = 0 where id = ? and is_current = 1") + .run(current.id); + } - return goal; + this.database.sqlite + .prepare( + `insert into workflow_goals ( + id, project_workflow_key, objective, scope_in_json, scope_out_json, + success_criteria_json, verification_json, stop_conditions_json, current_summary, + status, revision, is_current, created_at, updated_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, null)`, + ) + .run( + goal.id, + goal.projectWorkflowKey, + goal.objective, + JSON.stringify(goal.scopeIn), + JSON.stringify(goal.scopeOut), + JSON.stringify(goal.successCriteria), + JSON.stringify(goal.verification), + JSON.stringify(goal.stopConditions), + goal.currentSummary ?? null, + goal.status, + goal.revision, + goal.createdAt, + goal.updatedAt, + ); + this.ensureGoalMetricsRecord(goal.id, now); + + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goal.id, + eventType: "goal.created", + summary: truncateText(`Created goal: ${goal.objective}`, MAX_EVENT_SUMMARY_BYTES), + revision: goal.revision, + createdAt: now, + }); + return this.hydrateGoalMetrics(goal, workflow.key, now); + })(); } getGoal(workspaceSessionId: string): WorkspaceGoal | undefined { - const row = this.database.db - .select() - .from(workspaceGoals) - .where(eq(workspaceGoals.workspaceSessionId, workspaceSessionId)) - .get(); + const workflow = this.workflowForSession(workspaceSessionId); + const row = this.database.sqlite + .prepare( + `select id, project_workflow_key, objective, scope_in_json, scope_out_json, + success_criteria_json, verification_json, stop_conditions_json, current_summary, + status, revision, created_at, updated_at, archived_at + from workflow_goals + where project_workflow_key = ? and is_current = 1 + order by updated_at desc, id desc + limit 1`, + ) + .get(workflow.key) as WorkflowGoalRow | undefined; + + return row ? this.hydrateGoalMetrics(rowToWorkspaceGoal(row), workflow.key) : undefined; + } + + getGoalMetrics(workspaceSessionId: string): GoalMetrics | undefined { + return this.getGoal(workspaceSessionId)?.metrics; + } - return row ? rowToWorkspaceGoal(row) : undefined; + startGoalWork(input: { workspaceSessionId: string }): { + metrics: GoalMetrics; + started: boolean; + } { + const workflow = this.workflowForSession(input.workspaceSessionId); + const goal = this.getGoal(input.workspaceSessionId); + if (!goal) throw new Error("No current Goal exists for this project workflow."); + if (goal.status !== "active") throw new Error("Only an active Goal can start work tracking."); + const now = new Date().toISOString(); + + return this.database.sqlite.transaction(() => { + this.ensureGoalMetricsRecord(goal.id, now); + const started = this.database.sqlite + .prepare( + `update workflow_goal_metrics + set active_work_started_at = ?, updated_at = ? + where goal_id = ? and active_work_started_at is null`, + ) + .run(now, now, goal.id).changes === 1; + if (started) { + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goal.id, + eventType: "goal.work_started", + summary: "Started exact goal work timer.", + revision: goal.revision, + createdAt: now, + }); + } + return { + metrics: this.hydrateGoalMetrics(goal, workflow.key, now).metrics, + started, + }; + })(); + } + + pauseGoalWork(input: { workspaceSessionId: string }): { + metrics: GoalMetrics; + paused: boolean; + } { + const workflow = this.workflowForSession(input.workspaceSessionId); + const goal = this.getGoal(input.workspaceSessionId); + if (!goal) throw new Error("No current Goal exists for this project workflow."); + const now = new Date().toISOString(); + + return this.database.sqlite.transaction(() => { + const paused = this.pauseGoalWorkForGoal(goal.id, now); + if (paused) { + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goal.id, + eventType: "goal.work_paused", + summary: "Paused exact goal work timer.", + revision: goal.revision, + createdAt: now, + }); + } + return { + metrics: this.hydrateGoalMetrics(goal, workflow.key, now).metrics, + paused, + }; + })(); + } + + recordGoalTokenUsage(input: { + workspaceSessionId: string; + provider: string; + providerRequestId: string; + model?: string; + inputTokens: number; + outputTokens: number; + reasoningTokens?: number; + totalTokens: number; + providerReportedAt?: string; + }): { + metrics: GoalMetrics; + recorded: boolean; + } { + const workflow = this.workflowForSession(input.workspaceSessionId); + const goal = this.getGoal(input.workspaceSessionId); + if (!goal) throw new Error("No current Goal exists for this project workflow."); + validateTokenUsage(input); + const now = new Date().toISOString(); + + return this.database.sqlite.transaction(() => { + this.ensureGoalMetricsRecord(goal.id, now); + const result = this.database.sqlite + .prepare( + `insert into workflow_goal_token_usage ( + id, goal_id, provider, provider_request_id, model, + input_tokens, output_tokens, reasoning_tokens, total_tokens, + provider_reported_at, recorded_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(goal_id, provider, provider_request_id) do nothing`, + ) + .run( + randomUUID(), + goal.id, + normalizeRequiredText(input.provider, "Token usage provider"), + normalizeRequiredText(input.providerRequestId, "Provider request ID"), + normalizeOptionalText(input.model, 512) ?? null, + input.inputTokens, + input.outputTokens, + input.reasoningTokens ?? 0, + input.totalTokens, + input.providerReportedAt ?? null, + now, + ); + const recorded = result.changes === 1; + if (recorded) { + this.database.sqlite + .prepare("update workflow_goal_metrics set updated_at = ? where goal_id = ?") + .run(now, goal.id); + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goal.id, + eventType: "goal.token_usage_recorded", + summary: `Recorded provider-reported token usage from ${input.provider}.`, + revision: goal.revision, + createdAt: now, + }); + } + return { + metrics: this.hydrateGoalMetrics(goal, workflow.key, now).metrics, + recorded, + }; + })(); } updateGoal(input: { workspaceSessionId: string; + expectedRevision: number; objective?: string; - tokenBudget?: number; - status?: "active" | "complete" | "blocked"; + scopeIn?: string[]; + scopeOut?: string[]; + successCriteria?: string[]; + verification?: string[]; + stopConditions?: string[]; + currentSummary?: string; + status?: GoalStatus; }): WorkspaceGoal { + const workflow = this.workflowForSession(input.workspaceSessionId); const existing = this.getGoal(input.workspaceSessionId); if (!existing) { - throw new Error("No goal exists for this workspace."); + throw new Error("No current goal exists for this project workflow."); + } + if (existing.status !== "active") { + throw new Error("Only an active Goal can be updated. Create a new Goal after completing, blocking, or archiving the previous Goal."); + } + if (input.expectedRevision !== existing.revision) { + throw new WorkflowRevisionConflictError("goal", existing.revision); } - const updatedAt = new Date().toISOString(); - const nextStatus = input.status ?? existing.status; - const completedAt = nextStatus === "complete" - ? existing.completedAt ?? updatedAt - : null; - const blockedAt = nextStatus === "blocked" - ? existing.blockedAt ?? updatedAt - : null; - const activeSeconds = nextStatus === "active" - ? String(existing.timeUsedSeconds) - : String(calculateGoalActiveSeconds(existing, updatedAt)); - - this.database.db - .update(workspaceGoals) - .set({ - objective: input.objective ?? existing.objective, - tokenBudget: input.tokenBudget === undefined - ? (existing.tokenBudget === undefined ? null : String(existing.tokenBudget)) - : String(input.tokenBudget), - status: nextStatus, - updatedAt, - activeSeconds, - completedAt, - blockedAt, - }) - .where(eq(workspaceGoals.workspaceSessionId, input.workspaceSessionId)) - .run(); + const now = new Date().toISOString(); + const status = input.status ?? existing.status; + const goal: WorkspaceGoal = { + ...existing, + objective: input.objective === undefined ? existing.objective : normalizeRequiredText(input.objective, "Goal objective"), + scopeIn: input.scopeIn === undefined ? existing.scopeIn : normalizeStringList(input.scopeIn), + scopeOut: input.scopeOut === undefined ? existing.scopeOut : normalizeStringList(input.scopeOut), + successCriteria: input.successCriteria === undefined + ? existing.successCriteria + : normalizeStringList(input.successCriteria), + verification: input.verification === undefined ? existing.verification : normalizeStringList(input.verification), + stopConditions: input.stopConditions === undefined + ? existing.stopConditions + : normalizeStringList(input.stopConditions), + currentSummary: input.currentSummary === undefined + ? existing.currentSummary + : normalizeOptionalText(input.currentSummary, MAX_SUMMARY_BYTES), + status, + revision: existing.revision + 1, + updatedAt: now, + archivedAt: status === "archived" ? now : undefined, + }; - const updated = this.getGoal(input.workspaceSessionId); - if (!updated) { - throw new Error("Failed to reload goal after update."); - } + return this.database.sqlite.transaction(() => { + const updated = this.database.sqlite + .prepare( + `update workflow_goals + set objective = ?, scope_in_json = ?, scope_out_json = ?, success_criteria_json = ?, + verification_json = ?, stop_conditions_json = ?, current_summary = ?, status = ?, + revision = ?, is_current = ?, updated_at = ?, archived_at = ? + where id = ? and revision = ? and is_current = 1 and status = 'active'`, + ) + .run( + goal.objective, + JSON.stringify(goal.scopeIn), + JSON.stringify(goal.scopeOut), + JSON.stringify(goal.successCriteria), + JSON.stringify(goal.verification), + JSON.stringify(goal.stopConditions), + goal.currentSummary ?? null, + goal.status, + goal.revision, + goal.status === "archived" ? 0 : 1, + goal.updatedAt, + goal.archivedAt ?? null, + goal.id, + input.expectedRevision, + ); + if (updated.changes !== 1) { + throw new WorkflowRevisionConflictError("goal", this.currentGoalRevision(workflow.key)); + } + if (goal.status !== "active") { + this.pauseGoalWorkForGoal(goal.id, now); + } - return updated; + const eventType = goal.status === "archived" + ? "goal.archived" + : goal.status === "completed" + ? "goal.completed" + : goal.status === "blocked" + ? "goal.blocked" + : "goal.updated"; + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goal.id, + eventType, + summary: truncateText(`Updated goal: ${goal.objective}`, MAX_EVENT_SUMMARY_BYTES), + revision: goal.revision, + createdAt: now, + }); + return this.hydrateGoalMetrics(goal, workflow.key, now); + })(); } updateGoalStatus(input: { workspaceSessionId: string; - status: "complete" | "blocked"; + status: "completed" | "complete" | "blocked" | "archived"; + expectedRevision?: number; }): WorkspaceGoal { + const existing = this.getGoal(input.workspaceSessionId); + if (!existing) { + throw new Error("No current goal exists for this project workflow."); + } return this.updateGoal({ workspaceSessionId: input.workspaceSessionId, - status: input.status, + expectedRevision: input.expectedRevision ?? existing.revision, + status: input.status === "complete" ? "completed" : input.status, }); } @@ -396,29 +953,33 @@ export class SqliteWorkspaceStore implements WorkspaceStore { mode: CollaborationMode; }): { workspaceSessionId: string; + projectWorkflowKey: string; mode: CollaborationMode; updatedAt: string; } { + const workflow = this.workflowForSession(input.workspaceSessionId); const updatedAt = new Date().toISOString(); - this.database.db - .insert(workspaceModes) - .values({ - workspaceSessionId: input.workspaceSessionId, - mode: input.mode, - updatedAt, - }) - .onConflictDoUpdate({ - target: workspaceModes.workspaceSessionId, - set: { - mode: input.mode, - updatedAt, - }, - }) - .run(); + this.database.sqlite + .prepare( + `insert into workflow_modes (project_workflow_key, mode, updated_at) + values (?, ?, ?) + on conflict(project_workflow_key) do update set mode = excluded.mode, updated_at = excluded.updated_at`, + ) + .run(workflow.key, input.mode, updatedAt); + + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "mode", + entityId: workflow.key, + eventType: "mode.changed", + summary: `Collaboration mode changed to ${input.mode}.`, + createdAt: updatedAt, + }); return { workspaceSessionId: input.workspaceSessionId, + projectWorkflowKey: workflow.key, mode: input.mode, updatedAt, }; @@ -426,22 +987,21 @@ export class SqliteWorkspaceStore implements WorkspaceStore { getCollaborationMode(workspaceSessionId: string): { workspaceSessionId: string; + projectWorkflowKey: string; mode: CollaborationMode; updatedAt: string; } { - const row = this.database.db - .select() - .from(workspaceModes) - .where(eq(workspaceModes.workspaceSessionId, workspaceSessionId)) - .get(); + const workflow = this.workflowForSession(workspaceSessionId); + const row = this.database.sqlite + .prepare("select mode, updated_at from workflow_modes where project_workflow_key = ?") + .get(workflow.key) as { mode: string; updated_at: string } | undefined; - return row - ? rowToWorkspaceMode(row) - : { - workspaceSessionId, - mode: "default", - updatedAt: "", - }; + return { + workspaceSessionId, + projectWorkflowKey: workflow.key, + mode: row?.mode === "plan" ? "plan" : "default", + updatedAt: row?.updated_at ?? "", + }; } createUserInputRequest(input: { @@ -547,6 +1107,244 @@ export class SqliteWorkspaceStore implements WorkspaceStore { this.database.close(); } + private currentPlanRevision(projectWorkflowKey: string): number { + const row = this.database.sqlite + .prepare("select revision from workflow_plans where project_workflow_key = ? and is_current = 1 limit 1") + .get(projectWorkflowKey) as { revision: number } | undefined; + return row?.revision ?? 0; + } + + private currentGoalRevision(projectWorkflowKey: string): number { + const row = this.database.sqlite + .prepare("select revision from workflow_goals where project_workflow_key = ? and is_current = 1 limit 1") + .get(projectWorkflowKey) as { revision: number } | undefined; + return row?.revision ?? 0; + } + + private workflowForSession(workspaceSessionId: string): { key: string; canonicalRoot: string; mode: WorkspaceMode } { + const session = this.getSession(workspaceSessionId); + if (!session) { + throw new Error(`Unknown workspace session: ${workspaceSessionId}`); + } + return this.ensureProjectWorkflow(session.root, session.mode); + } + + private ensureProjectWorkflow(root: string, mode: WorkspaceMode): { + key: string; + canonicalRoot: string; + mode: WorkspaceMode; + } { + const canonicalRoot = canonicalizeRoot(root); + const key = projectWorkflowKeyForRoot(canonicalRoot); + const now = new Date().toISOString(); + const existing = this.database.sqlite + .prepare("select project_workflow_key from project_workflows where project_workflow_key = ?") + .get(key); + + if (existing) { + this.database.sqlite + .prepare("update project_workflows set workspace_kind = ?, updated_at = ? where project_workflow_key = ?") + .run(mode, now, key); + return { key, canonicalRoot, mode }; + } + + const git = readGitIdentity(canonicalRoot); + this.database.sqlite + .prepare( + `insert into project_workflows ( + project_workflow_key, canonical_root, workspace_kind, git_common_dir, git_remote_origin, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(key, canonicalRoot, mode, git.commonDir ?? null, git.remoteOrigin ?? null, now, now); + + return { key, canonicalRoot, mode }; + } + + private getCurrentPlanForWorkflow(projectWorkflowKey: string): WorkspacePlan | undefined { + const row = this.database.sqlite + .prepare( + `select id, project_workflow_key, goal_id, title, summary, + scope_in_json, scope_out_json, validation_json, risks_json, + status, revision, created_at, updated_at, archived_at + from workflow_plans + where project_workflow_key = ? and is_current = 1 + order by updated_at desc, id desc + limit 1`, + ) + .get(projectWorkflowKey) as WorkflowPlanRow | undefined; + return row ? this.rowToWorkspacePlan(row) : undefined; + } + + private ensureGoalMetricsRecord(goalId: string, now: string): void { + this.database.sqlite + .prepare( + `insert into workflow_goal_metrics ( + goal_id, active_work_started_at, accumulated_work_ms, updated_at + ) values (?, null, 0, ?) + on conflict(goal_id) do nothing`, + ) + .run(goalId, now); + } + + private pauseGoalWorkForGoal(goalId: string, now: string): boolean { + const row = this.database.sqlite + .prepare( + `select active_work_started_at, accumulated_work_ms + from workflow_goal_metrics where goal_id = ?`, + ) + .get(goalId) as GoalMetricsRow | undefined; + if (!row?.active_work_started_at) return false; + + const elapsed = elapsedMilliseconds(row.active_work_started_at, now); + const result = this.database.sqlite + .prepare( + `update workflow_goal_metrics + set active_work_started_at = null, accumulated_work_ms = ?, updated_at = ? + where goal_id = ? and active_work_started_at = ?`, + ) + .run(Number(row.accumulated_work_ms) + elapsed, now, goalId, row.active_work_started_at); + return result.changes === 1; + } + + private hydrateGoalMetrics( + goal: WorkspaceGoal, + projectWorkflowKey: string, + measuredAt = new Date().toISOString(), + ): WorkspaceGoal { + const row = this.database.sqlite + .prepare( + `select active_work_started_at, accumulated_work_ms, updated_at + from workflow_goal_metrics where goal_id = ?`, + ) + .get(goal.id) as GoalMetricsRow | undefined; + const usage = this.database.sqlite + .prepare( + `select + coalesce(sum(input_tokens), 0) as input_tokens, + coalesce(sum(output_tokens), 0) as output_tokens, + coalesce(sum(reasoning_tokens), 0) as reasoning_tokens, + coalesce(sum(total_tokens), 0) as total_tokens, + count(*) as report_count, + max(recorded_at) as last_reported_at + from workflow_goal_token_usage where goal_id = ?`, + ) + .get(goal.id) as GoalTokenUsageRow; + const accumulatedMilliseconds = Number(row?.accumulated_work_ms ?? 0); + const liveMilliseconds = row?.active_work_started_at + ? elapsedMilliseconds(row.active_work_started_at, measuredAt) + : 0; + const linkedPlan = this.getCurrentPlanForWorkflow(projectWorkflowKey); + const progress = linkedPlan?.goalId === goal.id + ? goalProgressFromPlan(linkedPlan) + : unlinkedGoalProgress(); + + return { + ...goal, + metrics: { + tokenUsage: { + inputTokens: Number(usage.input_tokens ?? 0), + outputTokens: Number(usage.output_tokens ?? 0), + reasoningTokens: Number(usage.reasoning_tokens ?? 0), + totalTokens: Number(usage.total_tokens ?? 0), + reportCount: Number(usage.report_count ?? 0), + lastReportedAt: usage.last_reported_at ?? undefined, + }, + workDuration: { + running: Boolean(row?.active_work_started_at), + startedAt: row?.active_work_started_at ?? undefined, + accumulatedMilliseconds, + liveMilliseconds, + totalMilliseconds: accumulatedMilliseconds + liveMilliseconds, + measuredAt, + }, + progress, + updatedAt: maxIsoTimestamp(row?.updated_at, usage.last_reported_at), + }, + }; + } + + private insertPlanSteps(plan: WorkspacePlan): void { + const statement = this.database.sqlite.prepare( + `insert into workflow_plan_steps (id, plan_id, position, content, status, note, updated_at) + values (?, ?, ?, ?, ?, ?, ?)`, + ); + for (const [position, step] of plan.steps.entries()) { + statement.run( + step.id ?? randomUUID(), + plan.id, + position, + step.step, + step.status, + step.note ?? null, + step.updatedAt ?? plan.updatedAt, + ); + } + } + + private rowToWorkspacePlan(row: WorkflowPlanRow): WorkspacePlan { + const stepRows = this.database.sqlite + .prepare( + `select id, position, content, status, note, updated_at + from workflow_plan_steps where plan_id = ? order by position asc`, + ) + .all(row.id) as WorkflowPlanStepRow[]; + + return { + id: row.id, + projectWorkflowKey: row.project_workflow_key, + goalId: row.goal_id ?? undefined, + title: row.title, + summary: row.summary ?? undefined, + scopeIn: parseStringList(row.scope_in_json), + scopeOut: parseStringList(row.scope_out_json), + validation: parseStringList(row.validation_json), + risks: parseStringList(row.risks_json), + status: normalizePlanStatus(row.status), + revision: Number(row.revision), + steps: stepRows.map((step) => ({ + id: step.id, + step: step.content, + status: normalizePlanStepStatus(step.status), + note: step.note ?? undefined, + updatedAt: step.updated_at, + })), + createdAt: row.created_at, + updatedAt: row.updated_at, + archivedAt: row.archived_at ?? undefined, + }; + } + + private recordWorkflowEvent(input: Omit): void { + this.database.sqlite + .prepare( + `insert into workflow_events ( + id, project_workflow_key, entity_type, entity_id, event_type, summary, revision, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + randomUUID(), + input.projectWorkflowKey, + input.entityType, + input.entityId, + input.eventType, + truncateText(input.summary, MAX_EVENT_SUMMARY_BYTES), + input.revision ?? null, + input.createdAt, + ); + + this.database.sqlite + .prepare( + `delete from workflow_events + where id in ( + select id from workflow_events + where project_workflow_key = ? + order by created_at desc, id desc + limit -1 offset ? + )`, + ) + .run(input.projectWorkflowKey, MAX_WORKFLOW_EVENTS); + } + private persistUserInputRecord(record: WorkspaceUserInputRecord): WorkspaceUserInputRecord { this.database.db .insert(workspaceUserInputs) @@ -669,6 +1467,155 @@ export class SqliteWorkspaceStore implements WorkspaceStore { references workspace_sessions(id) on delete cascade ); + + create table if not exists project_workflows ( + project_workflow_key text primary key, + canonical_root text not null, + workspace_kind text not null, + git_common_dir text, + git_remote_origin text, + created_at text not null, + updated_at text not null + ); + + create unique index if not exists project_workflows_root_idx + on project_workflows(canonical_root); + + create table if not exists workflow_plans ( + id text primary key, + project_workflow_key text not null, + goal_id text, + title text not null, + summary text, + scope_in_json text not null default '[]', + scope_out_json text not null default '[]', + validation_json text not null default '[]', + risks_json text not null default '[]', + status text not null, + revision integer not null, + is_current integer not null default 1, + created_at text not null, + updated_at text not null, + archived_at text, + foreign key (project_workflow_key) + references project_workflows(project_workflow_key) + on delete cascade + ); + + create unique index if not exists workflow_plans_current_idx + on workflow_plans(project_workflow_key) + where is_current = 1; + + create index if not exists workflow_plans_history_idx + on workflow_plans(project_workflow_key, updated_at desc); + + create table if not exists workflow_plan_steps ( + id text primary key, + plan_id text not null, + position integer not null, + content text not null, + status text not null, + note text, + updated_at text not null, + foreign key (plan_id) + references workflow_plans(id) + on delete cascade + ); + + create unique index if not exists workflow_plan_steps_position_idx + on workflow_plan_steps(plan_id, position); + + create table if not exists workflow_goals ( + id text primary key, + project_workflow_key text not null, + objective text not null, + scope_in_json text not null default '[]', + scope_out_json text not null default '[]', + success_criteria_json text not null default '[]', + verification_json text not null default '[]', + stop_conditions_json text not null default '[]', + current_summary text, + status text not null, + revision integer not null, + is_current integer not null default 1, + created_at text not null, + updated_at text not null, + archived_at text, + foreign key (project_workflow_key) + references project_workflows(project_workflow_key) + on delete cascade + ); + + create unique index if not exists workflow_goals_current_idx + on workflow_goals(project_workflow_key) + where is_current = 1; + + create index if not exists workflow_goals_history_idx + on workflow_goals(project_workflow_key, updated_at desc); + + create table if not exists workflow_goal_metrics ( + goal_id text primary key, + active_work_started_at text, + accumulated_work_ms integer not null default 0, + updated_at text not null, + foreign key (goal_id) + references workflow_goals(id) + on delete cascade + ); + + create table if not exists workflow_goal_token_usage ( + id text primary key, + goal_id text not null, + provider text not null, + provider_request_id text not null, + model text, + input_tokens integer not null, + output_tokens integer not null, + reasoning_tokens integer not null default 0, + total_tokens integer not null, + provider_reported_at text, + recorded_at text not null, + foreign key (goal_id) + references workflow_goals(id) + on delete cascade + ); + + create unique index if not exists workflow_goal_token_usage_dedupe_idx + on workflow_goal_token_usage(goal_id, provider, provider_request_id); + + create index if not exists workflow_goal_token_usage_history_idx + on workflow_goal_token_usage(goal_id, recorded_at desc); + + create table if not exists workflow_modes ( + project_workflow_key text primary key, + mode text not null default 'default', + updated_at text not null, + foreign key (project_workflow_key) + references project_workflows(project_workflow_key) + on delete cascade + ); + + create table if not exists workflow_events ( + id text primary key, + project_workflow_key text not null, + entity_type text not null, + entity_id text not null, + event_type text not null, + summary text not null, + revision integer, + created_at text not null, + foreign key (project_workflow_key) + references project_workflows(project_workflow_key) + on delete cascade + ); + + create index if not exists workflow_events_history_idx + on workflow_events(project_workflow_key, created_at desc, id desc); + + create table if not exists workflow_migrations ( + migration_key text primary key, + completed_at text not null + ); `); this.addColumnIfMissing("workspace_sessions", "mode", "text not null default 'checkout'"); @@ -680,6 +1627,195 @@ export class SqliteWorkspaceStore implements WorkspaceStore { this.addColumnIfMissing("workspace_user_inputs", "delivery_mode", "text"); this.addColumnIfMissing("workspace_user_inputs", "response_json", "text"); this.addColumnIfMissing("workspace_user_inputs", "answered_at", "text"); + + this.database.sqlite + .prepare( + `insert or ignore into workflow_goal_metrics (goal_id, active_work_started_at, accumulated_work_ms, updated_at) + select id, null, 0, updated_at from workflow_goals`, + ) + .run(); + + this.migrateLegacyWorkflowState(); + } + + private migrateLegacyWorkflowState(): void { + const migrationKey = "project-workflow-store-v2"; + const alreadyMigrated = this.database.sqlite + .prepare("select migration_key from workflow_migrations where migration_key = ?") + .get(migrationKey); + if (alreadyMigrated) return; + + this.database.sqlite.transaction(() => { + const sessions = this.database.sqlite + .prepare("select id, root, mode from workspace_sessions") + .all() as Array<{ id: string; root: string; mode: string }>; + const workflows = new Map(); + + for (const session of sessions) { + const mode: WorkspaceMode = session.mode === "worktree" ? "worktree" : "checkout"; + const workflow = this.ensureProjectWorkflow(session.root, mode); + workflows.set(session.id, { key: workflow.key, root: workflow.canonicalRoot, mode }); + } + + const existingCurrentPlans = new Set( + (this.database.sqlite + .prepare("select project_workflow_key from workflow_plans where is_current = 1") + .all() as Array<{ project_workflow_key: string }>) + .map((row) => row.project_workflow_key), + ); + const importedPlans = new Set(); + const legacyPlans = this.database.sqlite + .prepare( + `select workspace_session_id, explanation, steps_json, updated_at + from workspace_plans + order by updated_at desc`, + ) + .all() as LegacyPlanRow[]; + + for (const legacy of legacyPlans) { + const workflow = workflows.get(legacy.workspace_session_id); + if (!workflow || existingCurrentPlans.has(workflow.key)) continue; + const now = legacy.updated_at; + const isCurrent = !importedPlans.has(workflow.key); + const status: PlanStatus = isCurrent ? "active" : "archived"; + const archivedAt = isCurrent ? null : now; + const planId = randomUUID(); + const steps = normalizePlanSteps(parseLegacyPlanSteps(legacy.steps_json), now); + this.database.sqlite + .prepare( + `insert into workflow_plans ( + id, project_workflow_key, goal_id, title, summary, + scope_in_json, scope_out_json, validation_json, risks_json, + status, revision, is_current, created_at, updated_at, archived_at + ) values (?, ?, null, ?, ?, '[]', '[]', '[]', '[]', ?, 1, ?, ?, ?, ?)`, + ) + .run( + planId, + workflow.key, + "Migrated workspace plan", + legacy.explanation ?? null, + status, + isCurrent ? 1 : 0, + now, + now, + archivedAt, + ); + this.insertPlanSteps({ + id: planId, + projectWorkflowKey: workflow.key, + title: "Migrated workspace plan", + summary: legacy.explanation ?? undefined, + scopeIn: [], + scopeOut: [], + validation: [], + risks: [], + status, + revision: 1, + steps, + createdAt: now, + updatedAt: now, + archivedAt: archivedAt ?? undefined, + }); + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "plan", + entityId: planId, + eventType: isCurrent ? "plan.migrated" : "plan.archived_migrated", + summary: isCurrent ? "Migrated legacy workspace plan." : "Archived older legacy workspace plan during migration.", + revision: 1, + createdAt: now, + }); + if (isCurrent) importedPlans.add(workflow.key); + } + + const existingCurrentGoals = new Set( + (this.database.sqlite + .prepare("select project_workflow_key from workflow_goals where is_current = 1") + .all() as Array<{ project_workflow_key: string }>) + .map((row) => row.project_workflow_key), + ); + const importedGoals = new Set(); + const legacyGoals = this.database.sqlite + .prepare( + `select workspace_session_id, objective, status, created_at, updated_at + from workspace_goals + order by updated_at desc`, + ) + .all() as LegacyGoalRow[]; + + for (const legacy of legacyGoals) { + const workflow = workflows.get(legacy.workspace_session_id); + if (!workflow || existingCurrentGoals.has(workflow.key)) continue; + const parsed = parseGoalDefinition(legacy.objective).definition; + const goalId = randomUUID(); + const isCurrent = !importedGoals.has(workflow.key); + const status: GoalStatus = isCurrent ? normalizeGoalStatus(legacy.status) : "archived"; + const archivedAt = isCurrent ? null : legacy.updated_at; + this.database.sqlite + .prepare( + `insert into workflow_goals ( + id, project_workflow_key, objective, scope_in_json, scope_out_json, + success_criteria_json, verification_json, stop_conditions_json, current_summary, + status, revision, is_current, created_at, updated_at, archived_at + ) values (?, ?, ?, ?, ?, '[]', ?, ?, null, ?, 1, ?, ?, ?, ?)`, + ) + .run( + goalId, + workflow.key, + normalizeRequiredText(parsed.objective, "Goal objective"), + JSON.stringify(parsed.scope?.in ?? []), + JSON.stringify(parsed.scope?.out ?? []), + JSON.stringify(parsed.verification ?? []), + JSON.stringify(parsed.stopConditions ?? []), + status, + isCurrent ? 1 : 0, + legacy.created_at, + legacy.updated_at, + archivedAt, + ); + this.ensureGoalMetricsRecord(goalId, legacy.updated_at); + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goalId, + eventType: isCurrent ? "goal.migrated" : "goal.archived_migrated", + summary: isCurrent ? "Migrated legacy workspace goal." : "Archived older legacy workspace goal during migration.", + revision: 1, + createdAt: legacy.updated_at, + }); + if (isCurrent) importedGoals.add(workflow.key); + } + + const existingModes = new Set( + (this.database.sqlite + .prepare("select project_workflow_key from workflow_modes") + .all() as Array<{ project_workflow_key: string }>) + .map((row) => row.project_workflow_key), + ); + const importedModes = new Set(); + const legacyModes = this.database.sqlite + .prepare( + `select workspace_session_id, mode, updated_at + from workspace_modes + order by updated_at desc`, + ) + .all() as LegacyModeRow[]; + + for (const legacy of legacyModes) { + const workflow = workflows.get(legacy.workspace_session_id); + if (!workflow || existingModes.has(workflow.key) || importedModes.has(workflow.key)) continue; + this.database.sqlite + .prepare( + "insert into workflow_modes (project_workflow_key, mode, updated_at) values (?, ?, ?)", + ) + .run(workflow.key, legacy.mode === "plan" ? "plan" : "default", legacy.updated_at); + importedModes.add(workflow.key); + } + + this.database.sqlite + .prepare("insert into workflow_migrations (migration_key, completed_at) values (?, ?)") + .run(migrationKey, new Date().toISOString()); + })(); } private addColumnIfMissing(table: string, column: string, definition: string): void { @@ -696,6 +1832,41 @@ export function createWorkspaceStore(stateDir: string): WorkspaceStore { return new SqliteWorkspaceStore(stateDir); } +export function projectWorkflowKeyForRoot(root: string): string { + return `pw_${createHash("sha256").update(`v1:${canonicalizeRoot(root)}`).digest("hex")}`; +} + +function canonicalizeRoot(root: string): string { + try { + return realpathSync.native(root); + } catch { + return resolve(root); + } +} + +function readGitIdentity(root: string): { commonDir?: string; remoteOrigin?: string } { + const commonDir = runGitForMetadata(root, ["rev-parse", "--git-common-dir"]); + if (!commonDir) return {}; + const remoteOrigin = runGitForMetadata(root, ["remote", "get-url", "origin"]); + return { + commonDir: canonicalizeRoot(resolve(root, commonDir)), + remoteOrigin: remoteOrigin || undefined, + }; +} + +function runGitForMetadata(root: string, args: string[]): string | undefined { + try { + const value = execFileSync("git", ["-C", root, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1_500, + }).trim(); + return value || undefined; + } catch { + return undefined; + } +} + function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { return { id: row.id, @@ -711,44 +1882,23 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { }; } -function rowToWorkspacePlan(row: WorkspacePlanRow): WorkspacePlan { +function rowToWorkspaceGoal(row: WorkflowGoalRow): WorkspaceGoal { return { - workspaceSessionId: row.workspaceSessionId, - explanation: row.explanation ?? undefined, - steps: parsePlanSteps(row.stepsJson), - updatedAt: row.updatedAt, - }; -} - -function rowToWorkspaceGoal(row: WorkspaceGoalRow): WorkspaceGoal { - return { - workspaceSessionId: row.workspaceSessionId, + id: row.id, + projectWorkflowKey: row.project_workflow_key, objective: row.objective, - status: - row.status === "complete" ? "complete" : row.status === "blocked" ? "blocked" : "active", - tokenBudget: row.tokenBudget === null ? undefined : Number(row.tokenBudget), - createdAt: row.createdAt, - updatedAt: row.updatedAt, - timeUsedSeconds: computePersistedGoalTimeUsedSeconds( - row.createdAt, - row.updatedAt, - row.activeSeconds, - row.status, - ), - completedAt: row.completedAt ?? undefined, - blockedAt: row.blockedAt ?? undefined, - }; -} - -function rowToWorkspaceMode(row: WorkspaceModeRow): { - workspaceSessionId: string; - mode: CollaborationMode; - updatedAt: string; -} { - return { - workspaceSessionId: row.workspaceSessionId, - mode: row.mode === "plan" ? "plan" : "default", - updatedAt: row.updatedAt, + scopeIn: parseStringList(row.scope_in_json), + scopeOut: parseStringList(row.scope_out_json), + successCriteria: parseStringList(row.success_criteria_json), + verification: parseStringList(row.verification_json), + stopConditions: parseStringList(row.stop_conditions_json), + currentSummary: row.current_summary ?? undefined, + status: normalizeGoalStatus(row.status), + revision: Number(row.revision), + metrics: emptyGoalMetrics(row.updated_at), + createdAt: row.created_at, + updatedAt: row.updated_at, + archivedAt: row.archived_at ?? undefined, }; } @@ -777,62 +1927,220 @@ function rowToWorkspaceUserInput(row: WorkspaceUserInputRow): WorkspaceUserInput }; } -function parsePlanSteps(value: string): WorkspacePlanStep[] { - const parsed = JSON.parse(value) as unknown; - if (!Array.isArray(parsed)) return []; +function emptyGoalMetrics(measuredAt: string): GoalMetrics { + return { + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + reportCount: 0, + }, + workDuration: { + running: false, + accumulatedMilliseconds: 0, + liveMilliseconds: 0, + totalMilliseconds: 0, + measuredAt, + }, + progress: unlinkedGoalProgress(), + }; +} + +function unlinkedGoalProgress(): GoalProgress { + return { + source: "unlinked", + completedSteps: 0, + totalSteps: 0, + }; +} + +function goalProgressFromPlan(plan: WorkspacePlan): GoalProgress { + const completedSteps = plan.steps.filter((step) => step.status === "completed").length; + const totalSteps = plan.steps.length; + if (totalSteps === 0) return unlinkedGoalProgress(); - return parsed.flatMap((item) => { - if (!item || typeof item !== "object") return []; - const step = "step" in item && typeof item.step === "string" ? item.step : undefined; - const status = - "status" in item && typeof item.status === "string" ? item.status : undefined; - if (!step) return []; - if (status !== "pending" && status !== "in_progress" && status !== "completed") return []; + return { + source: "linked_plan_steps", + completedSteps, + totalSteps, + exactFraction: `${completedSteps}/${totalSteps}`, + percentageNumerator: completedSteps * 100, + percentageDenominator: totalSteps, + displayPercent: formatExactPercent(completedSteps, totalSteps), + }; +} - return [{ step, status }]; - }); +function formatExactPercent(completed: number, total: number): string { + const scale = 100n; + const numerator = BigInt(completed) * 100n * scale; + const denominator = BigInt(total); + const rounded = (numerator + denominator / 2n) / denominator; + const integer = rounded / scale; + const decimal = (rounded % scale).toString().padStart(2, "0"); + return `${integer}.${decimal}%`; } -function calculateGoalActiveSeconds(existing: WorkspaceGoal, updatedAt: string): number { - const createdAtMs = Date.parse(existing.createdAt); - const updatedAtMs = Date.parse(updatedAt); - if (!Number.isFinite(createdAtMs) || !Number.isFinite(updatedAtMs)) { - return existing.timeUsedSeconds; - } +function elapsedMilliseconds(startedAt: string, measuredAt: string): number { + const start = Date.parse(startedAt); + const end = Date.parse(measuredAt); + if (!Number.isFinite(start) || !Number.isFinite(end)) return 0; + return Math.max(0, end - start); +} - return Math.max(existing.timeUsedSeconds, Math.floor((updatedAtMs - createdAtMs) / 1000)); +function maxIsoTimestamp(...values: Array): string | undefined { + return values + .filter((value): value is string => Boolean(value)) + .sort() + .at(-1); } -function computePersistedGoalTimeUsedSeconds( - createdAt: string, - updatedAt: string, - activeSeconds: string | null, - status: string, -): number { - const persisted = activeSeconds === null ? NaN : Number(activeSeconds); - if (Number.isFinite(persisted)) { - if (status === "active") { - const createdAtMs = Date.parse(createdAt); - const updatedAtMs = Date.now(); - if (Number.isFinite(createdAtMs) && Number.isFinite(updatedAtMs)) { - return Math.max(persisted, Math.floor((updatedAtMs - createdAtMs) / 1000)); - } +function validateTokenUsage(input: { + inputTokens: number; + outputTokens: number; + reasoningTokens?: number; + totalTokens: number; + providerReportedAt?: string; +}): void { + for (const [label, value] of [ + ["inputTokens", input.inputTokens], + ["outputTokens", input.outputTokens], + ["reasoningTokens", input.reasoningTokens ?? 0], + ["totalTokens", input.totalTokens], + ] as const) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer reported by the provider.`); } + } + if (input.providerReportedAt && !Number.isFinite(Date.parse(input.providerReportedAt))) { + throw new Error("providerReportedAt must be a valid ISO timestamp."); + } +} + +function normalizePlanSteps(steps: WorkspacePlanStep[], updatedAt: string): WorkspacePlanStep[] { + return steps.map((step) => ({ + id: step.id ?? randomUUID(), + step: normalizeRequiredText(step.step, "Plan step"), + status: normalizePlanStepStatus(step.status), + note: normalizeOptionalText(step.note, MAX_WORKFLOW_TEXT_BYTES), + updatedAt, + })); +} - return persisted; +function parseLegacyPlanSteps(value: string): WorkspacePlanStep[] { + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const candidate = item as { step?: unknown; content?: unknown; status?: unknown; note?: unknown }; + const step = typeof candidate.step === "string" + ? candidate.step + : typeof candidate.content === "string" + ? candidate.content + : undefined; + if (!step) return []; + return [{ + step, + status: normalizePlanStepStatus(typeof candidate.status === "string" ? candidate.status : "pending"), + note: typeof candidate.note === "string" ? candidate.note : undefined, + }]; + }); + } catch { + return []; } +} - const createdAtMs = Date.parse(createdAt); - const endMs = status === "active" ? Date.now() : Date.parse(updatedAt); - if (!Number.isFinite(createdAtMs) || !Number.isFinite(endMs)) return 0; - return Math.max(0, Math.floor((endMs - createdAtMs) / 1000)); +function validatePlanSteps(steps: WorkspacePlanStep[]): void { + if (steps.length === 0) { + throw new Error("A plan must include at least one step."); + } + if (steps.length > 100) { + throw new Error("A plan may not contain more than 100 steps."); + } + const inProgressCount = steps.filter((step) => step.status === "in_progress").length; + if (inProgressCount > 1) { + throw new Error("A plan may have at most one in_progress step."); + } +} + +function normalizePlanStatus(value: string): PlanStatus { + if (value === "draft" || value === "completed" || value === "archived") return value; + return "active"; +} + +function normalizePlanStepStatus(value: string): PlanStepStatus { + if ( + value === "pending" || + value === "in_progress" || + value === "blocked" || + value === "completed" || + value === "skipped" + ) { + return value; + } + return "pending"; +} + +function normalizeGoalStatus(value: string): GoalStatus { + if (value === "blocked" || value === "completed" || value === "archived") return value; + if (value === "complete") return "completed"; + return "active"; +} + +function normalizeStringList(values: string[]): string[] { + const normalized = values + .map((value) => normalizeOptionalText(value, MAX_WORKFLOW_TEXT_BYTES)) + .filter((value): value is string => Boolean(value)); + const serialized = JSON.stringify(normalized); + assertTextLimit(serialized, MAX_WORKFLOW_TEXT_BYTES, "Workflow list"); + return normalized; +} + +function parseStringList(value: string | null | undefined): string[] { + if (!value) return []; + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : []; + } catch { + return []; + } +} + +function normalizeRequiredText(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + assertTextLimit(normalized, MAX_WORKFLOW_TEXT_BYTES, label); + return normalized; +} + +function normalizeOptionalText(value: string | undefined, maxBytes: number): string | undefined { + if (value === undefined) return undefined; + const normalized = value.trim(); + if (!normalized) return undefined; + assertTextLimit(normalized, maxBytes, "Workflow text"); + return normalized; +} + +function assertTextLimit(value: string, maxBytes: number, label: string): void { + if (Buffer.byteLength(value, "utf8") > maxBytes) { + throw new Error(`${label} exceeds the ${maxBytes}-byte limit.`); + } +} + +function truncateText(value: string, maxBytes: number): string { + if (Buffer.byteLength(value, "utf8") <= maxBytes) return value; + let end = Math.max(0, Math.floor(maxBytes / 2)); + while (Buffer.byteLength(value.slice(0, end), "utf8") > maxBytes - 3 && end > 0) end--; + return `${value.slice(0, end)}...`; } function normalizeUserInputStatus(value: string): UserInputStatus { if (value === "completed" || value === "declined" || value === "cancelled") { return value; } - return "pending"; } @@ -842,6 +2150,128 @@ function normalizeUserInputDeliveryMode( if (value === "elicitation" || value === "tool" || value === "ui") { return value; } - return undefined; } + +function encodeHistoryCursor(cursor: { createdAt: string; id: string }): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +function decodeHistoryCursor(cursor: string | undefined): { createdAt: string; id: string } | undefined { + if (!cursor) return undefined; + try { + const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { + createdAt?: unknown; + id?: unknown; + }; + if (typeof parsed.createdAt !== "string" || typeof parsed.id !== "string") { + throw new Error("invalid cursor"); + } + return { createdAt: parsed.createdAt, id: parsed.id }; + } catch { + throw new Error("Invalid workflow history cursor."); + } +} + +function rowToWorkflowEvent(row: WorkflowEventRow): WorkflowEvent { + return { + id: row.id, + projectWorkflowKey: row.project_workflow_key, + entityType: row.entity_type === "goal" || row.entity_type === "mode" ? row.entity_type : "plan", + entityId: row.entity_id, + eventType: row.event_type, + summary: row.summary, + revision: row.revision === null ? undefined : Number(row.revision), + createdAt: row.created_at, + }; +} + +interface GoalMetricsRow { + active_work_started_at: string | null; + accumulated_work_ms: number; + updated_at: string; +} + +interface GoalTokenUsageRow { + input_tokens: number | null; + output_tokens: number | null; + reasoning_tokens: number | null; + total_tokens: number | null; + report_count: number | null; + last_reported_at: string | null; +} + +interface WorkflowPlanRow { + id: string; + project_workflow_key: string; + goal_id: string | null; + title: string; + summary: string | null; + scope_in_json: string; + scope_out_json: string; + validation_json: string; + risks_json: string; + status: string; + revision: number; + created_at: string; + updated_at: string; + archived_at: string | null; +} + +interface WorkflowPlanStepRow { + id: string; + position: number; + content: string; + status: string; + note: string | null; + updated_at: string; +} + +interface WorkflowGoalRow { + id: string; + project_workflow_key: string; + objective: string; + scope_in_json: string; + scope_out_json: string; + success_criteria_json: string; + verification_json: string; + stop_conditions_json: string; + current_summary: string | null; + status: string; + revision: number; + created_at: string; + updated_at: string; + archived_at: string | null; +} + +interface WorkflowEventRow { + id: string; + project_workflow_key: string; + entity_type: string; + entity_id: string; + event_type: string; + summary: string; + revision: number | null; + created_at: string; +} + +interface LegacyPlanRow { + workspace_session_id: string; + explanation: string | null; + steps_json: string; + updated_at: string; +} + +interface LegacyGoalRow { + workspace_session_id: string; + objective: string; + status: string; + created_at: string; + updated_at: string; +} + +interface LegacyModeRow { + workspace_session_id: string; + mode: string; + updated_at: string; +} diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index f3afe5c..f6b20cd 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -121,7 +121,11 @@ try { }); const savedPlan = firstStore.savePlan({ workspaceSessionId: persistentWorkspace.workspace.id, - explanation: "Track work in small steps", + expectedRevision: 0, + title: "Workflow state migration", + summary: "Track work in small steps", + scopeIn: ["project workflow state"], + validation: ["npm test"], steps: [ { step: "Inspect repo", status: "completed" }, { step: "Implement plan tools", status: "in_progress" }, @@ -152,10 +156,22 @@ try { assert.equal(savedPrompt.status, "pending"); const savedGoal = firstStore.saveGoal({ workspaceSessionId: persistentWorkspace.workspace.id, - objective: "Ship Codex-style planning support", - tokenBudget: 1200, + objective: "Ship project-scoped workflow support", + successCriteria: ["A new session resumes the current Plan and Goal"], + verification: ["npm test"], + currentSummary: "Current: migrate workflow state.", }); assert.equal(savedGoal.status, "active"); + const sameProjectSession = await persistentRegistry.openWorkspace(root); + assert.notEqual(sameProjectSession.workspace.id, persistentWorkspace.workspace.id); + assert.equal( + firstStore.getPlan(sameProjectSession.workspace.id)?.projectWorkflowKey, + savedPlan.projectWorkflowKey, + ); + assert.equal(firstStore.getGoal(sameProjectSession.workspace.id)?.objective, savedGoal.objective); + assert.equal(firstStore.getCollaborationMode(sameProjectSession.workspace.id).mode, "plan"); + assert.equal(firstStore.getWorkflowDigest(sameProjectSession.workspace.id).hasActivePlan, true); + assert.equal(firstStore.getWorkflowDigest(persistentWorktree.workspace.id).hasActivePlan, false); const blockedGoal = firstStore.updateGoalStatus({ workspaceSessionId: persistentWorkspace.workspace.id, status: "blocked", @@ -174,8 +190,10 @@ try { assert.equal(restoredWorkspace.root, root); assert.equal(restoredWorkspace.mode, "checkout"); const restoredPlan = secondStore.getPlan(persistentWorkspace.workspace.id); - assert.equal(restoredPlan?.explanation, "Track work in small steps"); + assert.equal(restoredPlan?.title, "Workflow state migration"); + assert.equal(restoredPlan?.summary, "Track work in small steps"); assert.equal(restoredPlan?.steps[1]?.status, "in_progress"); + assert.equal(restoredPlan?.revision, 1); const restoredMode = secondStore.getCollaborationMode(persistentWorkspace.workspace.id); assert.equal(restoredMode.mode, "plan"); const restoredPrompt = secondStore.getPendingUserInput(persistentWorkspace.workspace.id); @@ -184,8 +202,9 @@ try { const restoredGoal = secondStore.getGoal(persistentWorkspace.workspace.id); assert.equal(restoredGoal?.objective, "Retry Codex-style planning support"); assert.equal(restoredGoal?.status, "active"); - assert.equal(restoredGoal?.tokenBudget, undefined); - assert.equal(typeof restoredGoal?.timeUsedSeconds, "number"); + assert.equal(restoredGoal?.revision, 1); + assert.equal("tokenBudget" in (restoredGoal ?? {}), false); + assert.equal("timeUsedSeconds" in (restoredGoal ?? {}), false); assert.throws( () => secondStore.saveGoal({ @@ -198,7 +217,7 @@ try { workspaceSessionId: persistentWorkspace.workspace.id, status: "complete", }); - assert.equal(completedGoal.status, "complete"); + assert.equal(completedGoal.status, "completed"); const restoredWorktree = restoredRegistry.getWorkspace(persistentWorktree.workspace.id); assert.equal(restoredWorktree.mode, "worktree"); From 1f96992db2e48c7e72b5cd342e9121c97683d0aa Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Sun, 21 Jun 2026 23:47:11 +0000 Subject: [PATCH 26/41] refactor(skills): simplify system skill layout --- README.md | 7 +- README.zh-CN.md | 8 +- docs/chatgpt-coding-workflow.md | 9 +- docs/openai-skills-vendor.md | 34 ---- package.json | 1 - scripts/vendor-openai-skills.mjs | 137 ------------- skills/.system/README.md | 32 +++ skills/.system/architecture-review/SKILL.md | 34 ++++ .../references/decision-guide.md | 19 ++ skills/.system/create-plan/SKILL.md | 64 ------ skills/.system/define-goal/SKILL.md | 45 ----- skills/.system/devspace-goal/SKILL.md | 60 ------ .../references/goal-conflicts.md | 18 -- .../devspace-goal/references/goal-state.md | 47 ----- skills/.system/devspace-plan/SKILL.md | 70 ------- .../references/plan-conflicts.md | 17 -- .../devspace-plan/references/plan-output.md | 11 - .../devspace-plan/references/plan-state.md | 26 --- skills/.system/devspace-workflow/SKILL.md | 45 ----- .../references/command-routing.md | 11 - .../devspace-workflow/references/commands.md | 23 --- .../devspace-workflow/references/examples.md | 28 --- .../devspace-workflow/references/style.md | 12 -- .../references/workflow-recovery.md | 10 - skills/.system/goal/SKILL.md | 36 ++++ skills/.system/goal/references/conflicts.md | 18 ++ skills/.system/goal/references/metrics.md | 25 +++ skills/.system/goal/references/state.md | 17 ++ skills/.system/plan/SKILL.md | 55 +++++ skills/.system/plan/references/conflicts.md | 14 ++ skills/.system/plan/references/output.md | 11 + skills/.system/plan/references/state.md | 28 +++ skills/.system/senior-architect-lite/SKILL.md | 48 ----- .../references/decision-guide.md | 21 -- .../senior-architect-lite/references/style.md | 12 -- skills/.system/senior-architect/SKILL.md | 34 ---- skills/.system/skill-authoring-lite/SKILL.md | 45 ----- .../references/structure-checklist.md | 14 -- skills/.system/skill-authoring/SKILL.md | 37 ++-- .../references/structure-checklist.md | 14 ++ skills/.system/workflow/SKILL.md | 41 ++++ skills/.system/workflow/references/mode.md | 13 ++ .../.system/workflow/references/recovery.md | 10 + skills/.system/workflow/references/routing.md | 14 ++ src/package-smoke.test.ts | 26 ++- src/prompting.test.ts | 4 +- src/prompting.ts | 6 +- src/server.ts | 7 +- src/skill-manager.test.ts | 6 +- src/skill-manager.ts | 7 +- src/skills.test.ts | 63 +++--- src/skills.ts | 188 ++++-------------- 52 files changed, 510 insertions(+), 1072 deletions(-) delete mode 100644 docs/openai-skills-vendor.md delete mode 100644 scripts/vendor-openai-skills.mjs create mode 100644 skills/.system/README.md create mode 100644 skills/.system/architecture-review/SKILL.md create mode 100644 skills/.system/architecture-review/references/decision-guide.md delete mode 100644 skills/.system/create-plan/SKILL.md delete mode 100644 skills/.system/define-goal/SKILL.md delete mode 100644 skills/.system/devspace-goal/SKILL.md delete mode 100644 skills/.system/devspace-goal/references/goal-conflicts.md delete mode 100644 skills/.system/devspace-goal/references/goal-state.md delete mode 100644 skills/.system/devspace-plan/SKILL.md delete mode 100644 skills/.system/devspace-plan/references/plan-conflicts.md delete mode 100644 skills/.system/devspace-plan/references/plan-output.md delete mode 100644 skills/.system/devspace-plan/references/plan-state.md delete mode 100644 skills/.system/devspace-workflow/SKILL.md delete mode 100644 skills/.system/devspace-workflow/references/command-routing.md delete mode 100644 skills/.system/devspace-workflow/references/commands.md delete mode 100644 skills/.system/devspace-workflow/references/examples.md delete mode 100644 skills/.system/devspace-workflow/references/style.md delete mode 100644 skills/.system/devspace-workflow/references/workflow-recovery.md create mode 100644 skills/.system/goal/SKILL.md create mode 100644 skills/.system/goal/references/conflicts.md create mode 100644 skills/.system/goal/references/metrics.md create mode 100644 skills/.system/goal/references/state.md create mode 100644 skills/.system/plan/SKILL.md create mode 100644 skills/.system/plan/references/conflicts.md create mode 100644 skills/.system/plan/references/output.md create mode 100644 skills/.system/plan/references/state.md delete mode 100644 skills/.system/senior-architect-lite/SKILL.md delete mode 100644 skills/.system/senior-architect-lite/references/decision-guide.md delete mode 100644 skills/.system/senior-architect-lite/references/style.md delete mode 100644 skills/.system/senior-architect/SKILL.md delete mode 100644 skills/.system/skill-authoring-lite/SKILL.md delete mode 100644 skills/.system/skill-authoring-lite/references/structure-checklist.md create mode 100644 skills/.system/skill-authoring/references/structure-checklist.md create mode 100644 skills/.system/workflow/SKILL.md create mode 100644 skills/.system/workflow/references/mode.md create mode 100644 skills/.system/workflow/references/recovery.md create mode 100644 skills/.system/workflow/references/routing.md diff --git a/README.md b/README.md index 98e28f6..4e06119 100644 --- a/README.md +++ b/README.md @@ -183,14 +183,13 @@ DevSpace bundles durable workflow Skills rather than short prompt examples. Core Project Skill directories are split by purpose: -- `skills/.system`: DevSpace core workflow Skills committed with DevSpace -- `skills/.system/openai/skills` (when vendored): reviewed OpenAI Skills, synchronized manually and never updated at runtime +- `skills/.system`: exactly five DevSpace-owned system Skills: `plan`, `goal`, `workflow`, `architecture-review`, and `skill-authoring` - `skills/local`: project-defined Skills you want to keep in version control -- `skills/installed`: user-installed project Skills, ignored by git by default +- `skills/installed`: user-installed external Skills, ignored by git by default ChatGPT Plus on the web cannot natively install or register Codex Skills. DevSpace provides MCP-side discovery, resolution, and controlled `skill://` resource access instead. -`@devspace /plan` and `@devspace /goal` are stable alias-style workflow conventions. `/plan` always resolves to `devspace-plan`; `/goal` always resolves to `devspace-goal`; local or vendored Skills cannot silently override them. See [vendored OpenAI Skills](docs/openai-skills-vendor.md) for the manual review and synchronization policy. +`@devspace /plan` and `@devspace /goal` are stable alias-style workflow conventions. `/plan` always resolves to system `plan`; `/goal` always resolves to system `goal`; local, installed, and global Skills cannot silently override them. `skills/.system/README.md` records the system Skill policy and change log. Manage installed skills with: diff --git a/README.zh-CN.md b/README.zh-CN.md index dfce7e9..1885b2e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -162,17 +162,17 @@ DevSpace 为 ChatGPT 提供了这些能力: - 从你的技能目录中发现本地 agent skills - 在兼容 ChatGPT Apps 的宿主中显示工具卡片和可选的变更摘要 -DevSpace 还内置了一小组工作流与工程技能。这些技能的结构灵感来自 [alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills),后者采用 MIT 许可证发布。 +DevSpace 内置一组稳定的工作流与工程技能,用于 Plan、Goal、跨会话恢复、架构审查和 Skill 编写。 项目技能目录按用途拆分为: -- DevSpace 自带并随项目提交的 system 内置技能 +- `skills/.system`:DevSpace 自己维护的 5 个系统技能:`plan`、`goal`、`workflow`、`architecture-review`、`skill-authoring` - `skills/local`:你希望随项目版本控制保存的项目自定义技能 -- `skills/installed`:用户安装的项目技能,默认被 git 忽略 +- `skills/installed`:按需安装的外部技能,默认被 git 忽略 网页版 ChatGPT Plus 不能原生安装或注册 Codex Skills。DevSpace 改为在 MCP 这一侧提供技能安装、发现和解析这一层能力。 -`@devspace /plan` 和 `@devspace /goal` 只是别名风格的工作流约定,不是 ChatGPT 原生斜杠命令。 +`@devspace /plan` 和 `@devspace /goal` 只是别名风格的工作流约定,不是 ChatGPT 原生斜杠命令。`/plan` 固定对应系统 `plan`,`/goal` 固定对应系统 `goal`;本地、已安装和全局技能都不能覆盖它们。 用这些命令管理已安装技能: diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 058013d..41caf4c 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -80,8 +80,7 @@ Skills are enabled by default for coding-agent workflows. DevSpace discovers Skills from: -- DevSpace core workflow Skills in `skills/.system` -- vendored OpenAI Skills in `skills/.system/openai/skills` when present +- five DevSpace system Skills in `skills/.system`: `plan`, `goal`, `workflow`, `architecture-review`, and `skill-authoring` - workspace-local Skills in `skills/local` - workspace-installed Skills in `skills/installed` - `DEVSPACE_AGENT_DIR`, which defaults to `~/.codex` @@ -89,7 +88,7 @@ DevSpace discovers Skills from: ChatGPT Plus on the web cannot natively install or register Codex Skills. In this setup, DevSpace provides MCP-based skill installation, discovery, and resolution. -`@devspace /plan` and `@devspace /goal` are workflow aliases, not native ChatGPT slash commands. `/plan` always resolves to `devspace-plan`; `/goal` always resolves to `devspace-goal`. Vendored and project-local Skills cannot silently override either alias. +`@devspace /plan` and `@devspace /goal` are workflow aliases, not native ChatGPT slash commands. `/plan` always resolves to system `plan`; `/goal` always resolves to system `goal`. Project-local, installed, and global Skills cannot silently override either alias. User-installed project skills can be managed through DevSpace itself: @@ -117,7 +116,7 @@ User-installed project skills can be managed through DevSpace itself: @devspace /goal 将 DevSpace 的第三方 Skill 安装流程收敛为可测试、可回滚、跨平台兼容的实现 ``` -`open_workspace` returns core and project Skill metadata only, capped at 24 entries, plus a source-count summary. Use `resolve_skill` to load the full `SKILL.md` once a Skill is selected. Use `search_skills` to discover vendored or additional Skills without loading every Skill instruction into context. +`open_workspace` returns system and project Skill metadata only, capped at 24 entries, plus a source-count summary. Use `resolve_skill` to load the full `SKILL.md` once a Skill is selected. Use `search_skills` to discover additional local, installed, or global Skills without loading every Skill instruction into context. Skill resources use `skill://` locators. DevSpace only permits reading: @@ -126,7 +125,7 @@ Skill resources use `skill://` locators. DevSpace only permits reading: Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. -DevSpace core Skills define the stable `/plan`, `/goal`, workflow recovery, and MCP Tool contracts. Vendored OpenAI Skills are an optional, manually reviewed source and never control the core aliases. +DevSpace system Skills define the stable `/plan`, `/goal`, workflow recovery, and MCP Tool contracts. External Skills are installed only when needed and never control the core aliases. ## Project Workflow Store diff --git a/docs/openai-skills-vendor.md b/docs/openai-skills-vendor.md deleted file mode 100644 index ec81cb1..0000000 --- a/docs/openai-skills-vendor.md +++ /dev/null @@ -1,34 +0,0 @@ -# Vendored OpenAI Skills - -DevSpace can include a reviewed local copy of the upstream `openai/skills` `skills/` directory at `skills/.system/openai/skills/`. - -DevSpace never fetches this source while serving MCP requests. The core aliases stay independent: - -```text -/plan -> devspace-plan -/goal -> devspace-goal -``` - -Vendored Skills are optional discovery material. They cannot silently replace either core alias. - -## Manual synchronization - -Clone or update `https://github.com/openai/skills.git` outside the DevSpace runtime, review the target commit and every changed Skill, then run the maintainer-only helper: - -```bash -npm run vendor:openai-skills -- --source /absolute/path/to/openai-skills --check -npm run vendor:openai-skills -- --source /absolute/path/to/openai-skills --apply -``` - -The helper verifies that the reviewed clone has the official `openai/skills` origin, stages a local copy, swaps the vendor tree only after staging succeeds, and writes `skills/.system/openai/UPSTREAM.md` with the full commit SHA and sync date. - -Before committing a vendor update, inspect the diff, preserve every upstream `LICENSE.txt`, and run: - -```bash -npm run typecheck -npm run test -npm run build -npm pack --dry-run -``` - -Do not run the helper from DevSpace startup, an MCP Tool, a package-install hook, or a scheduled task. Upstream changes become active only after a maintainer reviews and commits them. diff --git a/package.json b/package.json index c8c5d13..1c97ecc 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "vendor:openai-skills": "node scripts/vendor-openai-skills.mjs", "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/config-operations.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/skill-manager.test.ts && node --import tsx src/cli-skills.test.ts && node --import tsx src/goal-definition.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspace-operations.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/workflow-store.test.ts && node --import tsx src/workflow-migration.test.ts && node --import tsx src/package-smoke.test.ts && node --import tsx src/review-checkpoints.test.ts && node --import tsx src/service.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/scripts/vendor-openai-skills.mjs b/scripts/vendor-openai-skills.mjs deleted file mode 100644 index 494d426..0000000 --- a/scripts/vendor-openai-skills.mjs +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env node -/** - * Maintainer-only helper for a reviewed local clone of openai/skills. - * DevSpace never invokes this at runtime. - * - * Usage: - * node scripts/vendor-openai-skills.mjs --source /path/to/openai-skills --check - * node scripts/vendor-openai-skills.mjs --source /path/to/openai-skills --apply - */ - -import { execFileSync } from "node:child_process"; -import { cp, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const vendorRoot = resolve(repositoryRoot, "skills", ".system", "openai"); -const expectedRemote = "https://github.com/openai/skills"; -const { source, apply } = parseArguments(process.argv.slice(2)); - -if (!source) { - fail("Usage: node scripts/vendor-openai-skills.mjs --source /path/to/openai-skills --check|--apply"); -} - -const sourceRoot = resolve(source); -const sourceSkills = resolve(sourceRoot, "skills"); -const sourceStat = await stat(sourceSkills).catch(() => undefined); -if (!sourceStat?.isDirectory()) { - fail(`Missing upstream skills directory: ${sourceSkills}`); -} - -const remote = git(sourceRoot, ["remote", "get-url", "origin"]); -if (!remote || normalizeRemote(remote) !== expectedRemote) { - fail(`Reviewed source must have origin ${expectedRemote}; got ${remote ?? ""}.`); -} -const commit = git(sourceRoot, ["rev-parse", "HEAD"]); -if (!commit || !/^[0-9a-f]{40}$/.test(commit)) { - fail("Could not resolve a full upstream commit SHA."); -} - -const fileCount = Number(git(sourceRoot, ["ls-files", "skills"])?.split("\n").filter(Boolean).length ?? 0); -if (!fileCount) fail("No tracked files found below upstream skills/."); - -const summary = [ - `Source: ${expectedRemote}`, - `Commit: ${commit}`, - `Tracked upstream skill files: ${fileCount}`, - `Destination: ${resolve(vendorRoot, "skills")}`, -].join("\n"); - -if (!apply) { - process.stdout.write(`${summary}\n\nCheck only. Review the upstream diff and licenses, then rerun with --apply.\n`); - process.exit(0); -} - -const stagingRoot = resolve(tmpdir(), `devspace-openai-skills-${process.pid}-${Date.now()}`); -const stagingSkills = resolve(stagingRoot, "skills"); -const destinationSkills = resolve(vendorRoot, "skills"); -const backupSkills = resolve(vendorRoot, `.skills-backup-${Date.now()}`); - -try { - await mkdir(stagingRoot, { recursive: true }); - await cp(sourceSkills, stagingSkills, { recursive: true, dereference: false, force: true }); - await mkdir(vendorRoot, { recursive: true }); - - if (existsSync(destinationSkills)) { - await rename(destinationSkills, backupSkills); - } - - try { - await rename(stagingSkills, destinationSkills); - await writeFile(resolve(vendorRoot, "UPSTREAM.md"), upstreamManifest(commit), "utf8"); - await rm(backupSkills, { recursive: true, force: true }); - } catch (error) { - await rm(destinationSkills, { recursive: true, force: true }); - if (existsSync(backupSkills)) await rename(backupSkills, destinationSkills); - throw error; - } - - process.stdout.write(`${summary}\n\nVendor copy updated. Review git diff and run npm run typecheck, npm run test, npm run build, and npm pack --dry-run.\n`); -} finally { - await rm(stagingRoot, { recursive: true, force: true }); -} - -function parseArguments(argumentsList) { - let sourcePath; - let apply = false; - for (let index = 0; index < argumentsList.length; index++) { - const value = argumentsList[index]; - if (value === "--source") sourcePath = argumentsList[++index]; - else if (value === "--apply") apply = true; - else if (value === "--check") apply = false; - else if (value === "--help" || value === "-h") { - process.stdout.write("Usage: node scripts/vendor-openai-skills.mjs --source /path/to/openai-skills --check|--apply\n"); - process.exit(0); - } else fail(`Unknown argument: ${value}`); - } - return { source: sourcePath, apply }; -} - -function git(cwd, argumentsList) { - try { - return execFileSync("git", argumentsList, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); - } catch { - return undefined; - } -} - -function normalizeRemote(value) { - return value - .replace(/^git@github\.com:/, "https://github.com/") - .replace(/\.git$/, ""); -} - -function upstreamManifest(commit) { - return [ - `Source repository: ${expectedRemote}.git`, - `Pinned commit: ${commit}`, - "Source branch: main", - `Last synced: ${new Date().toISOString().slice(0, 10)}`, - "", - "Copied paths:", - "- skills/.system", - "- skills/.curated", - "", - "Local modifications:", - "- None. DevSpace-specific behavior belongs in sibling devspace-* Skills.", - "", - ].join("\n"); -} - -function fail(message) { - process.stderr.write(`${message}\n`); - process.exit(1); -} diff --git a/skills/.system/README.md b/skills/.system/README.md new file mode 100644 index 0000000..a1569a9 --- /dev/null +++ b/skills/.system/README.md @@ -0,0 +1,32 @@ +# DevSpace System Skills + +This directory contains DevSpace-owned system Skills only. + +## Entries + +| Directory | Skill | Purpose | +|---|---|---| +| `plan/` | `plan` | `/plan`, durable Plans, steps, validation, and conflicts | +| `goal/` | `goal` | `/goal`, Goal lifecycle, metrics, and conflicts | +| `workflow/` | `workflow` | recovery, modes, isolation, routing, and history | +| `architecture-review/` | `architecture-review` | evidence-based architecture review | +| `skill-authoring/` | `skill-authoring` | Skill structure and quality rules | + +## Policy + +- `/plan` always resolves to `plan`. +- `/goal` always resolves to `goal`. +- The five Skill names above are reserved system names. +- Project-local, installed, and global Skills cannot override reserved names or aliases. +- External Skills belong in `skills/installed/`, not in `.system`. +- Old system Skill identifiers are not supported; use the names listed in the table above. + +## Change log + +| Date | Version | Change | +|---|---:|---| +| 2026-06-22 | 3.0 | `create-plan` and `devspace-plan` merged into `plan` | +| 2026-06-22 | 3.0 | `define-goal` and `devspace-goal` merged into `goal` | +| 2026-06-22 | 3.0 | `devspace-workflow` renamed to `workflow` | +| 2026-06-22 | 3.0 | architecture and authoring Skills consolidated; `-lite` copies removed | +| 2026-06-22 | 3.0 | full OpenAI Skill mirror removed from the package | diff --git a/skills/.system/architecture-review/SKILL.md b/skills/.system/architecture-review/SKILL.md new file mode 100644 index 0000000..e19a7da --- /dev/null +++ b/skills/.system/architecture-review/SKILL.md @@ -0,0 +1,34 @@ +--- +name: architecture-review +description: Perform evidence-driven architecture review for a DevSpace workspace without bypassing project instructions, tests, workflow state, or authorization boundaries. +license: MIT +metadata: + version: 3.0.0 + author: DevSpace + category: system-engineering + updated: 2026-06-22 +--- + +# Architecture Review + +Use this Skill for decisions spanning modules, persistent state, compatibility, security boundaries, migrations, rollout risk, or operational recovery. + +## Method + +1. Read `AGENTS.md`, relevant entry points, schema, public interfaces, tests, configuration, and deployment paths before making claims. +2. Separate observed facts from assumptions and unresolved questions. +3. Prefer the smallest compatible change that preserves migration safety and authorization boundaries. +4. Evaluate ownership, lifecycle, concurrency, failure recovery, backwards compatibility, observability, rollout, and rollback. +5. When a durable implementation plan is needed, resolve `/plan` and persist a verified Plan. + +## Output + +State: + +- constraints and evidence; +- recommended boundary and approach; +- rejected alternatives and why; +- migration, security, and rollback effects; +- tests that prove the decision. + +Do not produce generic architecture slogans or introduce a subsystem without identifying its code boundary and operational cost. \ No newline at end of file diff --git a/skills/.system/architecture-review/references/decision-guide.md b/skills/.system/architecture-review/references/decision-guide.md new file mode 100644 index 0000000..5caf613 --- /dev/null +++ b/skills/.system/architecture-review/references/decision-guide.md @@ -0,0 +1,19 @@ +# Architecture Decision Guide + +Before recommending a change, establish the minimum evidence: + +- current public API, command, or tool contract; +- data schema, migration behavior, and persistence ownership; +- relevant tests and failure behavior; +- deployment, operator, and authorization boundaries; +- compatibility expectations for existing clients and stored data. + +Ask: + +1. What concrete user or operator failure does the change solve? +2. Which module owns the behavior and lifecycle? +3. What happens during partial failure, restart, retry, or concurrent access? +4. Which callers, stored records, or deployment paths can break? +5. How is the change verified and rolled back? + +Prefer a narrow adapter or migration over a new framework when an existing boundary already fits the requirement. \ No newline at end of file diff --git a/skills/.system/create-plan/SKILL.md b/skills/.system/create-plan/SKILL.md deleted file mode 100644 index df0bd07..0000000 --- a/skills/.system/create-plan/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: create-plan -description: Legacy compatibility guidance for older DevSpace planning prompts. DevSpace now uses devspace-plan for the durable project-scoped /plan workflow. -license: MIT -metadata: - version: 2.0.0 - author: DevSpace - category: legacy-workflow - updated: 2026-06-21 ---- - -# Legacy Create Plan Compatibility - -This Skill is retained for projects or older prompts that still name `create-plan`. New DevSpace workflow resolution does not route `/plan` here; it routes to `devspace-plan`. - -## Required Migration Behavior - -When this legacy Skill is intentionally selected, follow the same durable planning contract as `devspace-plan`: - -1. Open or reuse the current workspace and inspect its `workflowDigest`. -2. Call `get_plan` before replacing or editing any existing Plan. -3. Stay read-only while collecting repository evidence: read project instructions, public interfaces, tests, configuration, and relevant source files. -4. Ask questions only when an unresolved decision materially changes scope, compatibility, safety, or implementation order. -5. Present a finite Plan with explicit scope, ordered steps, validation, and risks. -6. Persist it with `update_plan`. Use `expectedRevision=0` only when no Plan exists; otherwise use the revision from `get_plan`. -7. On a revision conflict, reload the Plan and merge deliberately instead of retrying stale data. - -## State Expectations - -A Plan is project-scoped shared state. It survives a new ChatGPT session for the same canonical directory. It is not a chat log, a token budget, or a progress dashboard. - -A complete Plan uses these states: - -- Plan: `draft`, `active`, `completed`, `archived` -- Steps: `pending`, `in_progress`, `blocked`, `completed`, `skipped` - -Only one step may be `in_progress`. A blocked or skipped step must preserve the reason in its note. - -## Output Contract - -```markdown -# Plan - -## Goal - - -## Existing state - - -## Scope -- In: ... -- Out: ... - -## Action items -- [ ] - -## Validation -- - -## Risks / rollback -- -``` - -For the current DevSpace workflow contract, resolve `devspace-plan` and read its references. \ No newline at end of file diff --git a/skills/.system/define-goal/SKILL.md b/skills/.system/define-goal/SKILL.md deleted file mode 100644 index 3945c31..0000000 --- a/skills/.system/define-goal/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: define-goal -description: Legacy compatibility guidance for older DevSpace goal-definition prompts. DevSpace now uses devspace-goal for the durable project-scoped /goal workflow. -license: MIT -metadata: - version: 2.0.0 - author: DevSpace - category: legacy-workflow - updated: 2026-06-21 ---- - -# Legacy Define Goal Compatibility - -This Skill is retained for compatibility with earlier prompts that request `define-goal`. New DevSpace alias resolution maps `/goal` to `devspace-goal`. - -## Required Goal Lifecycle - -1. Inspect the current project `workflowDigest` and call `get_goal` before creating or changing a Goal. -2. Create a Goal only when the user explicitly needs a persistent, cross-session outcome. Routine coding requests do not need one. -3. When no active Goal exists, use `create_goal` with an objective, scope, acceptance criteria, verification, stop conditions, and concise current summary. -4. When a Goal already exists and matches the request, preserve it and update only the parts that changed. -5. When a Goal conflicts with the new request, ask the user whether to archive it, mark it completed, mark it blocked, or keep it unchanged. Never silently replace an active Goal. -6. Use `update_goal(expectedRevision=...)` for every change to an existing Goal. Reload with `get_goal` after a revision conflict. - -## Goal Standard - -A durable Goal must be verifiable rather than aspirational: - -- `objective`: one user-visible outcome. -- `scope.in` / `scope.out`: what is included and excluded. -- `successCriteria`: conditions that prove success. -- `verification`: tests, review checks, or manual validation. -- `stopConditions`: reasons to pause, escalate, or abandon the approach. -- `currentSummary`: compact completed/current/blocked state for the next session. - -Use statuses deliberately: - -- `active`: work can continue. -- `blocked`: a specific decision, dependency, or permission is missing. -- `completed`: success criteria were satisfied and verified. -- `archived`: no longer current, while history remains available. - -Do not invent token counts, clock time, activity seconds, or percentage progress. Store evidence and blockers instead. - -For the active DevSpace contract, resolve `devspace-goal` and read its references. \ No newline at end of file diff --git a/skills/.system/devspace-goal/SKILL.md b/skills/.system/devspace-goal/SKILL.md deleted file mode 100644 index 741d29b..0000000 --- a/skills/.system/devspace-goal/SKILL.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: devspace-goal -description: Define and maintain a durable, verifiable project Goal in DevSpace. Use for /goal when the user explicitly wants a goal to persist across sessions. -license: MIT -metadata: - version: 2.0.0 - author: DevSpace - category: workflow - updated: 2026-06-21 ---- - -# DevSpace Goal Workflow - -Use this Skill when the user asks for `/goal`, asks to preserve a project outcome across sessions, or needs explicit success and stop conditions. Do not create a Goal for every routine coding request. - -## Required Tool Lifecycle - -1. Call `get_goal` before creating or changing a Goal. -2. If no current Goal exists, clarify only material ambiguity and call `create_goal`. -3. If the current Goal matches, continue it and use `update_goal` only when the definition, summary, verification, or status changes. -4. If the current Goal conflicts with the new request, show the conflict and ask the user to choose one action: - - replace the old Goal by archiving it, - - mark the old Goal `completed`, - - mark the old Goal `blocked`, or - - keep the old Goal unchanged. -5. Before updating, use the Goal revision from `get_goal` as `expectedRevision`. -6. On a revision conflict, reload with `get_goal` and merge deliberately. Never overwrite a different session's Goal state blindly. -7. When execution starts, call `start_goal_work`; pause it with `pause_goal_work` before waiting for approval, changing tasks, or ending work. This is the only source of Goal work-duration data. -8. Record tokens only with `record_goal_token_usage` when an upstream provider/API has returned exact counts and a stable request ID. Never infer tokens from text length, elapsed time, or model name. -9. For exact percentage progress, link the current Plan to the Goal through `update_plan(goalId=...)`. Goal progress is then derived from completed Plan steps, not guessed. - -Read [references/goal-state.md](references/goal-state.md) for field definitions and [references/goal-conflicts.md](references/goal-conflicts.md) for conflict handling. - -## Goal Quality Standard - -A Goal must describe a user-visible outcome that can be verified. It is not a task dump and it is not a time or token budget. - -Include: - -- `objective`: a one-sentence intended outcome. -- `scope.in` and `scope.out`: boundaries. -- `successCriteria`: what must be true when done. -- `verification`: commands, tests, review steps, or manual checks. -- `stopConditions`: conditions that justify stopping or escalating. -- `currentSummary`: compact state of completed work, current work, and blockers. - -## Status Rules - -- `active`: the current target is being pursued. -- `blocked`: progress needs an external decision, dependency, or access change. -- `completed`: the defined criteria were met and verified. -- `archived`: intentionally removed from current workflow state; history remains available. - -The Goal has three measurable fields only under explicit evidence rules: - -- Provider tokens: append-only counts returned by a provider/API response, deduplicated by provider request ID. -- Work duration: server wall-clock milliseconds while `start_goal_work` is running; it is paused explicitly and automatically when a Goal leaves `active`. -- Percentage progress: exact completed-step ratio from the current Plan only when that Plan is explicitly linked to this Goal. The stored numerator/denominator is canonical; display percentages are rounded for humans. - -Never invent or backfill any of these values from chat text, elapsed conversation time, or intuition. diff --git a/skills/.system/devspace-goal/references/goal-conflicts.md b/skills/.system/devspace-goal/references/goal-conflicts.md deleted file mode 100644 index 5cc4514..0000000 --- a/skills/.system/devspace-goal/references/goal-conflicts.md +++ /dev/null @@ -1,18 +0,0 @@ -# Goal Conflict Procedure - -A conflicting Goal is one whose objective, scope, or success criteria would cause the project to pursue a different outcome than the active Goal. - -## Required user choice - -When a conflict exists, present the current Goal and the requested Goal in one compact comparison, then ask the user to choose: - -1. Archive the current Goal and create the new one. -2. Mark the current Goal completed after verifying its criteria. -3. Mark the current Goal blocked and explain the blocker. -4. Keep the current Goal and treat the new request as ordinary work. - -Do not silently replace an active Goal. Do not create competing active Goals. - -## Revision conflict - -A revision conflict means another session changed the Goal after it was read. Reload the Goal, preserve valid updates, and write again with the refreshed revision. diff --git a/skills/.system/devspace-goal/references/goal-state.md b/skills/.system/devspace-goal/references/goal-state.md deleted file mode 100644 index d7ad23f..0000000 --- a/skills/.system/devspace-goal/references/goal-state.md +++ /dev/null @@ -1,47 +0,0 @@ -# Goal State Contract - -A Goal is project-scoped shared workflow state. It is not a chat transcript. It can expose measured token, work-duration, and Plan-progress fields only when their exact evidence rules are satisfied. - -## Fields - -- `objective`: required concrete outcome. -- `scope.in` / `scope.out`: boundaries. -- `successCriteria`: observable outcome checks. -- `verification`: tests, builds, inspection, or manual checks. -- `stopConditions`: conditions for pause, escalation, or intentional stop. -- `currentSummary`: concise completed / current / blocked information. -- `status`: `active`, `blocked`, `completed`, `archived`. -- `revision`: optimistic concurrency version. -- `metrics.tokenUsage`: append-only provider-reported usage totals, deduplicated by provider request ID. -- `metrics.workDuration`: server-measured milliseconds accumulated only while the explicit Goal timer is running. -- `metrics.progress`: completed Plan step ratio only when the current Plan is explicitly linked to this Goal. - -## Exact Metric Rules - -Use `record_goal_token_usage` only with fields from an actual API or provider usage response. Do not derive token values from message text, bytes, model context limits, or elapsed time. - -`start_goal_work` stores a server timestamp and `pause_goal_work` persists elapsed wall-clock milliseconds. A transition from `active` to `blocked`, `completed`, or `archived` pauses a running timer automatically. This measures an explicit timer interval, not unobservable human or model thinking time. - -Link a Plan through `update_plan` by sending the Goal ID as `goalId`. The canonical percentage progress fields are `completedSteps/totalSteps` and `percentageNumerator/percentageDenominator`; `displayPercent` is a rounded human presentation. - -## Lifecycle - -- `create_goal` creates an `active` Goal only when no active Goal exists. -- `update_goal` changes the current Goal and requires `expectedRevision`. -- `archived` removes a Goal from current hot state and retains its events in history. -- A later `create_goal` can start a new active Goal after a previous Goal has become blocked, completed, or archived. - -## Current summary pattern - -```text -Completed: -- ... - -Current: -- ... - -Blocked: -- ... -``` - -Keep this summary short enough to be useful in `workflowDigest`; do not paste tool output or chat history into it. diff --git a/skills/.system/devspace-plan/SKILL.md b/skills/.system/devspace-plan/SKILL.md deleted file mode 100644 index a1d0e46..0000000 --- a/skills/.system/devspace-plan/SKILL.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -name: devspace-plan -description: Create, resume, and maintain a durable DevSpace implementation Plan for the current project. Use for /plan and for requests that need a read-only analysis before code changes. -license: MIT -metadata: - version: 2.0.0 - author: DevSpace - category: workflow - updated: 2026-06-21 ---- - -# DevSpace Plan Workflow - -Use this Skill when the user explicitly asks for `/plan`, asks to plan before implementation, or needs a durable execution checklist shared across future DevSpace sessions. - -## Required Tool Lifecycle - -1. Call `get_plan` first. Reuse the existing Plan when it matches the task instead of silently replacing it. -2. Call `set_collaboration_mode` with `mode="plan"` when entering an intentional planning pass. -3. Read relevant `AGENTS.md`, docs, source files, tests, and configuration. Planning is read-only: do not edit files, run destructive commands, or claim implementation is complete. -4. Ask with `request_user_input` only when a real decision changes scope, architecture, compatibility, safety, or rollout. Do not ask questions whose answer can be established by inspection. -5. Produce one finite implementation Plan with clear boundaries, ordered actions, validation, and risks. -6. Persist the Plan with `update_plan`: - - Use `expectedRevision=0` when no current Plan exists. - - Otherwise use the revision returned by `get_plan`. - - If DevSpace reports a revision conflict, call `get_plan` again and reconcile; never overwrite blindly. -7. Keep Plan Mode enabled until the user explicitly starts implementation or switches back to default mode. - -Read [references/plan-state.md](references/plan-state.md) for Plan fields and transitions. Read [references/plan-conflicts.md](references/plan-conflicts.md) before resolving concurrent updates. - -## Planning Standard - -A Plan must be specific enough that another session can continue it without reconstructing the project context. Every step must be an observable action, not a vague goal. - -Use statuses deliberately: - -- `pending`: not started. -- `in_progress`: the one active step, if work has started. -- `blocked`: cannot proceed; add a concise `note` with the blocker and the decision needed. -- `completed`: verified done. -- `skipped`: intentionally not doing this step; add a reason in `note`. - -Do not use fake percentages, token budgets, elapsed-time estimates, or dashboard-style reporting. - -## Required Output Shape - -```markdown -# Plan - -## Goal - - -## Existing state - - -## Scope -- In: ... -- Out: ... - -## Action items -- [ ] - -## Validation -- - -## Risks / rollback -- -``` - -The human-readable response must match the persisted Plan. Keep it concise but do not omit validation or risks when they matter. diff --git a/skills/.system/devspace-plan/references/plan-conflicts.md b/skills/.system/devspace-plan/references/plan-conflicts.md deleted file mode 100644 index b943bd2..0000000 --- a/skills/.system/devspace-plan/references/plan-conflicts.md +++ /dev/null @@ -1,17 +0,0 @@ -# Plan Revision Conflicts - -Multiple ChatGPT sessions can open the same project. DevSpace prevents silent loss by versioning the current Plan. - -## Conflict procedure - -1. A Plan update returns a revision conflict. -2. Stop; do not retry the old payload. -3. Call `get_plan` and inspect the current revision, changed steps, notes, and summary. -4. Merge only compatible changes. -5. Call `update_plan` with the refreshed `expectedRevision`. - -## Do not - -- Do not assume your in-memory Plan is current. -- Do not overwrite a blocker, validation result, or completed step without checking why it changed. -- Do not turn a revision conflict into a user-facing implementation failure when it can be resolved by reloading state. diff --git a/skills/.system/devspace-plan/references/plan-output.md b/skills/.system/devspace-plan/references/plan-output.md deleted file mode 100644 index dbb62bd..0000000 --- a/skills/.system/devspace-plan/references/plan-output.md +++ /dev/null @@ -1,11 +0,0 @@ -# Plan Writing Checklist - -Before persisting a Plan, verify all of the following: - -- The title names the actual user outcome, not a generic activity. -- Existing-state claims are backed by files, tests, or configuration that were inspected. -- Scope includes explicit exclusions where likely misunderstandings exist. -- Each action item names a module, interface, behavior, or test target. -- Validation is executable or observable. -- Risks identify a real failure mode, migration concern, security concern, or rollback path. -- The response does not promise edits while Plan Mode is active. diff --git a/skills/.system/devspace-plan/references/plan-state.md b/skills/.system/devspace-plan/references/plan-state.md deleted file mode 100644 index 29b249f..0000000 --- a/skills/.system/devspace-plan/references/plan-state.md +++ /dev/null @@ -1,26 +0,0 @@ -# Plan State Contract - -A DevSpace Plan is project-scoped state. It survives a new `open_workspace` call for the same canonical project directory and is isolated from other projects and Git worktrees. - -## Plan fields - -- `title`: concise name for the work. -- `summary`: current implementation context and decision record. -- `scope.in` / `scope.out`: explicit boundaries. -- `steps`: ordered executable work. -- `validation`: evidence required before completion. -- `risks`: operational, compatibility, or rollback concerns. -- `status`: `draft`, `active`, `completed`, or `archived`. -- `revision`: optimistic-concurrency version. - -## Update rules - -- Call `get_plan` before modifying a Plan. -- Pass `expectedRevision=0` only to create a Plan when none exists. -- Pass the current `revision` for every update to an existing Plan. -- `archived` removes the Plan from the current hot state but retains its event history. -- `completed` remains readable as the current Plan until a new Plan is explicitly created after archiving it. - -## Step rules - -Keep the step list complete on every `update_plan` call. At most one step can be `in_progress`. A `blocked` or `skipped` step needs a short `note` that makes the future decision clear. diff --git a/skills/.system/devspace-workflow/SKILL.md b/skills/.system/devspace-workflow/SKILL.md deleted file mode 100644 index ebfc10b..0000000 --- a/skills/.system/devspace-workflow/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: devspace-workflow -description: Recover and coordinate project-scoped DevSpace workflow state across sessions, including Plan, Goal, mode, and concise history. -license: MIT -metadata: - version: 2.0.0 - author: DevSpace - category: workflow - updated: 2026-06-21 ---- - -# DevSpace Workflow Recovery - -DevSpace stores a small project-scoped workflow state keyed by the workspace's canonical real path. This lets a new ChatGPT session continue the current project without loading chat transcripts, tool output, or all historical Plans. - -## Start or Resume - -After `open_workspace`, inspect `workflowDigest` first. - -- No active state: work normally. Create a Plan or Goal only when the user asks for durable workflow state. -- Existing Goal or Plan: call `get_goal` or `get_plan` only when the current task needs its complete definition. -- Relevant older decision: call `get_workflow_history` with a small page size. Do not load history by default. -- A matching user request can resume the current workflow; an incompatible request requires the Goal conflict procedure. - -## Scope and Isolation - -- Same canonical project root: shared across DevSpace sessions and restarts. -- Different project roots: isolated. -- Different Git worktree roots: isolated by default. -- `workspaceId` is a session handle, never the durable Plan or Goal identity. - -## Mode - -`plan` is a workflow preference, not a permission boundary. - -- `plan`: read, inspect, ask material questions, write or update the Plan, then wait for implementation approval. -- `default`: make approved changes, test, and maintain the Plan or Goal when relevant. - -Plan Mode never grants extra filesystem, shell, Git, or Skill permissions. - -## History - -Workflow history contains only compact events such as `plan.updated`, `goal.blocked`, and `mode.changed`. It must never contain full chats, raw tool output, secrets, or command logs. - -Read [references/workflow-recovery.md](references/workflow-recovery.md) for the resume sequence and [references/command-routing.md](references/command-routing.md) for alias behavior. diff --git a/skills/.system/devspace-workflow/references/command-routing.md b/skills/.system/devspace-workflow/references/command-routing.md deleted file mode 100644 index f41fecb..0000000 --- a/skills/.system/devspace-workflow/references/command-routing.md +++ /dev/null @@ -1,11 +0,0 @@ -# Command Routing - -The `/plan` and `/goal` strings are stable DevSpace aliases, not native host slash commands. - -- `/plan` resolves only to `devspace-plan`. -- `/goal` resolves only to `devspace-goal`. -- Project-local, installed, global, and vendored OpenAI Skills cannot silently override either alias. -- `resolve_skill` returns the full selected `SKILL.md` in one explicit call and activates its resource directory. -- `search_skills` discovers optional Skills without loading their instructions. - -`handle_workspace_command` remains a compatibility helper for raw `/plan`, `/goal`, and compact pending-input replies. New workflows should call `resolve_skill`, `get_plan` / `get_goal`, and state tools directly. diff --git a/skills/.system/devspace-workflow/references/commands.md b/skills/.system/devspace-workflow/references/commands.md deleted file mode 100644 index 70a90fe..0000000 --- a/skills/.system/devspace-workflow/references/commands.md +++ /dev/null @@ -1,23 +0,0 @@ -# DevSpace Workflow Commands - -DevSpace command strings are routing conventions used by the model, not shell commands and not native ChatGPT slash commands. - -## `/plan` - -1. Resolve `devspace-plan` through `resolve_skill("/plan")`. -2. Read the current Plan with `get_plan`. -3. Set collaboration mode to `plan` when entering a planning pass. -4. Inspect the repository read-only, ask material questions only, and persist the complete Plan with `update_plan(expectedRevision=...)`. -5. Do not write project files while Plan Mode remains active. - -## `/goal` - -1. Resolve `devspace-goal` through `resolve_skill("/goal")`. -2. Read the current Goal with `get_goal`. -3. Create one only when the user asks for a durable, cross-session objective. -4. For a conflicting active Goal, ask before archiving, completing, blocking, or keeping it. -5. Use `expectedRevision` on every existing Goal update. - -## Optional Skills - -Use `search_skills` to discover Skills without injecting all instructions into context. Once selected, call `resolve_skill` with the returned qualified ID. A Skill reference can be read only after the Skill has been resolved and activated. \ No newline at end of file diff --git a/skills/.system/devspace-workflow/references/examples.md b/skills/.system/devspace-workflow/references/examples.md deleted file mode 100644 index 6f5f7a6..0000000 --- a/skills/.system/devspace-workflow/references/examples.md +++ /dev/null @@ -1,28 +0,0 @@ -# Workflow State Examples - -## Resume an existing Plan - -`open_workspace` reports an active Plan in `workflowDigest`. - -1. Call `get_plan`. -2. Confirm the requested work still matches the Plan scope. -3. When implementation starts, set the relevant pending step to `in_progress` using the returned revision. -4. After validation, mark the step `completed` and record a concise note only when the result matters to the next session. - -## Handle a concurrent update - -A Plan update fails with a revision conflict. - -1. Call `get_plan` again. -2. Compare the new step statuses, validation, and blockers against the intended update. -3. Preserve verified work from the other session. -4. Send one merged full step list with the new revision. - -## Recover a blocked Goal - -A Goal has `status=blocked` after an external decision is needed. - -1. Do not update the non-active Goal in place. -2. Resolve the blocker with the user. -3. Create a new Goal that captures the resumed outcome, or keep the blocked Goal as historical context. -4. Link the new Plan to the new Goal when the relationship is useful. diff --git a/skills/.system/devspace-workflow/references/style.md b/skills/.system/devspace-workflow/references/style.md deleted file mode 100644 index d1cf0cc..0000000 --- a/skills/.system/devspace-workflow/references/style.md +++ /dev/null @@ -1,12 +0,0 @@ -# Workflow Response Style - -Workflow responses should be compact, evidence-based, and resumable. - -- State what was inspected before making an architectural claim. -- Show the current Plan or Goal status only when it matters to the request. -- Prefer concrete validation evidence over percentage completion. -- Keep `currentSummary` to completed work, current work, and real blockers. -- Do not paste full chat history, raw shell output, full file contents, or credentials into workflow state. -- Explain a revision conflict as a normal concurrent-edit condition and reload state before deciding how to merge. - -A good Plan has enough detail for a future session to continue. A good Goal identifies success and stopping conditions without becoming a project-management dashboard. \ No newline at end of file diff --git a/skills/.system/devspace-workflow/references/workflow-recovery.md b/skills/.system/devspace-workflow/references/workflow-recovery.md deleted file mode 100644 index b5d976b..0000000 --- a/skills/.system/devspace-workflow/references/workflow-recovery.md +++ /dev/null @@ -1,10 +0,0 @@ -# Workflow Recovery Sequence - -1. Call `open_workspace` once for the project root or worktree. -2. Read the compact `workflowDigest` returned by DevSpace. -3. Load `get_goal` only when the task depends on its objective, success criteria, verification, stop conditions, status, or revision. -4. Load `get_plan` only when the task needs its steps, validation, risks, status, or revision. -5. Use `get_workflow_history` only to inspect a specific past decision. Use the cursor rather than requesting unbounded history. -6. When updating Plan or Goal, pass the revision that was read. On conflict, reload and merge. - -Do not automatically create a Goal merely because a workflowDigest is empty. Do not treat the digest as enough detail to execute an old Plan without loading it. diff --git a/skills/.system/goal/SKILL.md b/skills/.system/goal/SKILL.md new file mode 100644 index 0000000..2157bcd --- /dev/null +++ b/skills/.system/goal/SKILL.md @@ -0,0 +1,36 @@ +--- +name: goal +description: Define and maintain a durable, verifiable project Goal in DevSpace. Use for /goal when the user explicitly wants an outcome to persist across sessions. +license: MIT +metadata: + version: 3.0.0 + author: DevSpace + category: system-workflow + updated: 2026-06-22 +--- + +# DevSpace Goal + +Use this Skill for `/goal` or when the user explicitly requests a durable cross-session objective. Do not create a Goal for every ordinary coding request. + +## Required lifecycle + +1. Call `get_goal` first. +2. With no active Goal, create one with a concrete objective, scope, success criteria, verification, stop conditions, and concise current summary. +3. With a matching active Goal, continue it and update only fields that changed. +4. With a conflicting active Goal, ask the user whether to archive it, complete it, block it, or keep it. Never silently replace an active Goal. +5. Use the revision returned by `get_goal` as `expectedRevision` on every update. Reload before merging a revision conflict. +6. Start and pause measured work with `start_goal_work` and `pause_goal_work`. +7. Record tokens only with `record_goal_token_usage` when an upstream API or provider returned exact usage plus a stable request ID. +8. Link the current Plan through `update_plan(goalId=...)` only when the Plan is the authoritative breakdown for Goal progress. + +Read [references/state.md](references/state.md), [references/metrics.md](references/metrics.md), and [references/conflicts.md](references/conflicts.md) before acting on Goal state. + +## Status + +- `active`: the outcome can proceed. +- `blocked`: a specific decision, dependency, or permission is missing. +- `completed`: success criteria have been verified. +- `archived`: no longer current; history remains available. + +`currentSummary` contains only completed work, current work, and real blockers. Never put chat transcripts, raw tool output, file snapshots, or secrets into Goal state. diff --git a/skills/.system/goal/references/conflicts.md b/skills/.system/goal/references/conflicts.md new file mode 100644 index 0000000..d7f593b --- /dev/null +++ b/skills/.system/goal/references/conflicts.md @@ -0,0 +1,18 @@ +# Goal Conflicts + +A Goal conflicts when the requested objective, scope, or acceptance criteria would direct the project toward a different outcome than the active Goal. + +## Required user choice + +Show the current Goal and requested Goal briefly, then ask whether to: + +1. archive the current Goal and create the requested Goal; +2. complete the current Goal after verification; +3. mark the current Goal blocked with a concrete reason; or +4. keep the current Goal and treat the request as ordinary work. + +Do not create competing active Goals and do not silently replace one. + +## Revision conflict + +A revision conflict means another session changed the Goal after it was read. Call `get_goal`, preserve valid changes, and update once with the refreshed revision. \ No newline at end of file diff --git a/skills/.system/goal/references/metrics.md b/skills/.system/goal/references/metrics.md new file mode 100644 index 0000000..378c989 --- /dev/null +++ b/skills/.system/goal/references/metrics.md @@ -0,0 +1,25 @@ +# Exact Goal Metrics + +Goal metrics are recorded only under explicit evidence rules. + +## Provider tokens + +Use `record_goal_token_usage` only with exact counts returned by a model provider or API and a stable provider request ID. Usage is append-only and deduplicated by `provider + providerRequestId`. + +Never estimate tokens from text length, message bytes, context limits, model names, elapsed time, or intuition. + +## Work duration + +Call `start_goal_work` when measured work begins. Call `pause_goal_work` before waiting for approval, changing tasks, or stopping. DevSpace persists exact server wall-clock milliseconds only while this timer is running. A Goal transition out of `active` pauses a running timer automatically. + +This is an explicit timer interval, not a claim about hidden model reasoning or user attention. + +## Percentage progress + +Set the current Plan `goalId` to this Goal ID only when that Plan is the authoritative work breakdown. Progress then uses completed Plan steps: + +- canonical fraction: `completedSteps/totalSteps`; +- exact rational percentage: `percentageNumerator/percentageDenominator`; +- `displayPercent`: rounded human display only. + +Without a linked current Plan, percentage progress is unavailable rather than guessed. \ No newline at end of file diff --git a/skills/.system/goal/references/state.md b/skills/.system/goal/references/state.md new file mode 100644 index 0000000..e0688f8 --- /dev/null +++ b/skills/.system/goal/references/state.md @@ -0,0 +1,17 @@ +# Goal State + +A Goal is durable project-scoped state. It is shared across sessions for the same canonical project root and isolated from other projects and Git worktrees. + +## Fields + +- `objective`: concrete user-visible outcome. +- `scope.in` / `scope.out`: boundaries. +- `successCriteria`: observable completion requirements. +- `verification`: tests, builds, review steps, or manual checks. +- `stopConditions`: conditions that justify pausing, escalating, or stopping. +- `currentSummary`: compact completed/current/blocked record. +- `status`: `active`, `blocked`, `completed`, `archived`. +- `revision`: optimistic-concurrency version. +- `metrics`: exact token, duration, and Plan-progress data only where evidence exists. + +`create_goal` refuses to create a competing active Goal. After a Goal becomes blocked, completed, or archived, a new active Goal can be created deliberately. \ No newline at end of file diff --git a/skills/.system/plan/SKILL.md b/skills/.system/plan/SKILL.md new file mode 100644 index 0000000..082cac4 --- /dev/null +++ b/skills/.system/plan/SKILL.md @@ -0,0 +1,55 @@ +--- +name: plan +description: Create, resume, and maintain a durable DevSpace implementation Plan for the current project. Use for /plan and for requests that require read-only analysis before code changes. +license: MIT +metadata: + version: 3.0.0 + author: DevSpace + category: system-workflow + updated: 2026-06-22 +--- + +# DevSpace Plan + +Use this Skill for `/plan`, explicit implementation planning, or a task that should be analyzed before files are modified. + +## Required lifecycle + +1. Call `get_plan` first. Reuse a matching current Plan instead of silently replacing it. +2. Set collaboration mode to `plan` for the planning pass. +3. Read project instructions, source, tests, configuration, public interfaces, and migration paths. Planning is read-only: do not edit project files or claim implementation is complete. +4. Ask with `request_user_input` only when an unresolved decision changes scope, compatibility, architecture, safety, or rollout. +5. Produce one finite Plan with scope, ordered actions, validation, and risks. +6. Persist it with `update_plan`: + - use `expectedRevision=0` only when no current Plan exists; + - otherwise use the revision returned by `get_plan`; + - on a revision conflict, reload and merge instead of retrying stale content. + +Read [references/state.md](references/state.md) for Plan state and [references/conflicts.md](references/conflicts.md) before resolving concurrent changes. + +## Output contract + +```markdown +# Plan + +## Goal + + +## Existing state + + +## Scope +- In: ... +- Out: ... + +## Action items +- [ ] + +## Validation +- + +## Risks / rollback +- +``` + +The user-facing Plan must match the persisted Plan. Do not include token budgets, guessed time estimates, or invented percentage progress. diff --git a/skills/.system/plan/references/conflicts.md b/skills/.system/plan/references/conflicts.md new file mode 100644 index 0000000..75e30eb --- /dev/null +++ b/skills/.system/plan/references/conflicts.md @@ -0,0 +1,14 @@ +# Plan Revision Conflicts + +Multiple sessions can open the same project. DevSpace uses the Plan `revision` to prevent one session from silently erasing another session's work. + +## Resolution procedure + +1. A Plan update returns a revision conflict. +2. Stop; do not resend the stale Plan payload. +3. Call `get_plan` again. +4. Compare changed steps, notes, validation, risks, and status. +5. Preserve completed work and real blockers from the current Plan. +6. Submit one merged complete Plan with the refreshed `expectedRevision`. + +Do not use a conflict to justify overwriting a Plan just because a previous model response was longer or newer in chat history. \ No newline at end of file diff --git a/skills/.system/plan/references/output.md b/skills/.system/plan/references/output.md new file mode 100644 index 0000000..4f125a2 --- /dev/null +++ b/skills/.system/plan/references/output.md @@ -0,0 +1,11 @@ +# Plan Quality Checklist + +Before persisting a Plan, verify: + +- Existing-state statements are backed by inspected files, tests, or configuration. +- Scope identifies likely exclusions. +- Every action item is observable and names a real behavior, module, interface, or test target. +- Validation is executable or manually verifiable. +- Risks are concrete and include rollback or mitigation when appropriate. +- The Plan does not promise edits while Plan Mode is active. +- The Plan can be resumed by a future session without reconstructing the whole conversation. diff --git a/skills/.system/plan/references/state.md b/skills/.system/plan/references/state.md new file mode 100644 index 0000000..6f8792a --- /dev/null +++ b/skills/.system/plan/references/state.md @@ -0,0 +1,28 @@ +# Plan State + +A Plan is durable project-scoped state. It is shared by new DevSpace sessions opened on the same canonical project root and isolated from different projects and Git worktree roots. + +## Fields + +- `title`: concise work name. +- `summary`: current evidence and decision record. +- `scope.in` / `scope.out`: explicit boundaries. +- `steps`: ordered executable work. +- `validation`: proof required before completion. +- `risks`: real rollback, security, migration, or compatibility concerns. +- `status`: `draft`, `active`, `completed`, `archived`. +- `revision`: optimistic-concurrency version. + +## Step states + +- `pending`: not started. +- `in_progress`: the one active step. +- `blocked`: cannot proceed; include a short note explaining the decision or dependency needed. +- `completed`: verified done. +- `skipped`: intentionally not performed; include a reason in the note. + +A full step list is sent on every `update_plan`. At most one step can be `in_progress`. + +## Linking a Goal + +Set `goalId` on the current Plan only when that Plan is the authoritative work breakdown for a Goal. Goal percentage progress is then derived from completed Plan steps; it is unavailable when no current Plan is linked. diff --git a/skills/.system/senior-architect-lite/SKILL.md b/skills/.system/senior-architect-lite/SKILL.md deleted file mode 100644 index 7ff0ab9..0000000 --- a/skills/.system/senior-architect-lite/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: senior-architect-lite -description: Legacy compatibility architecture-review workflow. Use senior-architect for the active DevSpace core Skill. -license: MIT -metadata: - version: 2.0.0 - author: DevSpace - category: legacy-engineering - updated: 2026-06-21 ---- - -# Legacy Architecture Review Compatibility - -This Skill remains for compatibility with older prompts. The active core Skill is `senior-architect`. - -## Evidence First - -Before recommending an architectural change, inspect the project instructions, public interfaces, schema and persistence model, tests, deployment configuration, and current failure paths. Separate observed facts from assumptions. - -## Required Review Dimensions - -Evaluate each relevant dimension explicitly: - -- ownership and lifecycle of state; -- API and storage compatibility; -- concurrency, retries, and data-loss failure modes; -- authorization, filesystem, shell, and network boundaries; -- migration and rollback; -- observability and operator recovery; -- tests that prove the proposed behavior. - -Do not propose a subsystem because it sounds broadly useful. Tie every recommendation to concrete files, interfaces, user flows, and operational cost. - -## Workflow State - -When reviewing Plan or Goal work, remember that DevSpace state is scoped to a canonical project root. It is shared across new sessions for the same root but isolated from other projects and Git worktrees. Plans and Goals use revisions to prevent silent concurrent overwrite. - -## Output - -Return: - -1. constraints and evidence; -2. a recommended implementation boundary; -3. alternatives rejected and why; -4. migration, rollback, and security effects; -5. validation steps. - -Use `devspace-plan` when the user wants a persisted implementation Plan. \ No newline at end of file diff --git a/skills/.system/senior-architect-lite/references/decision-guide.md b/skills/.system/senior-architect-lite/references/decision-guide.md deleted file mode 100644 index 1e3e555..0000000 --- a/skills/.system/senior-architect-lite/references/decision-guide.md +++ /dev/null @@ -1,21 +0,0 @@ -# Architecture Decision Guide - -Use an architecture recommendation only after documenting the relevant constraints. - -## Minimum evidence - -- current public API or command contract; -- data schema and migration path; -- relevant tests and failure behavior; -- deployment and operator boundary; -- compatibility expectations for existing clients and stored data. - -## Decision questions - -1. What concrete user or operator failure does the change solve? -2. Which module owns the new state or policy? -3. What happens during partial failure, restart, retry, or concurrent access? -4. Which existing callers could break? -5. How can the change be verified and rolled back? - -Prefer a narrow adapter or migration over a new framework when the existing architecture already has a suitable boundary. \ No newline at end of file diff --git a/skills/.system/senior-architect-lite/references/style.md b/skills/.system/senior-architect-lite/references/style.md deleted file mode 100644 index 1e4a9c2..0000000 --- a/skills/.system/senior-architect-lite/references/style.md +++ /dev/null @@ -1,12 +0,0 @@ -# Architecture Review Style - -Use direct language. Separate facts from assumptions. Name the exact file, interface, table, or external contract that supports an important claim. - -Do not: - -- propose a subsystem without showing its ownership and lifecycle; -- treat a generalized future need as evidence for present complexity; -- call an unverified behavior safe, compatible, or complete; -- hide migration, security, or rollback implications behind broad wording. - -End with validation that a maintainer can actually run or observe. \ No newline at end of file diff --git a/skills/.system/senior-architect/SKILL.md b/skills/.system/senior-architect/SKILL.md deleted file mode 100644 index 99ec653..0000000 --- a/skills/.system/senior-architect/SKILL.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: senior-architect -description: Perform evidence-driven architecture review for a DevSpace workspace without bypassing project instructions, tests, or workflow state. -license: MIT -metadata: - version: 2.0.0 - author: DevSpace - category: engineering - updated: 2026-06-21 ---- - -# Senior Architect Review - -Use this Skill for design decisions that span modules, persistent state, compatibility, security boundaries, or rollout risk. - -## Method - -1. Read `AGENTS.md`, entry points, data schema, public API, tests, and relevant configuration before making design claims. -2. Distinguish observed facts from assumptions and unresolved questions. -3. Prefer the smallest compatible change that preserves security boundaries and migration safety. -4. Evaluate data ownership, lifecycle, failure recovery, concurrency, backwards compatibility, observability, and release validation. -5. When the user is asking for a Plan, follow `devspace-plan` and persist only a verified, implementation-ready Plan. - -## Output - -Give a decision with: - -- current constraints and evidence, -- recommended approach, -- rejected alternatives and why, -- migration and rollback impact, -- tests that prove the decision. - -Do not produce generic architecture slogans. Do not propose a new subsystem without identifying the concrete code boundaries and operational cost. diff --git a/skills/.system/skill-authoring-lite/SKILL.md b/skills/.system/skill-authoring-lite/SKILL.md deleted file mode 100644 index 2e82a05..0000000 --- a/skills/.system/skill-authoring-lite/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: skill-authoring-lite -description: Legacy compatibility Skill-authoring guidance. Use skill-authoring for the active DevSpace core Skill. -license: MIT -metadata: - version: 2.0.0 - author: DevSpace - category: legacy-engineering - updated: 2026-06-21 ---- - -# Legacy Skill Authoring Compatibility - -This Skill is retained for older callers. The current core authoring workflow is `skill-authoring`. - -## Authoring Contract - -A DevSpace Skill must describe a real task workflow rather than act as a one-line persona prompt. Its `SKILL.md` should state: - -- when the Skill applies; -- what repository evidence to inspect; -- which DevSpace Tools to call and in what order; -- write, shell, and security boundaries; -- validation and recovery behavior; -- where detailed references live. - -## Structure - -```text -skills/.system// -├── SKILL.md -└── references/ -``` - -Use `references/` for state contracts, API constraints, examples, checklists, or conflict procedures. Do not hide executable behavior in prose; Skills never grant additional filesystem, shell, Git, network, or credential access. - -## Routing and Sources - -`/plan` and `/goal` are reserved DevSpace aliases and must not be overridden by project-local, installed, global, or vendored Skills. `resolve_skill` selects and activates a Skill. `search_skills` discovers optional Skills without injecting their entire contents into context. - -Use `skill://` locators for Skill files and activated resources; do not expose server absolute paths as the public protocol. - -## Validation - -When adding or revising a Skill, update discovery and alias tests, test controlled resource access, ensure package contents include the Skill, and verify the Tool contracts described by the workflow. \ No newline at end of file diff --git a/skills/.system/skill-authoring-lite/references/structure-checklist.md b/skills/.system/skill-authoring-lite/references/structure-checklist.md deleted file mode 100644 index d472f29..0000000 --- a/skills/.system/skill-authoring-lite/references/structure-checklist.md +++ /dev/null @@ -1,14 +0,0 @@ -# Skill Structure Checklist - -Before accepting a Skill change, verify: - -- frontmatter has a stable name and an accurate description; -- the Skill says when it applies and when it does not; -- required Tool calls and their order are explicit; -- file, shell, Git, network, and credential boundaries are explicit; -- supporting procedures live in `references/` rather than bloating the main Skill; -- reserved aliases such as `/plan` and `/goal` are not overridden; -- the Skill can recover from missing state or a revision conflict; -- discovery, resolution, resource access, and packaging tests cover the change. - -A Skill must guide a dependable workflow, not merely request a tone or role. \ No newline at end of file diff --git a/skills/.system/skill-authoring/SKILL.md b/skills/.system/skill-authoring/SKILL.md index 8a12446..b39d5e9 100644 --- a/skills/.system/skill-authoring/SKILL.md +++ b/skills/.system/skill-authoring/SKILL.md @@ -1,37 +1,34 @@ --- name: skill-authoring -description: Create or revise DevSpace Skills with stable aliases, controlled resource access, test coverage, and no hidden execution behavior. +description: Create or revise DevSpace Skills with clear workflows, stable system routing, controlled resource access, and test coverage. license: MIT metadata: - version: 2.0.0 + version: 3.0.0 author: DevSpace - category: engineering - updated: 2026-06-21 + category: system-engineering + updated: 2026-06-22 --- -# DevSpace Skill Authoring +# Skill Authoring -Use this Skill when creating or revising a Skill bundled with DevSpace or installed in a workspace. +Use this Skill to create or revise a Skill bundled with DevSpace or stored in a project. ## Requirements -- Every Skill must have valid frontmatter with `name` and `description`. -- Instructions must describe a real workflow, tool lifecycle, safety boundary, validation standard, and recovery path where relevant. -- Put supporting detail in `references/`; do not make `SKILL.md` a one-line prompt or an unbounded manual. -- Skills must never imply automatic execution of scripts, shell commands, Git operations, or file writes. -- A Skill can only expose its resources after `resolve_skill` or a direct read of its `SKILL.md` activates it. -- `/plan` and `/goal` are reserved DevSpace aliases. Do not create a local Skill that expects to override them. +- Every Skill has valid frontmatter with an accurate `name` and `description`. +- Instructions describe a concrete lifecycle: when the Skill applies, what evidence to inspect, which tools to use, safety boundaries, validation, and recovery behavior. +- Put detailed contracts and examples in `references/`; do not make `SKILL.md` a one-line persona prompt or an unbounded manual. +- A Skill never grants automatic execution of scripts, shell commands, Git, service operations, file writes, network access, or credentials. +- Use `resolve_skill` to activate a Skill before reading its `skill://` resources. -## Bundled Skill Layout +## Directory policy ```text -skills/.system// -├── SKILL.md -└── references/ +skills/.system/ DevSpace system Skills only +skills/local/ repository-maintained project Skills +skills/installed/ externally installed project Skills ``` -DevSpace core Skills live directly under `skills/.system/`. Vendored OpenAI Skills live under `skills/.system/openai/skills/` and must remain unmodified except during a documented upstream sync. +System Skill names are reserved: `plan`, `goal`, `workflow`, `architecture-review`, and `skill-authoring`. `/plan` and `/goal` are fixed system aliases and cannot be overridden. -## Validation - -When changing Skills, add or update tests for discovery, source priority, alias routing, `skill://` access, packaging, and any tool contract the Skill depends on. +Read [references/structure-checklist.md](references/structure-checklist.md) before accepting a Skill change. \ No newline at end of file diff --git a/skills/.system/skill-authoring/references/structure-checklist.md b/skills/.system/skill-authoring/references/structure-checklist.md new file mode 100644 index 0000000..be21ce4 --- /dev/null +++ b/skills/.system/skill-authoring/references/structure-checklist.md @@ -0,0 +1,14 @@ +# Skill Structure Checklist + +Before accepting a Skill change, verify: + +- frontmatter has a stable name and accurate description; +- the Skill says when it applies and when it does not; +- required tool calls and their order are explicit; +- file, shell, Git, network, and credential boundaries are explicit; +- supporting procedures live in `references/` rather than bloating the main Skill; +- reserved system names and `/plan` / `/goal` aliases are not overridden; +- the Skill has a recovery path for missing state or revision conflict when relevant; +- discovery, resolution, resource access, packaging, and any described tool contracts have test coverage. + +A Skill must guide a dependable workflow, not merely ask the model to adopt a tone or role. \ No newline at end of file diff --git a/skills/.system/workflow/SKILL.md b/skills/.system/workflow/SKILL.md new file mode 100644 index 0000000..1db268d --- /dev/null +++ b/skills/.system/workflow/SKILL.md @@ -0,0 +1,41 @@ +--- +name: workflow +description: Recover and coordinate DevSpace project workflow state across sessions, including Plan, Goal, mode, routing, and concise history. +license: MIT +metadata: + version: 3.0.0 + author: DevSpace + category: system-workflow + updated: 2026-06-22 +--- + +# DevSpace Workflow + +Use this Skill when a request depends on cross-session recovery, Plan Mode, workspace isolation, workflow history, or Skill routing. + +## Resume sequence + +After `open_workspace`, inspect `workflowDigest` first. + +- No active state: work normally; create a Plan or Goal only when the task calls for durable state. +- Existing Plan or Goal: call `get_plan` or `get_goal` only when the full definition matters. +- Relevant earlier decision: call paginated `get_workflow_history`; do not load history by default. +- A matching request can resume state. An incompatible Goal follows the Goal conflict procedure. + +## Isolation + +- Same canonical project root: shared across sessions and DevSpace restarts. +- Different project root: isolated. +- Different Git worktree root: isolated by default. +- `workspaceId` is only a session handle, never durable Plan or Goal identity. + +## Modes + +`plan` is a workflow preference, not a security boundary. + +- `plan`: inspect, ask material questions, write or revise the Plan, then wait for implementation approval. +- `default`: perform approved changes, test them, and maintain relevant state. + +Plan Mode does not grant filesystem, shell, Git, network, credential, or service-management permission. + +Read [references/routing.md](references/routing.md), [references/recovery.md](references/recovery.md), and [references/mode.md](references/mode.md) for details. \ No newline at end of file diff --git a/skills/.system/workflow/references/mode.md b/skills/.system/workflow/references/mode.md new file mode 100644 index 0000000..8790aa1 --- /dev/null +++ b/skills/.system/workflow/references/mode.md @@ -0,0 +1,13 @@ +# Collaboration Mode + +## Plan mode + +Plan Mode is for repository inspection, material clarification, and durable Plan updates. `update_plan` is allowed. Project source edits, shell mutations, Git mutations, and implementation claims should wait for user approval or a return to default mode. + +## Default mode + +Default mode permits approved work through the existing DevSpace authorization boundaries. Keep a relevant Plan or Goal accurate when work changes its steps, verification evidence, blocker, or status. + +## History + +Workflow events are concise structured records such as `plan.updated`, `goal.blocked`, and `mode.changed`. They are not chat history and must not contain raw tool output, full diffs, logs, credentials, or source snapshots. \ No newline at end of file diff --git a/skills/.system/workflow/references/recovery.md b/skills/.system/workflow/references/recovery.md new file mode 100644 index 0000000..429da2d --- /dev/null +++ b/skills/.system/workflow/references/recovery.md @@ -0,0 +1,10 @@ +# Workflow Recovery + +1. Call `open_workspace` once for a project root or worktree. +2. Read `workflowDigest`. +3. Load `get_plan` only when the requested work needs Plan steps, risks, validation, or revision. +4. Load `get_goal` only when the requested work needs Goal scope, criteria, verification, status, metrics, or revision. +5. Use `get_workflow_history` only for a specific past decision; use its cursor rather than requesting unbounded history. +6. Before updating a Plan or Goal, use the revision that was read. On conflict, reload and merge. + +Do not treat a digest as enough context to execute a historical Plan without reading it. Do not create a Goal merely because no Goal exists. \ No newline at end of file diff --git a/skills/.system/workflow/references/routing.md b/skills/.system/workflow/references/routing.md new file mode 100644 index 0000000..6760fa9 --- /dev/null +++ b/skills/.system/workflow/references/routing.md @@ -0,0 +1,14 @@ +# Workflow Routing + +`/plan` and `/goal` are DevSpace routing aliases, not native ChatGPT slash commands. + +```text +/plan -> skills/.system/plan +/goal -> skills/.system/goal +``` + +The aliases are fixed system routes. Local, installed, and global Skills cannot override them. + +Use `resolve_skill` to load a selected Skill. Use `search_skills` to discover optional project-local, installed, or global Skills without loading every instruction into context. + +Skill resources are accessed through `skill://` locators only after a Skill has been resolved. Do not expose server absolute paths in model-facing output. \ No newline at end of file diff --git a/src/package-smoke.test.ts b/src/package-smoke.test.ts index 303ea30..9a82f72 100644 --- a/src/package-smoke.test.ts +++ b/src/package-smoke.test.ts @@ -12,14 +12,26 @@ assert.equal(Array.isArray(packageJson.files), true); assert.equal((packageJson.files as string[]).includes("skills"), true); for (const path of [ - "skills/.system/devspace-plan/SKILL.md", - "skills/.system/devspace-plan/references/plan-state.md", - "skills/.system/devspace-goal/SKILL.md", - "skills/.system/devspace-goal/references/goal-state.md", - "skills/.system/devspace-workflow/SKILL.md", - "skills/.system/devspace-workflow/references/workflow-recovery.md", - "skills/.system/senior-architect/SKILL.md", + "skills/.system/README.md", + "skills/.system/plan/SKILL.md", + "skills/.system/plan/references/state.md", + "skills/.system/goal/SKILL.md", + "skills/.system/goal/references/metrics.md", + "skills/.system/workflow/SKILL.md", + "skills/.system/workflow/references/routing.md", + "skills/.system/architecture-review/SKILL.md", "skills/.system/skill-authoring/SKILL.md", ]) { assert.equal(existsSync(resolve(projectRoot, path)), true, `Missing bundled Skill asset: ${path}`); } + +for (const removedPath of [ + "skills/openai", + "skills/.system/devspace-plan", + "skills/.system/devspace-goal", + "skills/.system/devspace-workflow", + "skills/.system/senior-architect-lite", + "skills/.system/skill-authoring-lite", +]) { + assert.equal(existsSync(resolve(projectRoot, removedPath)), false, `Unexpected legacy Skill path: ${removedPath}`); +} diff --git a/src/prompting.test.ts b/src/prompting.test.ts index 6693f16..07c85ec 100644 --- a/src/prompting.test.ts +++ b/src/prompting.test.ts @@ -26,11 +26,11 @@ assert.match(instructions, /Prefer action over explanation\./); assert.match(instructions, /Keep responses terse and operational\./); assert.match(instructions, /Do not add long design discussion, repeated background, or speculative future improvements unless the user explicitly asks for them\./); assert.match(instructions, /When the user sends a short reply such as '1B, 2A', treat it as workflow input and continue instead of explaining the mechanism back to them\./); -assert.match(instructions, /When the user mentions a skill name, \/plan, or \/goal, use resolve_skill to load the relevant SKILL\.md instructions\./); +assert.match(instructions, /When the user mentions a Skill name, \/plan, or \/goal, use resolve_skill to load the relevant SKILL\.md instructions\./); assert.match(instructions, /Plan and Goal as project-scoped shared workflow state/); assert.match(instructions, /open_workspace returns only workflowDigest/); assert.match(instructions, /update_plan is allowed in plan mode/); -assert.match(instructions, /\/plan always resolves to DevSpace's devspace-plan Skill/); +assert.match(instructions, /\/plan always resolves to DevSpace's system plan Skill/); assert.match(instructions, /Treat \/plan and \/goal as aliases, not native ChatGPT slash commands\./); assert.match(instructions, /Use handle_workspace_command only for compact pending-input replies or legacy workflow compatibility\./); diff --git a/src/prompting.ts b/src/prompting.ts index 18480e7..5fdb453 100644 --- a/src/prompting.ts +++ b/src/prompting.ts @@ -17,7 +17,7 @@ export function serverInstructions( : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; const skills = context.skillsEnabled - ? `When a task matches a Skill, use resolve_skill to load its SKILL.md instructions. Use search_skills to discover vendored OpenAI Skills without loading all of them. Skill resources use skill:// locators; ${toolNames.read} only permits the resolved SKILL.md and resources under an activated Skill directory. ` + ? `When a task matches a Skill, use resolve_skill to load its SKILL.md instructions. Use search_skills to discover optional project-local, installed, and global Skills without loading all of them. Skill resources use skill:// locators; ${toolNames.read} only permits the resolved SKILL.md and resources under an activated Skill directory. ` : ""; const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; @@ -33,7 +33,7 @@ export function serverInstructions( " Prefer action over explanation. Keep responses terse and operational. For mode switches, goal updates, confirmations, cancellations, pending answers, and other straightforward workflow steps, return only the necessary status or next action. Do not add long design discussion, repeated background, or speculative future improvements unless the user explicitly asks for them. When the user sends a short reply such as '1B, 2A', treat it as workflow input and continue instead of explaining the mechanism back to them."; const commands = - " When the user mentions a skill name, /plan, or /goal, use resolve_skill to load the relevant SKILL.md instructions. /plan always resolves to DevSpace's devspace-plan Skill and /goal always resolves to devspace-goal; vendored OpenAI Skills do not override these aliases. Treat /plan and /goal as aliases, not native ChatGPT slash commands. Use handle_workspace_command only for compact pending-input replies or legacy workflow compatibility. For concise pending-input replies, prefer answer_user_input(text) over paraphrasing the user's message."; + " When the user mentions a Skill name, /plan, or /goal, use resolve_skill to load the relevant SKILL.md instructions. /plan always resolves to DevSpace's system plan Skill and /goal always resolves to its system goal Skill; local, installed, and global Skills do not override these aliases. Treat /plan and /goal as aliases, not native ChatGPT slash commands. Use handle_workspace_command only for compact pending-input replies or legacy workflow compatibility. For concise pending-input replies, prefer answer_user_input(text) over paraphrasing the user's message."; return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, shell, skill, plan, and goal tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}${planning}${style}${commands} Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, apply_workspace_patch for coordinated multi-file patches, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Use git_push for explicit push requests instead of raw git push through ${toolNames.shell}. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; } @@ -43,7 +43,7 @@ export function workspaceInstruction( skillsEnabled: boolean, ): string { const base = skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. Use resolve_skill for task-matched Skills and search_skills for vendored Skill discovery." + ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. Use resolve_skill for task-matched Skills and search_skills for optional Skill discovery." : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; if (mode === "plan") { diff --git a/src/server.ts b/src/server.ts index 1c1d7b1..7d9290a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -234,9 +234,7 @@ function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { const skillSourceOutputSchema = z.enum([ "devspace_system", "local", - "legacy_core", "installed", - "official_vendored", "global", ]); @@ -760,7 +758,6 @@ function createMcpServer( .filter((skill) => ( skill.source === "devspace_system" || skill.source === "local" || - skill.source === "legacy_core" || skill.source === "installed" )) .sort((left, right) => left.name.localeCompare(right.name)); @@ -861,7 +858,7 @@ function createMcpServer( "Resolve a skill name or alias such as /plan or /goal for the current workspace. This tool only reads and returns skill instructions; it does not execute installation, file changes, or commands.", inputSchema: { workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), - nameOrAlias: z.string().describe("Skill name, qualifiedId, or alias such as devspace-plan, openai:.curated/define-goal, /plan, or /goal."), + nameOrAlias: z.string().describe("Skill name or stable alias such as plan, goal, /plan, or /goal."), }, outputSchema: { result: z.string(), @@ -3504,9 +3501,7 @@ function summarizeSkills(skills: Array<{ source: SkillSource }>): { const bySource: Record = { devspace_system: 0, local: 0, - legacy_core: 0, installed: 0, - official_vendored: 0, global: 0, }; for (const skill of skills) bySource[skill.source]++; diff --git a/src/skill-manager.test.ts b/src/skill-manager.test.ts index ac89a1e..0a2bba4 100644 --- a/src/skill-manager.test.ts +++ b/src/skill-manager.test.ts @@ -21,7 +21,7 @@ try { const agentDir = join(root, "agent"); const localSkill = join(root, "local-installed-skill"); const remoteRepo = join(root, "remote-skill-repo"); - const conflictingLocal = join(root, "devspace-plan"); + const conflictingLocal = join(root, "plan"); const invalidDirSkill = join(root, "mismatched-dir"); const symlinkSkill = join(root, "symlink-skill"); const pluginLikeRoot = join(root, "plugin-like-root"); @@ -68,7 +68,7 @@ try { join(conflictingLocal, "SKILL.md"), [ "---", - "name: devspace-plan", + "name: plan", "description: Should conflict with system skill.", "---", "", @@ -191,7 +191,7 @@ try { source: { kind: "local", path: conflictingLocal }, localPathResolver: (path) => path, }), - /DevSpace 核心/, + /DevSpace 系统/, ); await assert.rejects( diff --git a/src/skill-manager.ts b/src/skill-manager.ts index 19c9dd2..3772478 100644 --- a/src/skill-manager.ts +++ b/src/skill-manager.ts @@ -283,12 +283,7 @@ async function assertInstallConflicts( const existing = loaded.skills.find((skill) => skill.name === skillName); if (!existing) return; - if ( - existing.source === "devspace_system" || - existing.source === "official_vendored" || - existing.source === "local" || - existing.source === "legacy_core" - ) { + if (existing.source === "devspace_system" || existing.source === "local") { throw new Error( `Skill ${skillName} conflicts with an existing ${skillSourceLabel(existing.source)} skill.`, ); diff --git a/src/skills.test.ts b/src/skills.test.ts index 111f0d2..1d4de5c 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -1,7 +1,7 @@ +import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import assert from "node:assert/strict"; import { loadConfig } from "./config.js"; import { loadWorkspaceSkills, @@ -27,14 +27,9 @@ try { description: "Installed Skill.", body: "# Installed Skill", }); - await writeSkill(join(projectRoot, "skills", "core", "duplicate-priority-skill"), { - name: "duplicate-priority-skill", - description: "Legacy core loses to local.", - body: "# Legacy Duplicate", - }); await writeSkill(join(projectRoot, "skills", "local", "duplicate-priority-skill"), { name: "duplicate-priority-skill", - description: "Local wins over legacy, installed, and global.", + description: "Local wins over installed and global.", body: "# Local Duplicate", }); await writeSkill(join(projectRoot, "skills", "installed", "duplicate-priority-skill"), { @@ -42,9 +37,9 @@ try { description: "Installed loses to local.", body: "# Installed Duplicate", }); - await writeSkill(join(projectRoot, "skills", "local", "devspace-plan"), { - name: "devspace-plan", - description: "Attempted local override that must lose.", + await writeSkill(join(projectRoot, "skills", "local", "plan"), { + name: "plan", + description: "Attempted local system override that must lose.", body: "# Local Plan Override", }); await writeSkill(join(agentDir, "skills", "global-only-skill"), { @@ -81,55 +76,55 @@ try { assert.equal(loaded.skills.some((skill) => skill.name === "installed-skill" && skill.source === "installed"), true); assert.equal(loaded.skills.some((skill) => skill.name === "global-only-skill" && skill.source === "global"), true); assert.equal(loaded.skills.some((skill) => skill.name === "external-global-skill" && skill.source === "global"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "devspace-plan" && skill.source === "devspace_system"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "devspace-goal" && skill.source === "devspace_system"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "devspace-workflow" && skill.source === "devspace_system"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "senior-architect" && skill.source === "devspace_system"), true); - assert.equal(loaded.skills.some((skill) => skill.name === "skill-authoring" && skill.source === "devspace_system"), true); + assert.equal(loaded.skills.some((skill) => skill.baseDir.includes("/skills/core/")), false); + assert.deepEqual( + loaded.skills.filter((skill) => skill.source === "devspace_system").map((skill) => skill.name).sort(), + ["architecture-review", "goal", "plan", "skill-authoring", "workflow"], + ); const duplicate = loaded.skills.find((skill) => skill.name === "duplicate-priority-skill"); assert.ok(duplicate); assert.equal(duplicate.source, "local"); assert.match(duplicate.filePath, /skills\/local\/duplicate-priority-skill\/SKILL\.md$/); - assert.equal( - loaded.diagnostics.some((diagnostic) => String(diagnostic.message).includes("skills/core is deprecated")), - true, - ); - const plan = loaded.skills.find((skill) => skill.name === "devspace-plan"); + const plan = loaded.skills.find((skill) => skill.name === "plan"); assert.ok(plan); assert.equal(plan.source, "devspace_system"); - assert.doesNotMatch(plan.filePath, /skills\/local\/devspace-plan\/SKILL\.md$/); - assert.match(plan.locator, /^skill:\/\/devspace-system\/devspace-plan\/SKILL\.md$/); + assert.doesNotMatch(plan.filePath, /skills\/local\/plan\/SKILL\.md$/); + assert.match(plan.locator, /^skill:\/\/devspace-system\/plan\/SKILL\.md$/); const resolvedPlan = await resolveSkillDefinition(loaded.skills, "/plan"); - assert.equal(resolvedPlan.name, "devspace-plan"); - assert.equal(resolvedPlan.qualifiedId, "devspace-plan"); + assert.equal(resolvedPlan.name, "plan"); + assert.equal(resolvedPlan.qualifiedId, "plan"); assert.equal(resolvedPlan.source, "devspace_system"); assert.equal(resolvedPlan.alias, "/plan"); assert.equal(resolvedPlan.mode, "read_only"); - assert.match(resolvedPlan.instructions, /# DevSpace Plan Workflow/); - assert.match(resolvedPlan.path, /^skill:\/\//); + assert.match(resolvedPlan.instructions, /# DevSpace Plan/); + + await assert.rejects( + () => resolveSkillDefinition(loaded.skills, "create-plan"), + /Skill not found: create-plan/, + ); const resolvedGoal = await resolveSkillDefinition(loaded.skills, "/goal"); - assert.equal(resolvedGoal.name, "devspace-goal"); + assert.equal(resolvedGoal.name, "goal"); assert.equal(resolvedGoal.source, "devspace_system"); assert.equal(resolvedGoal.alias, "/goal"); assert.equal(resolvedGoal.mode, "normal"); - assert.match(resolvedGoal.instructions, /# DevSpace Goal Workflow/); + assert.match(resolvedGoal.instructions, /# DevSpace Goal/); + await assert.rejects(() => resolveSkillDefinition(loaded.skills, "define-goal"), /Skill not found: define-goal/); + await assert.rejects(() => resolveSkillDefinition(loaded.skills, "devspace-workflow"), /Skill not found: devspace-workflow/); + await assert.rejects(() => resolveSkillDefinition(loaded.skills, "senior-architect-lite"), /Skill not found: senior-architect-lite/); const skillFileRead = resolveSkillReadPath(loaded.skills, new Set(), resolvedPlan.path); assert.equal(skillFileRead?.isSkillFile, true); - assert.equal(skillFileRead?.skill.name, "devspace-plan"); + assert.equal(skillFileRead?.skill.name, "plan"); - const resourceLocator = resolvedPlan.path.replace("SKILL.md", "references/plan-state.md"); + const resourceLocator = resolvedPlan.path.replace("SKILL.md", "references/state.md"); assert.equal(resolveSkillReadPath(loaded.skills, new Set(), resourceLocator), undefined); const activated = new Set(); markSkillActivated(activated, resolvedPlan.skill); - assert.equal( - resolveSkillReadPath(loaded.skills, activated, resourceLocator)?.isSkillFile, - false, - ); + assert.equal(resolveSkillReadPath(loaded.skills, activated, resourceLocator)?.isSkillFile, false); const resolvedExplicit = await resolveSkillDefinition(loaded.skills, "external-global-skill"); assert.equal(resolvedExplicit.source, "global"); diff --git a/src/skills.ts b/src/skills.ts index 996eab9..e0f68d5 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,24 +1,18 @@ -import { existsSync, readdirSync } from "node:fs"; +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, relative, resolve, sep } from "node:path"; +import { resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { loadSkills, loadSkillsFromDir, - type Skill, type LoadSkillsResult, + type Skill, } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; import { expandHomePath, isPathInsideRoot } from "./roots.js"; -export type SkillSource = - | "devspace_system" - | "local" - | "legacy_core" - | "installed" - | "official_vendored" - | "global"; +export type SkillSource = "devspace_system" | "local" | "installed" | "global"; export type SkillResolveMode = "read_only" | "normal"; export interface DevSpaceSkill extends Skill { @@ -27,7 +21,6 @@ export interface DevSpaceSkill extends Skill { locator: string; aliases?: string[]; resolveMode: SkillResolveMode; - legacyCore?: boolean; } export interface LoadedSkills { @@ -57,28 +50,26 @@ interface SkillBatch { diagnostics: LoadSkillsResult["diagnostics"]; } -interface SkillSourceOptions { - legacyCore?: boolean; - qualifiedPrefix?: string; -} - const PLAN_ALIAS = "/plan"; const GOAL_ALIAS = "/goal"; +const SYSTEM_SKILL_NAMES = [ + "plan", + "goal", + "workflow", + "architecture-review", + "skill-authoring", +] as const; export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSkills { if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; - const batches: SkillBatch[] = [ + return mergeLoadedSkills([ ...loadDevSpaceSystemSkillBatches(), loadSkillsFromSourceDir(workspaceLocalSkillPath(cwd), "local"), - loadSkillsFromSourceDir(legacyWorkspaceCorePath(cwd), "legacy_core", { legacyCore: true }), loadSkillsFromSourceDir(workspaceInstalledSkillPath(cwd), "installed"), - ...loadOfficialVendoredSkillBatches(), loadSkillsFromSourceDir(globalSkillPath(config.agentDir), "global"), loadExplicitSkillPaths(config, cwd), - ]; - - return mergeLoadedSkills(batches); + ]); } export async function resolveSkillDefinition( @@ -88,9 +79,9 @@ export async function resolveSkillDefinition( const lookup = normalizeSkillLookup(nameOrAlias); const alias = lookup === PLAN_ALIAS || lookup === GOAL_ALIAS ? lookup : undefined; const fixedName = alias === PLAN_ALIAS - ? "devspace-plan" + ? "plan" : alias === GOAL_ALIAS - ? "devspace-goal" + ? "goal" : lookup; const skill = alias @@ -98,9 +89,7 @@ export async function resolveSkillDefinition( : skills.find((candidate) => candidate.qualifiedId === fixedName) ?? skills.find((candidate) => candidate.name === fixedName); - if (!skill) { - throw new Error(`Skill not found: ${nameOrAlias}`); - } + if (!skill) throw new Error(`Skill not found: ${nameOrAlias}`); return { name: skill.name, @@ -108,7 +97,7 @@ export async function resolveSkillDefinition( source: skill.source, path: skill.locator, alias, - mode: skill.resolveMode, + mode: fixedName === "plan" && skill.source === "devspace_system" ? "read_only" : skill.resolveMode, instructions: await readFile(skill.filePath, "utf8"), skill, }; @@ -123,56 +112,43 @@ export function resolveSkillReadPath( if (locatorMatch) return locatorMatch; const absolutePath = resolve(expandHomePath(inputPath)); - for (const skill of skills) { - const skillFilePath = resolve(skill.filePath); - if (absolutePath === skillFilePath) { + if (absolutePath === resolve(skill.filePath)) { return { absolutePath, skill, isSkillFile: true }; } } for (const skill of skills) { const baseDir = resolve(skill.baseDir); - if (!activatedSkillDirs.has(baseDir)) continue; - if (!isPathInsideRoot(absolutePath, baseDir)) continue; - + if (!activatedSkillDirs.has(baseDir) || !isPathInsideRoot(absolutePath, baseDir)) continue; return { absolutePath, skill, isSkillFile: false }; } return undefined; } -export function markSkillActivated( - activatedSkillDirs: Set, - skill: DevSpaceSkill, -): void { +export function markSkillActivated(activatedSkillDirs: Set, skill: DevSpaceSkill): void { activatedSkillDirs.add(resolve(skill.baseDir)); } export function formatPathForPrompt(path: string): string { const home = resolve(homedir()); const resolvedPath = resolve(path); - if (resolvedPath === home) return "~"; if (resolvedPath.startsWith(`${home}${sep}`)) { return `~/${resolvedPath.slice(home.length + 1).split(sep).join("/")}`; } - return resolvedPath.split(sep).join("/"); } export function skillSourceLabel(source: SkillSource): string { switch (source) { case "devspace_system": - return "DevSpace 核心"; + return "DevSpace 系统"; case "local": return "项目自定义"; - case "legacy_core": - return "项目 legacy core"; case "installed": return "项目已安装"; - case "official_vendored": - return "OpenAI 官方副本"; case "global": return "全局已安装"; } @@ -180,41 +156,11 @@ export function skillSourceLabel(source: SkillSource): string { function loadDevSpaceSystemSkillBatches(): SkillBatch[] { const root = bundledSystemSkillPath(); - if (!existsSync(root)) return []; - - const coreDirectories = new Set([ - "devspace-plan", - "devspace-goal", - "devspace-workflow", - "senior-architect", - "skill-authoring", - ]); - return readdirSync(root, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && coreDirectories.has(entry.name)) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((entry) => loadSkillsFromSourceDir(resolve(root, entry.name), "devspace_system")); -} - -function loadOfficialVendoredSkillBatches(): SkillBatch[] { - const root = officialVendoredSkillsPath(); - const channels = [".system", ".curated", ".experimental"]; - return channels.map((channel) => - loadSkillsFromSourceDir(resolve(root, channel), "official_vendored", { - qualifiedPrefix: `openai:${channel}`, - }), - ); + return SYSTEM_SKILL_NAMES.map((name) => loadSkillsFromSourceDir(resolve(root, name), "devspace_system")); } function bundledSystemSkillPath(): string { - return resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", ".system"); -} - -function officialVendoredSkillsPath(): string { - return resolve(bundledSystemSkillPath(), "openai", "skills"); -} - -function legacyWorkspaceCorePath(cwd: string): string { - return resolve(cwd, "skills", "core"); + return resolve(fileURLToPath(new URL("../skills/.system", import.meta.url))); } function workspaceLocalSkillPath(cwd: string): string { @@ -229,85 +175,50 @@ function globalSkillPath(agentDir: string): string { return resolve(agentDir, "skills"); } -function loadSkillsFromSourceDir( - dir: string, - source: SkillSource, - options: SkillSourceOptions = {}, -): SkillBatch { +function loadSkillsFromSourceDir(dir: string, source: SkillSource): SkillBatch { if (!existsSync(dir)) return { skills: [], diagnostics: [] }; - const loaded = loadSkillsFromDir({ dir, source: source === "global" ? "user" : "system", }); - - const diagnostics = [...loaded.diagnostics]; - if (options.legacyCore && loaded.skills.length > 0) { - diagnostics.push({ - type: "warning", - message: "skills/core is deprecated; migrate these skills to skills/local or skills/installed.", - path: dir, - }); - } - return { - diagnostics, - skills: loaded.skills.map((skill) => decorateSkill(skill, source, dir, options)), + diagnostics: [...loaded.diagnostics], + skills: loaded.skills.map((skill) => decorateSkill(skill, source)), }; } function loadExplicitSkillPaths(config: ServerConfig, cwd: string): SkillBatch { - if (config.skillPaths.length === 0) { - return { skills: [], diagnostics: [] }; - } - + if (config.skillPaths.length === 0) return { skills: [], diagnostics: [] }; const loaded = loadSkills({ cwd, agentDir: config.agentDir, skillPaths: config.skillPaths, includeDefaults: false, }); - return { diagnostics: loaded.diagnostics, - skills: loaded.skills.map((skill) => decorateSkill(skill, "global", dirname(skill.filePath))), + skills: loaded.skills.map((skill) => decorateSkill(skill, "global")), }; } -function decorateSkill( - skill: Skill, - source: SkillSource, - sourceRoot: string, - options: SkillSourceOptions = {}, -): DevSpaceSkill { - const relativePath = relative(sourceRoot, skill.baseDir).split(sep).join("/"); - const qualifiedId = options.qualifiedPrefix - ? `${options.qualifiedPrefix}/${relativePath || skill.name}` - : skill.name; - const locator = skillLocator(source, qualifiedId); - +function decorateSkill(skill: Skill, source: SkillSource): DevSpaceSkill { return { ...skill, source, - qualifiedId, - locator, + qualifiedId: skill.name, + locator: skillLocator(source, skill.name), aliases: aliasesForSkill(skill.name, source), - resolveMode: resolveModeForSkill(skill.name), - legacyCore: options.legacyCore, + resolveMode: skill.name === "plan" && source === "devspace_system" ? "read_only" : "normal", }; } function aliasesForSkill(name: string, source: SkillSource): string[] | undefined { if (source !== "devspace_system") return undefined; - if (name === "devspace-plan") return [PLAN_ALIAS]; - if (name === "devspace-goal") return [GOAL_ALIAS]; + if (name === "plan") return [PLAN_ALIAS]; + if (name === "goal") return [GOAL_ALIAS]; return undefined; } -function resolveModeForSkill(name: string): SkillResolveMode { - return name === "devspace-plan" ? "read_only" : "normal"; -} - function mergeLoadedSkills(batches: SkillBatch[]): LoadedSkills { const winners = new Map(); const diagnostics: LoadSkillsResult["diagnostics"] = []; @@ -315,13 +226,11 @@ function mergeLoadedSkills(batches: SkillBatch[]): LoadedSkills { for (const batch of batches) { diagnostics.push(...batch.diagnostics); for (const skill of batch.skills) { - const key = skill.source === "official_vendored" ? skill.qualifiedId : skill.name; - const existing = winners.get(key); + const existing = winners.get(skill.name); if (!existing) { - winners.set(key, skill); + winners.set(skill.name, skill); continue; } - diagnostics.push({ type: "collision", message: `name "${skill.name}" collision (${skillSourceLabel(existing.source)} wins over ${skillSourceLabel(skill.source)})`, @@ -336,10 +245,7 @@ function mergeLoadedSkills(batches: SkillBatch[]): LoadedSkills { } } - return { - skills: Array.from(winners.values()), - diagnostics, - }; + return { skills: Array.from(winners.values()), diagnostics }; } function resolveLocatorReadPath( @@ -354,9 +260,8 @@ function resolveLocatorReadPath( return { absolutePath: resolve(skill.filePath), skill, isSkillFile: true }; } - const prefix = `${skill.locator.slice(0, -"SKILL.md".length)}`; - if (!inputPath.startsWith(prefix)) continue; - if (!activatedSkillDirs.has(resolve(skill.baseDir))) continue; + const prefix = skill.locator.slice(0, -"SKILL.md".length); + if (!inputPath.startsWith(prefix) || !activatedSkillDirs.has(resolve(skill.baseDir))) continue; const relativePath = inputPath.slice(prefix.length); if (!relativePath || relativePath === "SKILL.md") { @@ -370,19 +275,12 @@ function resolveLocatorReadPath( return undefined; } -function skillLocator(source: SkillSource, qualifiedId: string): string { - const namespace = source === "devspace_system" - ? "devspace-system" - : source === "official_vendored" - ? "official-vendored" - : source; - return `skill://${namespace}/${qualifiedId}/SKILL.md`; +function skillLocator(source: SkillSource, name: string): string { + const namespace = source === "devspace_system" ? "devspace-system" : source; + return `skill://${namespace}/${name}/SKILL.md`; } function normalizeSkillLookup(nameOrAlias: string): string { const trimmed = nameOrAlias.trim().replace(/^@\S+\s+/, ""); - if (trimmed.startsWith("/")) { - return trimmed.split(/\s+/)[0] ?? trimmed; - } - return trimmed; + return trimmed.startsWith("/") ? (trimmed.split(/\s+/)[0] ?? trimmed) : trimmed; } From 3a1f6e8e73a31f07153b34d4ae4e08d6fb82f1bf Mon Sep 17 00:00:00 2001 From: haloworker Date: Mon, 22 Jun 2026 08:01:50 +0800 Subject: [PATCH 27/41] Annotate workspace service and skills commands --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ README.zh-CN.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/README.md b/README.md index 4e06119..278b145 100644 --- a/README.md +++ b/README.md @@ -115,10 +115,19 @@ OAuth approvals and tokens, and forces connected clients to reauthorize. Persist the workspace roots DevSpace is allowed to open: ```bash +# Add a workspace and mark it as the default one devspace workspace add ~/workspace/project-a --default + +# Add another workspace without changing the default devspace workspace add ~/workspace/project-b + +# Show configured workspaces devspace workspace list + +# Switch the default workspace devspace workspace default ~/workspace/project-b + +# Remove a workspace from the allowlist devspace workspace remove ~/workspace/project-a ``` @@ -143,13 +152,28 @@ creates it for the current platform and starts it; if it already exists, it just starts it. It does not manage arbitrary system services. ```bash +# Start the managed DevSpace background service devspace service start + +# Show whether the service is installed and running devspace service status + +# Read the service log output devspace service logs + +# Restart the running service devspace service restart + +# Stop the running service devspace service stop + +# Disable automatic service startup devspace service disable + +# Remove the installed DevSpace background service devspace service remove + +# Check service-manager support and current health devspace service doctor ``` @@ -194,15 +218,31 @@ ChatGPT Plus on the web cannot natively install or register Codex Skills. DevSpa Manage installed skills with: ```bash +# Install a skill for the current context devspace skills install --repo openai/skills --path skills/.curated/research + +# Install a skill for one specific workspace devspace skills install --workspace /path/to/project --repo openai/skills --path skills/.curated/research + +# List skills for the current context devspace skills list + +# List skills for one specific workspace devspace skills list --workspace /path/to/project + +# Remove a skill from the current context devspace skills remove research + +# Remove a skill from one specific workspace devspace skills remove --workspace /path/to/project research +# Install a global skill devspace skills install -g --repo openai/skills --path skills/.curated/research + +# List global skills devspace skills list -g + +# Remove a global skill devspace skills remove -g research ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 1885b2e..9f84946 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -107,10 +107,19 @@ devspace config key 持久化保存 DevSpace 被允许打开的工作区根目录: ```bash +# 添加工作区,并设为默认工作区 devspace workspace add ~/workspace/project-a --default + +# 再添加一个工作区,但不修改默认项 devspace workspace add ~/workspace/project-b + +# 查看当前工作区列表 devspace workspace list + +# 把默认工作区切换到 project-b devspace workspace default ~/workspace/project-b + +# 删除一个工作区配置 devspace workspace remove ~/workspace/project-a ``` @@ -129,13 +138,28 @@ devspace serve --add-dir ~/scratch/project-c --workspace ~/workspace/project-b DevSpace 的服务管理只负责管理 DevSpace 本身。`devspace service start` 是统一入口:如果后台服务不存在,DevSpace 会按当前平台创建并启动;如果已经存在,则只执行启动。它不会管理任意系统服务。 ```bash +# 启动 DevSpace 后台服务 devspace service start + +# 查看服务当前状态 devspace service status + +# 查看服务日志 devspace service logs + +# 重启服务 devspace service restart + +# 停止服务 devspace service stop + +# 禁用服务自启动 devspace service disable + +# 删除已安装的 DevSpace 后台服务 devspace service remove + +# 检查服务管理环境和健康状态 devspace service doctor ``` @@ -177,15 +201,31 @@ DevSpace 内置一组稳定的工作流与工程技能,用于 Plan、Goal、 用这些命令管理已安装技能: ```bash +# 为当前上下文安装 skill devspace skills install --repo openai/skills --path skills/.curated/research + +# 只为某个工作区安装 skill devspace skills install --workspace /path/to/project --repo openai/skills --path skills/.curated/research + +# 查看当前上下文可用的 skill devspace skills list + +# 查看指定工作区的 skill devspace skills list --workspace /path/to/project + +# 从当前上下文移除 skill devspace skills remove research + +# 从指定工作区移除 skill devspace skills remove --workspace /path/to/project research +# 安装全局 skill devspace skills install -g --repo openai/skills --path skills/.curated/research + +# 查看全局 skill 列表 devspace skills list -g + +# 删除全局 skill devspace skills remove -g research ``` From 730a6b6da5ca033491f418069da2f266977e15d5 Mon Sep 17 00:00:00 2001 From: haloworker Date: Mon, 22 Jun 2026 08:06:24 +0800 Subject: [PATCH 28/41] Annotate config commands --- README.md | 9 +++++++++ README.zh-CN.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/README.md b/README.md index 278b145..81e7237 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,19 @@ https://your-tunnel-host.example.com/mcp Update the local server config with short commands: ```bash +# Show the effective runtime configuration devspace config show + +# Change the local listening port devspace config port 7676 + +# Change the local bind host devspace config host 127.0.0.1 + +# Set the public domain or URL devspace config domain devspace.example.com + +# Rotate the Owner password devspace config key ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 9f84946..13fac29 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -89,10 +89,19 @@ https://your-tunnel-host.example.com/mcp 你可以用这些简短命令更新本地服务配置: ```bash +# 查看当前生效配置 devspace config show + +# 修改本地监听端口 devspace config port 7676 + +# 修改本地绑定地址 devspace config host 127.0.0.1 + +# 设置公网域名或 URL devspace config domain devspace.example.com + +# 轮换 Owner 密码 devspace config key ``` From 819fed58875de2184795a91985754755b5779a5a Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Mon, 22 Jun 2026 00:09:35 +0000 Subject: [PATCH 29/41] docs(skills): record OpenAI upstream metadata --- skills/.system/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/skills/.system/README.md b/skills/.system/README.md index a1569a9..ee0cd21 100644 --- a/skills/.system/README.md +++ b/skills/.system/README.md @@ -21,6 +21,15 @@ This directory contains DevSpace-owned system Skills only. - External Skills belong in `skills/installed/`, not in `.system`. - Old system Skill identifiers are not supported; use the names listed in the table above. +## OpenAI Skills upstream record + +| Field | Value | +|---|---| +| Upstream repository | `https://github.com/openai/skills.git` | +| Upstream Git commit | `972cb867affac58fda9afa76bb1a19b399a278cf` | +| Last sync check (UTC) | `2026-06-21T23:57:02Z` | +| Sync policy | DevSpace does not mirror the full upstream repository into `.system`; external Skills are installed individually into `skills/installed/`. | + ## Change log | Date | Version | Change | From b72e44326c39461814060aa3dee740b2d1a9ba6c Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Mon, 22 Jun 2026 00:19:33 +0000 Subject: [PATCH 30/41] chore(skills): sync system versions with package --- skills/.system/README.md | 11 ++++++----- skills/.system/architecture-review/SKILL.md | 2 +- skills/.system/goal/SKILL.md | 2 +- skills/.system/plan/SKILL.md | 2 +- skills/.system/skill-authoring/SKILL.md | 2 +- skills/.system/workflow/SKILL.md | 2 +- src/package-smoke.test.ts | 22 +++++++++++++++++++-- 7 files changed, 31 insertions(+), 12 deletions(-) diff --git a/skills/.system/README.md b/skills/.system/README.md index ee0cd21..40c98f3 100644 --- a/skills/.system/README.md +++ b/skills/.system/README.md @@ -20,6 +20,7 @@ This directory contains DevSpace-owned system Skills only. - Project-local, installed, and global Skills cannot override reserved names or aliases. - External Skills belong in `skills/installed/`, not in `.system`. - Old system Skill identifiers are not supported; use the names listed in the table above. +- System Skill frontmatter and the change-log Version column track the root `package.json` version. Update them in the same release commit. ## OpenAI Skills upstream record @@ -34,8 +35,8 @@ This directory contains DevSpace-owned system Skills only. | Date | Version | Change | |---|---:|---| -| 2026-06-22 | 3.0 | `create-plan` and `devspace-plan` merged into `plan` | -| 2026-06-22 | 3.0 | `define-goal` and `devspace-goal` merged into `goal` | -| 2026-06-22 | 3.0 | `devspace-workflow` renamed to `workflow` | -| 2026-06-22 | 3.0 | architecture and authoring Skills consolidated; `-lite` copies removed | -| 2026-06-22 | 3.0 | full OpenAI Skill mirror removed from the package | +| 2026-06-22 | 1.0.1 | `create-plan` and `devspace-plan` merged into `plan` | +| 2026-06-22 | 1.0.1 | `define-goal` and `devspace-goal` merged into `goal` | +| 2026-06-22 | 1.0.1 | `devspace-workflow` renamed to `workflow` | +| 2026-06-22 | 1.0.1 | architecture and authoring Skills consolidated; `-lite` copies removed | +| 2026-06-22 | 1.0.1 | full OpenAI Skill mirror removed from the package | diff --git a/skills/.system/architecture-review/SKILL.md b/skills/.system/architecture-review/SKILL.md index e19a7da..007a11e 100644 --- a/skills/.system/architecture-review/SKILL.md +++ b/skills/.system/architecture-review/SKILL.md @@ -3,7 +3,7 @@ name: architecture-review description: Perform evidence-driven architecture review for a DevSpace workspace without bypassing project instructions, tests, workflow state, or authorization boundaries. license: MIT metadata: - version: 3.0.0 + version: 1.0.1 author: DevSpace category: system-engineering updated: 2026-06-22 diff --git a/skills/.system/goal/SKILL.md b/skills/.system/goal/SKILL.md index 2157bcd..830c7be 100644 --- a/skills/.system/goal/SKILL.md +++ b/skills/.system/goal/SKILL.md @@ -3,7 +3,7 @@ name: goal description: Define and maintain a durable, verifiable project Goal in DevSpace. Use for /goal when the user explicitly wants an outcome to persist across sessions. license: MIT metadata: - version: 3.0.0 + version: 1.0.1 author: DevSpace category: system-workflow updated: 2026-06-22 diff --git a/skills/.system/plan/SKILL.md b/skills/.system/plan/SKILL.md index 082cac4..5a0c5f5 100644 --- a/skills/.system/plan/SKILL.md +++ b/skills/.system/plan/SKILL.md @@ -3,7 +3,7 @@ name: plan description: Create, resume, and maintain a durable DevSpace implementation Plan for the current project. Use for /plan and for requests that require read-only analysis before code changes. license: MIT metadata: - version: 3.0.0 + version: 1.0.1 author: DevSpace category: system-workflow updated: 2026-06-22 diff --git a/skills/.system/skill-authoring/SKILL.md b/skills/.system/skill-authoring/SKILL.md index b39d5e9..d4c4fe9 100644 --- a/skills/.system/skill-authoring/SKILL.md +++ b/skills/.system/skill-authoring/SKILL.md @@ -3,7 +3,7 @@ name: skill-authoring description: Create or revise DevSpace Skills with clear workflows, stable system routing, controlled resource access, and test coverage. license: MIT metadata: - version: 3.0.0 + version: 1.0.1 author: DevSpace category: system-engineering updated: 2026-06-22 diff --git a/skills/.system/workflow/SKILL.md b/skills/.system/workflow/SKILL.md index 1db268d..e8c91d4 100644 --- a/skills/.system/workflow/SKILL.md +++ b/skills/.system/workflow/SKILL.md @@ -3,7 +3,7 @@ name: workflow description: Recover and coordinate DevSpace project workflow state across sessions, including Plan, Goal, mode, routing, and concise history. license: MIT metadata: - version: 3.0.0 + version: 1.0.1 author: DevSpace category: system-workflow updated: 2026-06-22 diff --git a/src/package-smoke.test.ts b/src/package-smoke.test.ts index 9a82f72..760e1e5 100644 --- a/src/package-smoke.test.ts +++ b/src/package-smoke.test.ts @@ -6,12 +6,15 @@ import { fileURLToPath } from "node:url"; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const packageJson = JSON.parse(readFileSync(resolve(projectRoot, "package.json"), "utf8")) as { files?: unknown; + version?: unknown; }; assert.equal(Array.isArray(packageJson.files), true); assert.equal((packageJson.files as string[]).includes("skills"), true); +assert.equal(typeof packageJson.version, "string"); +const packageVersion = packageJson.version as string; -for (const path of [ +const requiredAssets = [ "skills/.system/README.md", "skills/.system/plan/SKILL.md", "skills/.system/plan/references/state.md", @@ -21,10 +24,21 @@ for (const path of [ "skills/.system/workflow/references/routing.md", "skills/.system/architecture-review/SKILL.md", "skills/.system/skill-authoring/SKILL.md", -]) { +]; + +for (const path of requiredAssets) { assert.equal(existsSync(resolve(projectRoot, path)), true, `Missing bundled Skill asset: ${path}`); } +for (const path of requiredAssets.filter((asset) => asset.endsWith("/SKILL.md"))) { + const contents = readFileSync(resolve(projectRoot, path), "utf8"); + assert.match( + contents, + new RegExp(`\\n version: ${escapeRegExp(packageVersion)}\\n`), + `${path} must track package.json version`, + ); +} + for (const removedPath of [ "skills/openai", "skills/.system/devspace-plan", @@ -35,3 +49,7 @@ for (const removedPath of [ ]) { assert.equal(existsSync(resolve(projectRoot, removedPath)), false, `Unexpected legacy Skill path: ${removedPath}`); } + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} From dfe96960928a469e28f1e1f1036ca0c570b64624 Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Mon, 22 Jun 2026 00:29:01 +0000 Subject: [PATCH 31/41] docs: add plan and goal usage guide --- README.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ README.zh-CN.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/README.md b/README.md index 81e7237..8f9080d 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,60 @@ ChatGPT Plus on the web cannot natively install or register Codex Skills. DevSpa `@devspace /plan` and `@devspace /goal` are stable alias-style workflow conventions. `/plan` always resolves to system `plan`; `/goal` always resolves to system `goal`; local, installed, and global Skills cannot silently override them. `skills/.system/README.md` records the system Skill policy and change log. +## Using `/plan` and `/goal` + +Use these aliases in a normal ChatGPT message after DevSpace is connected. They are not native ChatGPT slash commands. Open the workspace first, then state the requested outcome clearly. + +### `/plan`: inspect first, then save an implementation plan + +Use `/plan` when you want repository analysis and a durable implementation plan before any file changes. DevSpace loads the current Plan when one exists, enters Plan Mode, inspects the repository read-only, then persists a Plan with ordered steps, validation, risks, and a revision number. + +```text +@devspace Open /path/to/project. + +/plan Add a hello CLI command that prints "Hello DevSpace". +First inspect the project and create a persistent Plan. +Do not modify project files or run commands that write to the repository. +``` + +A good `/plan` request states the outcome, relevant constraints, and whether implementation should wait for approval. To review a saved Plan later, ask DevSpace to open the same workspace and read the current Plan before taking action. + +```text +@devspace Open /path/to/project. + +Read the current Plan and summarize its title, revision, pending steps, validation, and blockers. Do not modify files. +``` + +### `/goal`: keep a durable outcome across sessions + +Use `/goal` when an objective should remain available across future DevSpace sessions. A Goal records the objective, scope, success criteria, verification, stop conditions, current status, and exact metrics where evidence exists. DevSpace reads the active Goal first and will not silently replace it with a competing one. + +```text +@devspace Open /path/to/project. + +/goal Create a durable Goal to add a hello CLI command. +Success criteria: the command runs and prints "Hello DevSpace". +Verification: run the command and its automated test. +Stop condition: the project requirements change to a non-CLI interface. +Do not modify files yet. +``` + +You can explicitly start or pause the Goal work timer, or update its status when work is blocked, completed, or archived. + +```text +@devspace Start the current Goal work timer. + +@devspace Pause the current Goal work timer and show the measured work duration. +``` + +### Using them together + +Create a Goal for the long-lived outcome, then create a Plan that breaks the Goal into concrete steps and explicitly links the Plan to that Goal. Goal progress is calculated only from completed steps in that linked Plan; DevSpace does not guess a percentage. Provider token metrics are recorded only when an API/provider returns real token usage and a stable request ID, so ChatGPT web usage is not filled in automatically. + +```text +@devspace Create a Plan for the current Goal, link the Plan to the Goal, and save it without modifying files. +``` + Manage installed skills with: ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 13fac29..fd2a339 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -207,6 +207,60 @@ DevSpace 内置一组稳定的工作流与工程技能,用于 Plan、Goal、 `@devspace /plan` 和 `@devspace /goal` 只是别名风格的工作流约定,不是 ChatGPT 原生斜杠命令。`/plan` 固定对应系统 `plan`,`/goal` 固定对应系统 `goal`;本地、已安装和全局技能都不能覆盖它们。 +## 如何使用 `/plan` 和 `/goal` + +DevSpace 已连接后,在普通 ChatGPT 消息中使用这两个别名即可。它们不是 ChatGPT 界面原生注册的斜杠命令。先打开工作区,再清晰说明目标。 + +### `/plan`:先分析,再保存实施计划 + +需要在改代码前先梳理现状、设计实施步骤时使用 `/plan`。DevSpace 会优先读取已有 Plan,进入 Plan Mode,以只读方式检查项目,然后保存包含步骤、验证方式、风险和 revision 的持久 Plan。 + +```text +@devspace 打开 /path/to/project。 + +/plan 为项目增加一个 hello CLI 命令,输出 "Hello DevSpace"。 +先检查项目,再创建并保存 Plan。 +不要修改项目文件,也不要执行会写入仓库的命令。 +``` + +一个好的 `/plan` 请求应说明目标、关键约束,以及是否需要等待你确认后再实施。之后想查看 Plan 时,重新打开同一个工作区,并要求 DevSpace 先读取当前 Plan。 + +```text +@devspace 打开 /path/to/project。 + +读取当前 Plan,告诉我 title、revision、未完成步骤、validation 和 blocker;不要修改文件。 +``` + +### `/goal`:跨会话保存长期目标 + +需要跨会话持续推进一个结果时使用 `/goal`。Goal 会保存目标、范围、成功标准、验证方式、停止条件、当前状态,以及有可靠依据时的精确指标。DevSpace 会先读取当前活跃 Goal,不会静默用新 Goal 覆盖旧 Goal。 + +```text +@devspace 打开 /path/to/project。 + +/goal 创建一个持久 Goal:为项目增加 hello CLI 命令。 +成功标准:命令可运行并输出 "Hello DevSpace"。 +验证方式:运行命令和对应自动化测试。 +停止条件:项目需求改为非 CLI 交互。 +暂时不要修改文件。 +``` + +你可以显式开始或暂停 Goal 工作计时,也可以在任务受阻、完成或不再继续时更新 Goal 状态。 + +```text +@devspace 开始当前 Goal 的工作计时。 + +@devspace 暂停当前 Goal 的工作计时,并返回已测量的工作时长。 +``` + +### 配合使用 + +先用 Goal 保存长期目标,再创建 Plan 把目标拆成可执行步骤,并明确把该 Plan 绑定到 Goal。Goal 进度只根据已绑定 Plan 中已完成的步骤计算,DevSpace 不会猜测百分比。Provider Token 指标也只在 API/Provider 返回真实 token usage 和稳定 request ID 时记录,ChatGPT 网页版用量不会自动填充。 + +```text +@devspace 为当前 Goal 创建 Plan,显式绑定到这个 Goal,并保存 Plan;不要修改文件。 +``` + 用这些命令管理已安装技能: ```bash From 4ea92bb8ee3a7b5fbe2e6a98c9e1877cde472197 Mon Sep 17 00:00:00 2001 From: haloworker Date: Mon, 22 Jun 2026 08:58:22 +0800 Subject: [PATCH 32/41] fix: broaden linux service manager detection --- src/service.test.ts | 9 +++++++++ src/service/manager.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/service.test.ts b/src/service.test.ts index 71e37e5..c30c01a 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -68,9 +68,18 @@ try { detectServiceManagerKind({ platform: "linux", env: {}, + hasSystemdRuntimeMarkers: false, }), "unsupported", ); + assert.equal( + detectServiceManagerKind({ + platform: "linux", + env: {}, + hasSystemdRuntimeMarkers: true, + }), + "systemd-user", + ); assert.equal( detectServiceManagerKind({ platform: "linux", diff --git a/src/service/manager.ts b/src/service/manager.ts index 39ce09f..a780615 100644 --- a/src/service/manager.ts +++ b/src/service/manager.ts @@ -25,6 +25,7 @@ interface ManagerContext { interface DetectServiceManagerOptions { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; + hasSystemdRuntimeMarkers?: boolean; } export function createServiceManager(context: ManagerContext): ServiceManager { @@ -73,6 +74,7 @@ export async function restartServiceIfRunning( export function detectServiceManagerKind(options: DetectServiceManagerOptions = {}): ServiceManagerKind { const currentPlatform = options.platform ?? platform(); const env = options.env ?? process.env; + const systemdRuntimeMarkers = options.hasSystemdRuntimeMarkers ?? hasSystemdRuntimeMarkers(); if (currentPlatform === "darwin") return "launchd"; if (currentPlatform === "win32") return "windows-task-scheduler"; @@ -80,7 +82,9 @@ export function detectServiceManagerKind(options: DetectServiceManagerOptions = return hasSystemdUserSession(env) ? "systemd-user" : "wsl-task-scheduler-fallback"; } if (currentPlatform === "linux") { - return hasSystemdUserSession(env) ? "systemd-user" : "unsupported"; + return hasSystemdUserSession(env) || systemdRuntimeMarkers + ? "systemd-user" + : "unsupported"; } return "unsupported"; } @@ -89,6 +93,10 @@ function hasSystemdUserSession(env: NodeJS.ProcessEnv): boolean { return Boolean(env.SYSTEMD_EXEC_PID || env.XDG_RUNTIME_DIR || env.DBUS_SESSION_BUS_ADDRESS); } +function hasSystemdRuntimeMarkers(): boolean { + return existsSync("/run/systemd/system") || existsSync(`/run/user/${process.getuid?.() ?? 0}`); +} + function createUnsupportedManager(config: ServerConfig): ServiceManager { return { kind: "unsupported", From d3b3c151a6fad197bf863c96522edf378bc13dc2 Mon Sep 17 00:00:00 2001 From: haloworker Date: Mon, 22 Jun 2026 13:14:38 +0800 Subject: [PATCH 33/41] harden service entrypoints and config env handling --- src/service.test.ts | 90 +++++++- src/service/manager.ts | 474 ++++++++++++++++++++++++++++++++++----- src/service/templates.ts | 16 +- src/service/types.ts | 2 +- 4 files changed, 496 insertions(+), 86 deletions(-) diff --git a/src/service.test.ts b/src/service.test.ts index c30c01a..6d7273b 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -4,23 +4,53 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; import { createServiceManager, detectServiceManagerKind } from "./service/manager.js"; -import { buildLaunchAgentPlist, buildSystemdUnit, devspaceLogDir } from "./service/templates.js"; +import { buildLaunchAgentPlist, buildServiceEnvironment, buildSystemdUnit, devspaceLogDir } from "./service/templates.js"; import type { CommandRunner } from "./service/runner.js"; +import { writeDevspaceAuth, writeDevspaceConfig } from "./user-config.js"; const root = mkdtempSync(join(tmpdir(), "devspace-service-test-")); const originalHome = process.env.HOME; +const originalConfigDir = process.env.DEVSPACE_CONFIG_DIR; +const originalAllowedRoots = process.env.DEVSPACE_ALLOWED_ROOTS; +const originalSessionWorkspace = process.env.DEVSPACE_SESSION_WORKSPACE; +const originalPath = process.env.PATH; try { process.env.HOME = root; process.env.DEVSPACE_CONFIG_DIR = root; + process.env.PATH = "/usr/bin:/bin"; + process.env.DEVSPACE_ALLOWED_ROOTS = "/tmp/should-not-persist"; + process.env.DEVSPACE_SESSION_WORKSPACE = "/tmp/session-only"; + writeDevspaceConfig({ + allowedRoots: [root], + workspaces: { + allowed: [root], + default: null, + }, + publicBaseUrl: "https://devspace.example.com", + server: { + publicBaseUrl: "https://devspace.example.com", + mcpPath: "/mcp", + host: "127.0.0.1", + port: 7676, + }, + }); + writeDevspaceAuth({ ownerToken: "test-owner-token-that-is-long-enough" }); const config = loadConfig({ DEVSPACE_CONFIG_DIR: root, - DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }); + const serviceEnvironment = buildServiceEnvironment(); + assert.equal(serviceEnvironment.DEVSPACE_ALLOWED_ROOTS, undefined); + assert.equal(serviceEnvironment.DEVSPACE_SESSION_WORKSPACE, undefined); + assert.equal(serviceEnvironment.DEVSPACE_CONFIG_DIR, root); + assert.equal(serviceEnvironment.PATH, "/usr/bin:/bin"); + const builtCliPath = join(root, "dist", "cli.js"); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync(builtCliPath, "console.log('devspace');\n", "utf8"); const systemdUnit = buildSystemdUnit({ - cliEntrypoint: "/tmp/devspace/dist/cli.js", + cliEntrypoint: builtCliPath, config, }); assert.match(systemdUnit, /ExecStart=/); @@ -28,28 +58,49 @@ try { assert.match(systemdUnit, /devspace\.out\.log/); assert.equal(devspaceLogDir(), join(root, "logs")); assert.match(systemdUnit, new RegExp(`${escapeRegExp(join(root, "logs", "devspace.out.log"))}`)); + assert.doesNotMatch(systemdUnit, /DEVSPACE_ALLOWED_ROOTS/); + assert.doesNotMatch(systemdUnit, /DEVSPACE_SESSION_WORKSPACE/); const launchdPlist = buildLaunchAgentPlist({ - cliEntrypoint: "/tmp/devspace/dist/cli.js", + cliEntrypoint: builtCliPath, config, }); assert.match(launchdPlist, /ProgramArguments/); assert.match(launchdPlist, /service-run/); assert.match(launchdPlist, /devspace\.err\.log/); assert.match(launchdPlist, new RegExp(`${escapeRegExp(join(root, "logs", "devspace.err.log"))}`)); + assert.doesNotMatch(launchdPlist, /DEVSPACE_ALLOWED_ROOTS/); + assert.doesNotMatch(launchdPlist, /DEVSPACE_SESSION_WORKSPACE/); const runner = createMockRunner(); + const systemdPaths = createSystemdPaths(root, "systemd"); const manager = createServiceManager({ config, - cliEntrypoint: "/tmp/devspace/dist/cli.js", + cliEntrypoint: join(root, "src", "cli.ts"), runner, + managerKindOverride: "systemd-user", + pathsOverride: systemdPaths, }); const startResult = await manager.start(); assert.equal(startResult.ok, true); - assert.match(startResult.message, /Started service|Installed and started service|Started task|Installed and started task/); + assert.match(startResult.message, /Started service|Installed and started service/); + assert.match(readFileSync(systemdPaths.userSystemdUnitPath, "utf8"), new RegExp(escapeRegExp(builtCliPath))); const status = await manager.status(); - assert.equal(typeof status.installed, "boolean"); - assert.equal(status.endpoint?.endsWith(config.mcpPath), true); + assert.equal(status.installed, true); + assert.equal(status.endpoint, "https://devspace.example.com/mcp"); + const doctor = await manager.doctor(); + assert.equal(doctor.checks.some((check) => check.level === "warn" && /running from source/.test(check.message)), true); + + const brokenManager = createServiceManager({ + config, + cliEntrypoint: join(root, "missing-project", "src", "cli.ts"), + runner: createMockRunner(), + managerKindOverride: "systemd-user", + pathsOverride: createSystemdPaths(root, "broken"), + }); + const brokenStart = await brokenManager.start(); + assert.equal(brokenStart.ok, false); + assert.match(brokenStart.message, /Expected built service entrypoint/); const logPath = join(root, "logs", "devspace.out.log"); mkdirSync(join(root, "logs"), { recursive: true }); @@ -93,7 +144,14 @@ try { } else { process.env.HOME = originalHome; } - delete process.env.DEVSPACE_CONFIG_DIR; + if (originalConfigDir === undefined) delete process.env.DEVSPACE_CONFIG_DIR; + else process.env.DEVSPACE_CONFIG_DIR = originalConfigDir; + if (originalAllowedRoots === undefined) delete process.env.DEVSPACE_ALLOWED_ROOTS; + else process.env.DEVSPACE_ALLOWED_ROOTS = originalAllowedRoots; + if (originalSessionWorkspace === undefined) delete process.env.DEVSPACE_SESSION_WORKSPACE; + else process.env.DEVSPACE_SESSION_WORKSPACE = originalSessionWorkspace; + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; rmSync(root, { recursive: true, force: true }); } @@ -101,14 +159,24 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function createSystemdPaths(baseRoot: string, label: string) { + return { + userSystemdUnitPath: join(baseRoot, label, ".config", "systemd", "user", "devspace.service"), + launchdPlistPath: join(baseRoot, label, "Library", "LaunchAgents", "com.devspace.server.plist"), + }; +} + function createMockRunner(): CommandRunner { return { async exec(command, args) { + if (command === "systemctl" && args[0] === "--user" && args.includes("show")) { + return { stdout: "PATH=/usr/bin\n123\n", stderr: "", exitCode: 0 }; + } if (command === "systemctl" && args.includes("is-enabled")) { - return { stdout: "", stderr: "", exitCode: 1 }; + return { stdout: "enabled\n", stderr: "", exitCode: 0 }; } if (command === "systemctl" && args.includes("is-active")) { - return { stdout: "", stderr: "", exitCode: 1 }; + return { stdout: "active\n", stderr: "", exitCode: 0 }; } if (command === "systemctl") { return { stdout: "", stderr: "", exitCode: 0 }; diff --git a/src/service/manager.ts b/src/service/manager.ts index a780615..9be9ce1 100644 --- a/src/service/manager.ts +++ b/src/service/manager.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, platform } from "node:os"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import type { ServerConfig } from "../config.js"; import { defaultCommandRunner, type CommandRunner } from "./runner.js"; import { buildLaunchAgentPlist, buildServiceCommand, buildSystemdUnit, devspaceLogDir } from "./templates.js"; @@ -20,6 +20,8 @@ interface ManagerContext { config: ServerConfig; cliEntrypoint: string; runner?: CommandRunner; + managerKindOverride?: ServiceManagerKind; + pathsOverride?: Partial; } interface DetectServiceManagerOptions { @@ -28,13 +30,58 @@ interface DetectServiceManagerOptions { hasSystemdRuntimeMarkers?: boolean; } +interface ServiceManagerPaths { + userSystemdUnitPath: string; + launchdPlistPath: string; +} + +interface CliEntrypointHealth { + currentEntrypoint: string; + managedEntrypoint?: string; + sourceBound: boolean; + managedExists: boolean; + message?: string; +} + +interface NormalizedManagerContext { + config: ServerConfig; + cliEntrypoint: string; + managedCliEntrypoint: string; + cliHealth: CliEntrypointHealth; + runner: CommandRunner; + paths: ServiceManagerPaths; +} + +interface ExistingSystemdUnitState { + installed: boolean; + content?: string; + execEntrypoint?: string; +} + +interface ExistingLaunchdState { + installed: boolean; + content?: string; + execEntrypoint?: string; +} + +interface SystemdRuntimeDetails { + environment: string; + mainPid?: number; +} + export function createServiceManager(context: ManagerContext): ServiceManager { - const kind = detectServiceManagerKind(); + const kind = context.managerKindOverride ?? detectServiceManagerKind(); const runner = context.runner ?? defaultCommandRunner; - const base = { + const paths = resolveServiceManagerPaths(context.pathsOverride); + const cliHealth = inspectCliEntrypoint(context.cliEntrypoint); + const managedCliEntrypoint = cliHealth.managedEntrypoint ?? context.cliEntrypoint; + const base: NormalizedManagerContext = { config: context.config, cliEntrypoint: context.cliEntrypoint, + managedCliEntrypoint, + cliHealth, runner, + paths, }; switch (kind) { @@ -134,8 +181,8 @@ function createUnsupportedManager(config: ServerConfig): ServiceManager { }; } -function createSystemdUserManager(context: Required): ServiceManager { - const unitPath = join(homedir(), ".config", "systemd", "user", SYSTEMD_SERVICE_NAME); +function createSystemdUserManager(context: NormalizedManagerContext): ServiceManager { + const unitPath = context.paths.userSystemdUnitPath; return { kind: "systemd-user", serviceName: SYSTEMD_SERVICE_NAME, @@ -160,33 +207,33 @@ function createSystemdUserManager(context: Required): ServiceMan return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "disable", SYSTEMD_SERVICE_NAME], "Disabled service"); }, async start() { - const installed = existsSync(unitPath); - if (!installed) { - mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true }); - mkdirSync(devspaceLogDir(), { recursive: true }); - writeFileSync(unitPath, buildSystemdUnit({ cliEntrypoint: context.cliEntrypoint, config: context.config }), "utf8"); - await context.runner.exec("systemctl", ["--user", "daemon-reload"]); - await context.runner.exec("systemctl", ["--user", "enable", SYSTEMD_SERVICE_NAME]); - return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "restart", SYSTEMD_SERVICE_NAME], "Installed and started service"); - } - return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "start", SYSTEMD_SERVICE_NAME], "Started service"); + return installOrStartSystemdUserService(context, "start"); }, async stop() { return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "stop", SYSTEMD_SERVICE_NAME], "Stopped service"); }, async restart() { - return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "restart", SYSTEMD_SERVICE_NAME], "Restarted service"); + return installOrStartSystemdUserService(context, "restart"); }, async status() { const installed = existsSync(unitPath); const enabled = installed && (await context.runner.exec("systemctl", ["--user", "is-enabled", SYSTEMD_SERVICE_NAME])).exitCode === 0; const running = installed && (await context.runner.exec("systemctl", ["--user", "is-active", SYSTEMD_SERVICE_NAME])).exitCode === 0; + const existing = readSystemdUnitState(unitPath); + const runtime = installed ? await readSystemdRuntimeDetails(context.runner) : emptySystemdRuntimeDetails(); return { ...baseStatus("systemd-user", SYSTEMD_SERVICE_NAME, context.config), installed, enabled, running, logPath: join(devspaceLogDir(), "devspace.out.log"), + pid: runtime.mainPid, + details: { + installedEntrypoint: existing.execEntrypoint ?? "(unknown)", + runtimeEnvironmentOverride: + runtime.environment.includes("DEVSPACE_ALLOWED_ROOTS=") + || runtime.environment.includes("DEVSPACE_SESSION_WORKSPACE="), + }, }; }, async logs(options) { @@ -194,30 +241,58 @@ function createSystemdUserManager(context: Required): ServiceMan return readLog(logPath, options?.tail); }, async doctor() { + const entrypointCheck = cliEntrypointDoctorCheck(context.cliHealth); const status = await this.status(); + const existing = readSystemdUnitState(unitPath); + const checks: ServiceDoctorResult["checks"] = [ + { + level: (await this.isSupported()) ? "pass" : "warn", + message: (await this.isSupported()) ? "systemd user service is available" : "systemd user service is unavailable", + }, + ]; + if (entrypointCheck) checks.push(entrypointCheck); + checks.push( + { + level: status.installed ? "pass" : "info", + message: status.installed ? "DevSpace unit is installed" : "DevSpace unit is not installed", + }, + { + level: status.running ? "pass" : "warn", + message: status.running ? "DevSpace service is running" : "DevSpace service is not running", + }, + ); + if ( + existing.installed + && existing.execEntrypoint + && existing.execEntrypoint !== context.managedCliEntrypoint + ) { + checks.push({ + level: "warn", + message: `Installed unit points to ${existing.execEntrypoint} instead of ${context.managedCliEntrypoint}. Run \`devspace service start\` to repair it.`, + }); + } + if (existing.installed && existing.execEntrypoint && !existsSync(existing.execEntrypoint)) { + checks.push({ + level: "error", + message: `Installed unit points to a missing CLI entrypoint: ${existing.execEntrypoint}.`, + }); + } + if (status.details?.runtimeEnvironmentOverride) { + checks.push({ + level: "error", + message: "Running service environment still contains temporary workspace override variables. Re-run `devspace service start` to rewrite the service definition.", + }); + } return { manager: "systemd-user", - checks: [ - { - level: (await this.isSupported()) ? "pass" : "warn", - message: (await this.isSupported()) ? "systemd user service is available" : "systemd user service is unavailable", - }, - { - level: status.installed ? "pass" : "info", - message: status.installed ? "DevSpace unit is installed" : "DevSpace unit is not installed", - }, - { - level: status.running ? "pass" : "warn", - message: status.running ? "DevSpace service is running" : "DevSpace service is not running", - }, - ], + checks, }; }, }; } -function createLaunchdManager(context: Required): ServiceManager { - const plistPath = join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`); +function createLaunchdManager(context: NormalizedManagerContext): ServiceManager { + const plistPath = context.paths.launchdPlistPath; return { kind: "launchd", serviceName: LAUNCHD_LABEL, @@ -235,12 +310,29 @@ function createLaunchdManager(context: Required): ServiceManager return execServiceResult(context.runner, "launchd", "launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`], "Disabled service"); }, async start() { - if (!existsSync(plistPath)) { + const validation = validateManagedCliEntrypoint(context.cliHealth, "launchd"); + if (validation) return validation; + + const expected = buildLaunchAgentPlist({ + cliEntrypoint: context.managedCliEntrypoint, + config: context.config, + }); + const existing = readLaunchdState(plistPath); + const needsRewrite = !existing.installed || existing.content !== expected; + + if (needsRewrite) { mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true }); mkdirSync(devspaceLogDir(), { recursive: true }); - writeFileSync(plistPath, buildLaunchAgentPlist({ cliEntrypoint: context.cliEntrypoint, config: context.config }), "utf8"); + writeFileSync(plistPath, expected, "utf8"); + if (existing.installed) { + await context.runner.exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); + } const bootstrap = await context.runner.exec("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath]); - if (bootstrap.exitCode !== 0 && !bootstrap.stderr.includes("already bootstrapped")) { + if ( + bootstrap.exitCode !== 0 + && !bootstrap.stderr.includes("already bootstrapped") + && !bootstrap.stderr.includes("service already loaded") + ) { return { ok: false, manager: "launchd", @@ -250,11 +342,14 @@ function createLaunchdManager(context: Required): ServiceManager ].filter(Boolean).join(" "), }; } - return { ok: true, manager: "launchd", message: "Installed and started service" }; } const kickstart = await context.runner.exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); if (kickstart.exitCode === 0) { - return { ok: true, manager: "launchd", message: "Started service" }; + return { + ok: true, + manager: "launchd", + message: needsRewrite ? "Installed and started service" : "Started service", + }; } return execServiceResult(context.runner, "launchd", "launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath], "Started service"); }, @@ -273,47 +368,72 @@ function createLaunchdManager(context: Required): ServiceManager return this.start(); }, async status() { - const installed = existsSync(plistPath); - const result = installed + const existing = readLaunchdState(plistPath); + const result = existing.installed ? await context.runner.exec("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]) : { stdout: "", stderr: "", exitCode: 1 }; return { ...baseStatus("launchd", LAUNCHD_LABEL, context.config), - installed, - enabled: installed, + installed: existing.installed, + enabled: existing.installed, running: result.exitCode === 0, logPath: join(devspaceLogDir(), "devspace.out.log"), + details: { + installedEntrypoint: existing.execEntrypoint ?? "(unknown)", + }, }; }, async logs(options) { return readLog(join(devspaceLogDir(), "devspace.out.log"), options?.tail); }, async doctor() { + const entrypointCheck = cliEntrypointDoctorCheck(context.cliHealth); const status = await this.status(); + const existing = readLaunchdState(plistPath); + const checks: ServiceDoctorResult["checks"] = [ + { level: "pass", message: "launchd is available" }, + ]; + if (entrypointCheck) checks.push(entrypointCheck); + checks.push( + { + level: status.installed ? "pass" : "info", + message: status.installed ? "LaunchAgent is installed" : "LaunchAgent is not installed", + }, + { + level: status.running ? "pass" : "warn", + message: status.running ? "DevSpace service is running" : "DevSpace service is not running", + }, + { + level: existsSync(devspaceLogDir()) ? "pass" : "warn", + message: existsSync(devspaceLogDir()) ? "Log directory is available" : "Log directory is missing", + }, + ); + if ( + existing.installed + && existing.execEntrypoint + && existing.execEntrypoint !== context.managedCliEntrypoint + ) { + checks.push({ + level: "warn", + message: `Installed LaunchAgent points to ${existing.execEntrypoint} instead of ${context.managedCliEntrypoint}. Run \`devspace service start\` to repair it.`, + }); + } + if (existing.installed && existing.execEntrypoint && !existsSync(existing.execEntrypoint)) { + checks.push({ + level: "error", + message: `Installed LaunchAgent points to a missing CLI entrypoint: ${existing.execEntrypoint}.`, + }); + } return { manager: "launchd", - checks: [ - { level: "pass", message: "launchd is available" }, - { - level: status.installed ? "pass" : "info", - message: status.installed ? "LaunchAgent is installed" : "LaunchAgent is not installed", - }, - { - level: status.running ? "pass" : "warn", - message: status.running ? "DevSpace service is running" : "DevSpace service is not running", - }, - { - level: existsSync(devspaceLogDir()) ? "pass" : "warn", - message: existsSync(devspaceLogDir()) ? "Log directory is available" : "Log directory is missing", - }, - ], + checks, }; }, }; } function createWindowsTaskManager( - context: Required, + context: NormalizedManagerContext, kind: "windows-task-scheduler" | "wsl-task-scheduler-fallback", ): ServiceManager { return { @@ -330,10 +450,11 @@ function createWindowsTaskManager( return execServiceResult(context.runner, kind, "schtasks.exe", ["/Change", "/TN", WINDOWS_TASK_NAME, "/DISABLE"], "Disabled task"); }, async start() { + const validation = validateManagedCliEntrypoint(context.cliHealth, kind); + if (validation) return validation; const installed = (await context.runner.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME])).exitCode === 0; if (!installed) { - const spec = buildServiceCommand(context.cliEntrypoint); - const taskCommand = `"${spec.command}" ${spec.args.map(windowsQuote).join(" ")}`; + const taskCommand = buildWindowsTaskCommand(context.managedCliEntrypoint); const created = await execServiceResult( context.runner, kind, @@ -395,7 +516,7 @@ function baseStatus(kind: ServiceManagerKind, serviceName: string, config: Serve running: false, manager: kind, serviceName, - endpoint: new URL(config.mcpPath, `http://${config.host}:${config.port}`).toString(), + endpoint: new URL(config.mcpPath, config.publicBaseUrl).toString(), publicBaseUrl: config.publicBaseUrl, }; } @@ -425,7 +546,240 @@ function unsupportedMessage(): string { return "DevSpace service management is not supported on this platform."; } +function buildWindowsTaskCommand(cliEntrypoint: string): string { + const spec = buildServiceCommand(cliEntrypoint); + return `"${spec.command}" ${spec.args.map(windowsQuote).join(" ")}`; +} + function windowsQuote(value: string): string { if (!/[\s"]/u.test(value)) return value; return `"${value.replace(/"/g, '""')}"`; } + +function installOrStartSystemdUserService( + context: NormalizedManagerContext, + action: "start" | "restart", +): Promise { + return installOrStartSystemdUserServiceImpl(context, action); +} + +async function installOrStartSystemdUserServiceImpl( + context: NormalizedManagerContext, + action: "start" | "restart", +): Promise { + const validation = validateManagedCliEntrypoint(context.cliHealth, "systemd-user"); + if (validation) return validation; + + const unitPath = context.paths.userSystemdUnitPath; + const expected = buildSystemdUnit({ + cliEntrypoint: context.managedCliEntrypoint, + config: context.config, + }); + const existing = readSystemdUnitState(unitPath); + const needsRewrite = !existing.installed || existing.content !== expected; + + if (needsRewrite) { + mkdirSync(dirname(unitPath), { recursive: true }); + mkdirSync(devspaceLogDir(), { recursive: true }); + writeFileSync(unitPath, expected, "utf8"); + await context.runner.exec("systemctl", ["--user", "daemon-reload"]); + await context.runner.exec("systemctl", ["--user", "enable", SYSTEMD_SERVICE_NAME]); + } + + const command = needsRewrite || action === "restart" ? "restart" : "start"; + return execServiceResult( + context.runner, + "systemd-user", + "systemctl", + ["--user", command, SYSTEMD_SERVICE_NAME], + needsRewrite + ? "Installed and started service" + : command === "restart" + ? "Restarted service" + : "Started service", + ); +} + +function resolveServiceManagerPaths(overrides: Partial = {}): ServiceManagerPaths { + return { + userSystemdUnitPath: + overrides.userSystemdUnitPath ?? join(homedir(), ".config", "systemd", "user", SYSTEMD_SERVICE_NAME), + launchdPlistPath: + overrides.launchdPlistPath ?? join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`), + }; +} + +function inspectCliEntrypoint(cliEntrypoint: string): CliEntrypointHealth { + const currentEntrypoint = resolve(cliEntrypoint); + const sourceBound = + /[/\\]src[/\\]cli\.[cm]?[jt]s$/u.test(currentEntrypoint) + || currentEntrypoint.includes("/worktrees/") + || currentEntrypoint.includes("\\worktrees\\"); + const managedEntrypoint = resolveManagedCliEntrypoint(currentEntrypoint); + const managedExists = managedEntrypoint ? existsSync(managedEntrypoint) : false; + + if (!managedEntrypoint) { + return { + currentEntrypoint, + sourceBound, + managedExists: false, + message: `DevSpace could not determine a stable CLI entrypoint from ${currentEntrypoint}.`, + }; + } + + if (!managedExists) { + return { + currentEntrypoint, + managedEntrypoint, + sourceBound, + managedExists: false, + message: sourceBound + ? [ + `Current DevSpace CLI is running from source at ${currentEntrypoint}.`, + `Expected built service entrypoint is ${managedEntrypoint}, but it does not exist.`, + "Run `npm run build` before starting the background service.", + ].join(" ") + : `DevSpace CLI entrypoint does not exist: ${managedEntrypoint}.`, + }; + } + + return { + currentEntrypoint, + managedEntrypoint, + sourceBound, + managedExists: true, + }; +} + +function resolveManagedCliEntrypoint(cliEntrypoint: string): string | undefined { + if (/[/\\]dist[/\\]cli\.js$/u.test(cliEntrypoint)) return cliEntrypoint; + if (/[/\\]src[/\\]cli\.[cm]?[jt]s$/u.test(cliEntrypoint)) { + return resolve(dirname(dirname(cliEntrypoint)), "dist", "cli.js"); + } + return cliEntrypoint; +} + +function validateManagedCliEntrypoint( + cliHealth: CliEntrypointHealth, + manager: ServiceManagerKind, +): ServiceResult | undefined { + if (cliHealth.managedEntrypoint && cliHealth.managedExists) return undefined; + return { + ok: false, + manager, + message: cliHealth.message ?? `DevSpace CLI entrypoint is unavailable: ${cliHealth.currentEntrypoint}`, + }; +} + +function cliEntrypointDoctorCheck( + cliHealth: CliEntrypointHealth, +): ServiceDoctorResult["checks"][number] | undefined { + if (cliHealth.sourceBound) { + return { + level: cliHealth.managedExists ? "warn" : "error", + message: cliHealth.managedExists + ? `Current DevSpace CLI is running from source. The managed service will bind to ${cliHealth.managedEntrypoint}.` + : (cliHealth.message ?? "Current DevSpace CLI is running from source, but the built service entrypoint is missing."), + }; + } + + if (!cliHealth.managedExists) { + return { + level: "error", + message: cliHealth.message ?? `DevSpace CLI entrypoint is missing: ${cliHealth.currentEntrypoint}`, + }; + } + + return undefined; +} + +function readSystemdUnitState(unitPath: string): ExistingSystemdUnitState { + if (!existsSync(unitPath)) return { installed: false }; + const content = readFileSync(unitPath, "utf8"); + const execStart = content.match(/^ExecStart=(.+)$/m)?.[1]; + const tokens = execStart ? splitCommandLine(execStart) : []; + return { + installed: true, + content, + execEntrypoint: tokens[1], + }; +} + +function readLaunchdState(plistPath: string): ExistingLaunchdState { + if (!existsSync(plistPath)) return { installed: false }; + const content = readFileSync(plistPath, "utf8"); + const matches = Array.from(content.matchAll(/([^<]+)<\/string>/g)).map((match) => xmlUnescape(match[1] ?? "")); + const serviceRunIndex = matches.indexOf("service-run"); + return { + installed: true, + content, + execEntrypoint: serviceRunIndex >= 1 ? matches[serviceRunIndex - 1] : undefined, + }; +} + +function splitCommandLine(value: string): string[] { + const parts: string[] = []; + let current = ""; + let quote: '"' | "'" | undefined; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (quote) { + if (char === quote) { + quote = undefined; + } else if (char === "\\" && quote === '"' && index + 1 < value.length) { + index += 1; + current += value[index]; + } else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (/\s/u.test(char)) { + if (current) { + parts.push(current); + current = ""; + } + continue; + } + current += char; + } + + if (current) parts.push(current); + return parts; +} + +function xmlUnescape(value: string): string { + return value + .replaceAll("'", "'") + .replaceAll(""", '"') + .replaceAll(">", ">") + .replaceAll("<", "<") + .replaceAll("&", "&"); +} + +async function readSystemdRuntimeDetails(runner: CommandRunner): Promise { + const result = await runner.exec("systemctl", [ + "--user", + "show", + SYSTEMD_SERVICE_NAME, + "--property=Environment", + "--property=MainPID", + "--value", + ]); + if (result.exitCode !== 0) return emptySystemdRuntimeDetails(); + const [environment = "", mainPidRaw = ""] = result.stdout.split(/\r?\n/); + const mainPid = Number(mainPidRaw.trim()); + return { + environment: environment.trim(), + mainPid: Number.isFinite(mainPid) && mainPid > 0 ? mainPid : undefined, + }; +} + +function emptySystemdRuntimeDetails(): SystemdRuntimeDetails { + return { environment: "" }; +} diff --git a/src/service/templates.ts b/src/service/templates.ts index 355d7b0..f6d2b09 100644 --- a/src/service/templates.ts +++ b/src/service/templates.ts @@ -21,19 +21,7 @@ export function buildServiceCommand(cliEntrypoint: string): ServiceCommandSpec { export function buildServiceEnvironment(): Record { const environment: Record = {}; - const allowed = [ - "DEVSPACE_CONFIG_DIR", - "DEVSPACE_PUBLIC_BASE_URL", - "DEVSPACE_ALLOWED_HOSTS", - "DEVSPACE_LOG_LEVEL", - "DEVSPACE_LOG_FORMAT", - "DEVSPACE_LOG_REQUESTS", - "DEVSPACE_LOG_ASSETS", - "DEVSPACE_LOG_TOOL_CALLS", - "DEVSPACE_LOG_SHELL_COMMANDS", - "DEVSPACE_TRUST_PROXY", - "PATH", - ]; + const allowed = ["DEVSPACE_CONFIG_DIR", "PATH"]; for (const key of allowed) { const value = process.env[key]; @@ -122,7 +110,7 @@ function xmlEscape(value: string): string { } function escapeEnvValue(value: string): string { - return value.replaceAll(" ", "\\ "); + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; } function escapeSystemdArg(value: string): string { diff --git a/src/service/types.ts b/src/service/types.ts index 5f210f5..206fdf0 100644 --- a/src/service/types.ts +++ b/src/service/types.ts @@ -27,7 +27,7 @@ export interface ServiceStatus { export interface ServiceDoctorResult { manager: ServiceManagerKind; checks: Array<{ - level: "pass" | "warn" | "info"; + level: "pass" | "warn" | "info" | "error"; message: string; }>; } From 3c42e3ba8d1ee7c505306b35708914deeeb00901 Mon Sep 17 00:00:00 2001 From: tao-xiaoxin Date: Tue, 23 Jun 2026 13:59:19 +0000 Subject: [PATCH 34/41] feat(cli): add version flags and fix skill URI reads --- src/cli-skills.test.ts | 12 ++++++++++++ src/cli.ts | 8 +++++++- src/workspaces.test.ts | 20 ++++++++++++++++++++ src/workspaces.ts | 16 +++++++++------- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/cli-skills.test.ts b/src/cli-skills.test.ts index fc1cc43..ce403d0 100644 --- a/src/cli-skills.test.ts +++ b/src/cli-skills.test.ts @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; const root = mkdtempSync(join(tmpdir(), "devspace-cli-skills-test-")); const projectRoot = fileURLToPath(new URL("..", import.meta.url)); +const require = createRequire(import.meta.url); +const packageVersion = (require("../package.json") as { version: string }).version; try { const help = execFileSync(process.execPath, ["--import", "tsx", "src/cli.ts", "help"], { @@ -17,6 +20,15 @@ try { assert.match(help, /devspace skills list -g/); assert.match(help, /devspace skills remove -g/); assert.match(help, /install expects the target path to point at one standard skill directory with a SKILL\.md file/); + + for (const flag of ["-v", "--version"]) { + const version = execFileSync(process.execPath, ["--import", "tsx", "src/cli.ts", flag], { + cwd: projectRoot, + encoding: "utf8", + env: { ...process.env, HOME: root }, + }).trim(); + assert.equal(version, packageVersion); + } } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/cli.ts b/src/cli.ts index 475451d..156fa98 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,8 +38,9 @@ import { type SkillScope, } from "./skill-manager.js"; -type Command = "serve" | "service-run" | "init" | "doctor" | "config" | "workspace" | "service" | "skills" | "help"; +type Command = "serve" | "service-run" | "init" | "doctor" | "config" | "workspace" | "service" | "skills" | "version" | "help"; const require = createRequire(import.meta.url); +const PACKAGE_VERSION = (require("../package.json") as { version: string }).version; const SUPPORTED_NODE_RANGE = ">=20.12 <27"; const CLI_ENTRYPOINT = fileURLToPath(import.meta.url); @@ -75,6 +76,9 @@ async function main(argv: string[]): Promise { case "skills": await runSkillsCommand(args); return; + case "version": + console.log(PACKAGE_VERSION); + return; case "help": printHelp(); return; @@ -84,6 +88,7 @@ async function main(argv: string[]): Promise { function normalizeCommand(command: string | undefined): Command { if (!command || command === "serve" || command === "start") return "serve"; if (command === "service-run" || command === "init" || command === "doctor" || command === "config" || command === "workspace" || command === "service" || command === "skills") return command; + if (command === "--version" || command === "-v") return "version"; if (command === "help" || command === "--help" || command === "-h") return "help"; throw new Error(`Unknown command: ${command}`); } @@ -544,6 +549,7 @@ function printHelp(): void { "", "Usage:", " devspace Run first-time setup if needed, then start the server", + " devspace -v, --version Print the installed DevSpace version", " devspace serve Start the server", " devspace serve --add-dir Temporarily allow an extra workspace root", " devspace serve --workspace Temporarily set the default workspace for this run", diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index f6b20cd..8198fba 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -41,6 +41,26 @@ try { [join(root, "nested", "AGENTS.md")], ); + const planSkill = workspace.skills.find((skill) => skill.name === "plan" && skill.source === "devspace_system"); + assert.ok(planSkill, "expected the bundled plan Skill to be available"); + + const planSkillFile = registry.resolveReadPath(workspace, planSkill.locator); + assert.equal(planSkillFile.absolutePath, planSkill.filePath); + assert.equal(planSkillFile.skillRead?.isSkillFile, true); + registry.markReadPathLoaded(workspace, planSkillFile); + + const planReference = registry.resolveReadPath( + workspace, + "skill://devspace-system/plan/references/state.md", + ); + assert.equal(planReference.absolutePath, join(planSkill.baseDir, "references", "state.md")); + assert.equal(planReference.skillRead?.isSkillFile, false); + + await assert.rejects( + async () => registry.resolveReadPath(workspace, "skill://devspace-system/unknown/SKILL.md"), + /Unknown or unauthorized Skill resource/, + ); + const missingWorkspaceRoot = join(root, "missing", "workspace"); const missingWorkspace = await registry.openWorkspace(missingWorkspaceRoot); assert.equal(missingWorkspace.workspace.root, missingWorkspaceRoot); diff --git a/src/workspaces.ts b/src/workspaces.ts index d3b1c73..2ae2d0f 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -141,18 +141,15 @@ export class WorkspaceRegistry { } resolveReadPath(workspace: Workspace, inputPath: string): WorkspaceReadPath { - try { - return { - absolutePath: this.resolvePath(workspace, inputPath), - readRoots: [workspace.root], - }; - } catch (workspaceError) { + if (inputPath.startsWith("skill://")) { const skillRead = resolveSkillReadPath( workspace.skills, workspace.activatedSkillDirs, inputPath, ); - if (!skillRead) throw workspaceError; + if (!skillRead) { + throw new Error(`Unknown or unauthorized Skill resource: ${inputPath}`); + } return { absolutePath: skillRead.absolutePath, @@ -160,6 +157,11 @@ export class WorkspaceRegistry { skillRead, }; } + + return { + absolutePath: this.resolvePath(workspace, inputPath), + readRoots: [workspace.root], + }; } markReadPathLoaded(workspace: Workspace, readPath: WorkspaceReadPath): void { From e4100dd9189e718c8dc470593445e27ceb23a6ae Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Tue, 23 Jun 2026 22:49:48 +0800 Subject: [PATCH 35/41] fix: close oauth provider after config key reset --- src/config-operations.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/config-operations.ts b/src/config-operations.ts index 920bd11..6e203a3 100644 --- a/src/config-operations.ts +++ b/src/config-operations.ts @@ -137,14 +137,18 @@ export async function resetConfigKey( const config = loadConfig(); const oauthProvider = new SingleUserOAuthProvider(config.oauth, new URL(config.mcpPath, config.publicBaseUrl)); - oauthProvider.resetState(); + try { + oauthProvider.resetState(); - const restartMessage = await applyConfigUpdate(cliEntrypoint, undefined, options); - return [ - "Access key has been reset successfully.", - "Existing clients must be reconfigured.", - restartMessage, - ].filter(Boolean).join("\n"); + const restartMessage = await applyConfigUpdate(cliEntrypoint, undefined, options); + return [ + "Access key has been reset successfully.", + "Existing clients must be reconfigured.", + restartMessage, + ].filter(Boolean).join("\n"); + } finally { + oauthProvider.close(); + } } export async function addWorkspace(path: string, options: { From 9db708bdae72957cbe8faa0d902785ba9e2717b2 Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Tue, 23 Jun 2026 23:00:26 +0800 Subject: [PATCH 36/41] test: harden sqlite temp cleanup on windows --- src/config-operations.test.ts | 5 ++-- src/oauth-provider.test.ts | 19 ++++++++++++-- src/oauth-store.test.ts | 5 ++-- src/test-utils.ts | 48 ++++++++++++++++++++++++++++++++++ src/workflow-migration.test.ts | 5 ++-- src/workflow-store.test.ts | 5 ++-- src/workspaces.test.ts | 5 ++-- 7 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 src/test-utils.ts diff --git a/src/config-operations.test.ts b/src/config-operations.test.ts index 4a1b3d5..fddbf40 100644 --- a/src/config-operations.test.ts +++ b/src/config-operations.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -16,6 +16,7 @@ import { } from "./config-operations.js"; import { loadDevspaceFiles, writeDevspaceAuth } from "./user-config.js"; import type { ServiceManager } from "./service/types.js"; +import { removeTempDirSync } from "./test-utils.js"; const root = mkdtempSync(join(tmpdir(), "devspace-config-ops-test-")); process.env.DEVSPACE_CONFIG_DIR = root; @@ -100,7 +101,7 @@ try { await assert.rejects(() => setConfigPort(0, import.meta.url, { manager: testManager }), /between 1 and 65535/); } finally { - rmSync(root, { recursive: true, force: true }); + removeTempDirSync(root); delete process.env.DEVSPACE_CONFIG_DIR; delete process.env.DEVSPACE_STATE_DIR; delete process.env.DEVSPACE_OAUTH_OWNER_TOKEN; diff --git a/src/oauth-provider.test.ts b/src/oauth-provider.test.ts index 3e7684d..c4cf35d 100644 --- a/src/oauth-provider.test.ts +++ b/src/oauth-provider.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { stat, chmod } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -10,6 +10,7 @@ import { SingleUserOAuthProvider, type OAuthConfig } from "./oauth-provider.js"; import { databasePath } from "./db/client.js"; import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; +import { removeTempDirSync } from "./test-utils.js"; const root = mkdtempSync(join(tmpdir(), "devspace-oauth-provider-test-")); const statePath = join(root, "state", "oauth.json"); @@ -23,9 +24,11 @@ const config: OAuthConfig = { allowedRedirectHosts: ["localhost"], statePath, }; +const providers: SingleUserOAuthProvider[] = []; try { const firstProvider = new SingleUserOAuthProvider(config, resourceServerUrl); + providers.push(firstProvider); const client = firstProvider.clientsStore.registerClient({ client_name: "test client", redirect_uris: ["http://localhost/callback"], @@ -51,6 +54,7 @@ try { assert.equal(dirStats.mode & 0o777, 0o700); const secondProvider = new SingleUserOAuthProvider(config, resourceServerUrl); + providers.push(secondProvider); const persistedClient = secondProvider.clientsStore.getClient(client.client_id); assert.equal(persistedClient?.client_id, client.client_id); @@ -103,6 +107,7 @@ try { ); await chmod(expiredStatePath, 0o600); const expiredProvider = new SingleUserOAuthProvider({ ...config, statePath: expiredStatePath }, resourceServerUrl); + providers.push(expiredProvider); await assert.rejects( () => expiredProvider.exchangeRefreshToken(client, assertString(firstTokens.refresh_token), undefined, resourceServerUrl), InvalidGrantError, @@ -116,6 +121,7 @@ try { writeFileSync(corruptStatePath, "{not valid json"); await chmod(corruptStatePath, 0o600); const corruptProvider = new SingleUserOAuthProvider({ ...config, statePath: corruptStatePath }, resourceServerUrl); + providers.push(corruptProvider); assert.equal(corruptProvider.clientsStore.getClient(client.client_id), undefined); const repairedState = readPersistedState(corruptStatePath); assert.deepEqual(repairedState, { clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); @@ -125,11 +131,13 @@ try { writeFileSync(emptyStatePath, ""); await chmod(emptyStatePath, 0o600); const emptyProvider = new SingleUserOAuthProvider({ ...config, statePath: emptyStatePath }, resourceServerUrl); + providers.push(emptyProvider); assert.equal(emptyProvider.clientsStore.getClient(client.client_id), undefined); const rewrittenEmptyState = readPersistedState(emptyStatePath); assert.deepEqual(rewrittenEmptyState, { clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); const customProvider = new SingleUserOAuthProvider({ ...config, statePath: customStatePath }, resourceServerUrl); + providers.push(customProvider); customProvider.clientsStore.registerClient({ client_name: "custom state client", redirect_uris: ["http://localhost/custom"], @@ -160,6 +168,7 @@ try { { ...config, statePath: expiredAccessStatePath }, resourceServerUrl, ); + providers.push(expiredAccessProvider); await assert.rejects( () => expiredAccessProvider.verifyAccessToken(assertString(expiredAccessTokens.access_token)), InvalidTokenError, @@ -169,6 +178,7 @@ try { const consentStatePath = join(root, "consent", "oauth.json"); const consentProvider = new SingleUserOAuthProvider({ ...config, statePath: consentStatePath }, resourceServerUrl); + providers.push(consentProvider); const consentClient = consentProvider.clientsStore.registerClient({ client_name: "consent client", redirect_uris: ["http://localhost/consent", "http://localhost/other"], @@ -227,6 +237,7 @@ try { { ...config, scopes: ["devspace", "admin"], statePath: expandedScopeStatePath }, resourceServerUrl, ); + providers.push(expandedScopeProvider); const expandedScopeClient = expandedScopeProvider.clientsStore.registerClient({ client_name: "expanded scope client", redirect_uris: ["http://localhost/expanded"], @@ -247,6 +258,7 @@ try { assert.match(assertString(expandedScopeGet.body), /Owner password/); const restartedConsentProvider = new SingleUserOAuthProvider({ ...config, statePath: consentStatePath }, resourceServerUrl); + providers.push(restartedConsentProvider); const restartedConsentClient = restartedConsentProvider.clientsStore.getClient(consentClient.client_id); assert.equal(Boolean(restartedConsentClient), true); const restartedConsentGet = mockResponse("GET"); @@ -259,7 +271,10 @@ try { assert.equal(JSON.stringify(finalConsentState).includes(assertString(firstTokens.access_token)), false); assert.equal(JSON.stringify(finalConsentState).includes(assertString(firstTokens.refresh_token)), false); } finally { - rmSync(root, { recursive: true, force: true }); + for (const provider of providers.splice(0).reverse()) { + provider.close(); + } + removeTempDirSync(root); } function issueTokens( diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 54492a3..4dce405 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -1,12 +1,13 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdtemp, rm, stat } from "node:fs/promises"; +import { mkdtemp, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import { databasePath, openDatabase } from "./db/client.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; +import { removeTempDir } from "./test-utils.js"; const root = await mkdtemp(join(tmpdir(), "devspace-oauth-test-")); const oauthConfig = { @@ -26,7 +27,7 @@ try { testTransactionalTokenRotation(join(root, "rotation")); await testProviderRestartRotationAndRevocation(join(root, "provider")); } finally { - await rm(root, { recursive: true, force: true }); + await removeTempDir(root); } async function testDatabaseConfiguration(stateDir: string): Promise { diff --git a/src/test-utils.ts b/src/test-utils.ts new file mode 100644 index 0000000..e6eaddc --- /dev/null +++ b/src/test-utils.ts @@ -0,0 +1,48 @@ +import { rm } from "node:fs/promises"; +import { rmSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; + +const RETRYABLE_REMOVE_ERRORS = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]); + +export async function removeTempDir(path: string): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + await rm(path, { recursive: true, force: true }); + return; + } catch (error) { + if (!isRetryableRemoveError(error)) throw error; + lastError = error; + await delay(25 * (attempt + 1)); + } + } + + throw lastError; +} + +export function removeTempDirSync(path: string): void { + let lastError: unknown; + + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + rmSync(path, { recursive: true, force: true }); + return; + } catch (error) { + if (!isRetryableRemoveError(error)) throw error; + lastError = error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * (attempt + 1)); + } + } + + throw lastError; +} + +function isRetryableRemoveError(error: unknown): boolean { + return ( + typeof error === "object" + && error !== null + && "code" in error + && RETRYABLE_REMOVE_ERRORS.has(String(error.code)) + ); +} diff --git a/src/workflow-migration.test.ts b/src/workflow-migration.test.ts index 2c31480..6955df9 100644 --- a/src/workflow-migration.test.ts +++ b/src/workflow-migration.test.ts @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import Database from "better-sqlite3"; import { databasePath } from "./db/client.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; +import { removeTempDir } from "./test-utils.js"; const root = await mkdtemp(join(tmpdir(), "devspace-workflow-migration-test-")); @@ -74,5 +75,5 @@ try { assert.equal(reopened.getWorkflowHistory({ workspaceSessionId: "new", limit: 50 }).events.length, eventCount); reopened.close(); } finally { - await rm(root, { recursive: true, force: true }); + await removeTempDir(root); } diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index cd0b1db..248ea61 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; -import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { mkdtemp, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { SqliteWorkspaceStore, WorkflowRevisionConflictError } from "./workspace-store.js"; +import { removeTempDir } from "./test-utils.js"; const root = await mkdtemp(join(tmpdir(), "devspace-workflow-store-test-")); @@ -202,5 +203,5 @@ try { store.close(); } finally { - await rm(root, { recursive: true, force: true }); + await removeTempDir(root); } diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 8198fba..eccdc58 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, stat, symlink, writeFile } from "node:fs/promises"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -8,6 +8,7 @@ import { loadConfig } from "./config.js"; import { GitWorktreeError } from "./git-worktrees.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; +import { removeTempDir } from "./test-utils.js"; const execFileAsync = promisify(execFile); const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); @@ -263,7 +264,7 @@ try { assert.equal(aliasWorkspace.workspace.sourceRoot, join(aliasRoot, "git-project")); } } finally { - await rm(root, { recursive: true, force: true }); + await removeTempDir(root); } async function git(cwd: string, args: string[]): Promise { From 8a83014f60dec21e92908168d90f610507f3f7ba Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Tue, 23 Jun 2026 23:04:34 +0800 Subject: [PATCH 37/41] test: skip posix mode assertions on windows --- src/oauth-provider.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/oauth-provider.test.ts b/src/oauth-provider.test.ts index c4cf35d..b9d8d9e 100644 --- a/src/oauth-provider.test.ts +++ b/src/oauth-provider.test.ts @@ -48,10 +48,12 @@ try { assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.access_token)), false); assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.refresh_token)), false); - const stateStats = await stat(databasePath(dirname(statePath))); - const dirStats = await stat(join(root, "state")); - assert.equal(stateStats.mode & 0o777, 0o600); - assert.equal(dirStats.mode & 0o777, 0o700); + if (process.platform !== "win32") { + const stateStats = await stat(databasePath(dirname(statePath))); + const dirStats = await stat(join(root, "state")); + assert.equal(stateStats.mode & 0o777, 0o600); + assert.equal(dirStats.mode & 0o777, 0o700); + } const secondProvider = new SingleUserOAuthProvider(config, resourceServerUrl); providers.push(secondProvider); From d6bc8d6ecf4c0a38f29613892211550b2c670fa4 Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Tue, 23 Jun 2026 23:12:18 +0800 Subject: [PATCH 38/41] test: make skill path assertions cross-platform --- src/skills.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/skills.test.ts b/src/skills.test.ts index 1d4de5c..9bfa245 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -76,7 +76,7 @@ try { assert.equal(loaded.skills.some((skill) => skill.name === "installed-skill" && skill.source === "installed"), true); assert.equal(loaded.skills.some((skill) => skill.name === "global-only-skill" && skill.source === "global"), true); assert.equal(loaded.skills.some((skill) => skill.name === "external-global-skill" && skill.source === "global"), true); - assert.equal(loaded.skills.some((skill) => skill.baseDir.includes("/skills/core/")), false); + assert.equal(loaded.skills.some((skill) => skill.baseDir.includes(join("skills", "core"))), false); assert.deepEqual( loaded.skills.filter((skill) => skill.source === "devspace_system").map((skill) => skill.name).sort(), ["architecture-review", "goal", "plan", "skill-authoring", "workflow"], @@ -85,12 +85,12 @@ try { const duplicate = loaded.skills.find((skill) => skill.name === "duplicate-priority-skill"); assert.ok(duplicate); assert.equal(duplicate.source, "local"); - assert.match(duplicate.filePath, /skills\/local\/duplicate-priority-skill\/SKILL\.md$/); + assert.equal(duplicate.filePath.endsWith(join("skills", "local", "duplicate-priority-skill", "SKILL.md")), true); const plan = loaded.skills.find((skill) => skill.name === "plan"); assert.ok(plan); assert.equal(plan.source, "devspace_system"); - assert.doesNotMatch(plan.filePath, /skills\/local\/plan\/SKILL\.md$/); + assert.equal(plan.filePath.endsWith(join("skills", "local", "plan", "SKILL.md")), false); assert.match(plan.locator, /^skill:\/\/devspace-system\/plan\/SKILL\.md$/); const resolvedPlan = await resolveSkillDefinition(loaded.skills, "/plan"); From 0318a5434ebdd8a183030e3690412bca1c6b0148 Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Tue, 23 Jun 2026 23:16:22 +0800 Subject: [PATCH 39/41] test: normalize patch newlines on windows --- src/workspace-operations.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/workspace-operations.test.ts b/src/workspace-operations.test.ts index c0d2443..b55715e 100644 --- a/src/workspace-operations.test.ts +++ b/src/workspace-operations.test.ts @@ -30,7 +30,7 @@ try { assert.deepEqual(extractPatchPaths(patch), ["README.md"]); const result = await applyWorkspacePatch({ patch }, { root }); assert.deepEqual(result.files, ["README.md"]); - assert.equal(await readFile(join(root, "README.md"), "utf8"), "hello\nworld\n"); + assert.equal(normalizeNewlines(await readFile(join(root, "README.md"), "utf8")), "hello\nworld\n"); const escapingPatch = [ "diff --git a/../escape.txt b/../escape.txt", @@ -56,3 +56,7 @@ try { async function git(cwd: string, args: string[]): Promise { await execFileAsync("git", args, { cwd }); } + +function normalizeNewlines(value: string): string { + return value.replace(/\r\n/g, "\n"); +} From edec4eb9c539e95e851ee08cc4bf7d0da80523d7 Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Tue, 23 Jun 2026 23:20:04 +0800 Subject: [PATCH 40/41] test: normalize bundled skill asset newlines --- src/package-smoke.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/package-smoke.test.ts b/src/package-smoke.test.ts index 760e1e5..fb367f3 100644 --- a/src/package-smoke.test.ts +++ b/src/package-smoke.test.ts @@ -31,7 +31,7 @@ for (const path of requiredAssets) { } for (const path of requiredAssets.filter((asset) => asset.endsWith("/SKILL.md"))) { - const contents = readFileSync(resolve(projectRoot, path), "utf8"); + const contents = normalizeNewlines(readFileSync(resolve(projectRoot, path), "utf8")); assert.match( contents, new RegExp(`\\n version: ${escapeRegExp(packageVersion)}\\n`), @@ -53,3 +53,7 @@ for (const removedPath of [ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } + +function normalizeNewlines(value: string): string { + return value.replace(/\r\n/g, "\n"); +} From cd74679cee234fa6cf0fe62cb93e4a96283af0bc Mon Sep 17 00:00:00 2001 From: hermes-echo Date: Tue, 23 Jun 2026 23:24:26 +0800 Subject: [PATCH 41/41] test: match escaped systemd entrypoint paths --- src/service.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/service.test.ts b/src/service.test.ts index 6d7273b..af1e624 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -84,7 +84,10 @@ try { const startResult = await manager.start(); assert.equal(startResult.ok, true); assert.match(startResult.message, /Started service|Installed and started service/); - assert.match(readFileSync(systemdPaths.userSystemdUnitPath, "utf8"), new RegExp(escapeRegExp(builtCliPath))); + assert.match( + readFileSync(systemdPaths.userSystemdUnitPath, "utf8"), + new RegExp(escapeRegExp(escapeSystemdUnitArg(builtCliPath))), + ); const status = await manager.status(); assert.equal(status.installed, true); assert.equal(status.endpoint, "https://devspace.example.com/mcp"); @@ -159,6 +162,11 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function escapeSystemdUnitArg(value: string): string { + if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value; + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + function createSystemdPaths(baseRoot: string, label: string) { return { userSystemdUnitPath: join(baseRoot, label, ".config", "systemd", "user", "devspace.service"),