diff --git a/docs/architecture.md b/docs/architecture.md index b4bf1a56c9..4dcf7a676a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex - `paseo script ls/start/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` - `paseo heartbeat create/update/delete` -- `paseo workspace create/ls/archive` +- `paseo workspace create/ls/rename/archive` - `paseo permit allow/deny/ls` - `paseo provider ls/models` - hidden legacy `paseo worktree create/ls/archive` compatibility alias diff --git a/docs/development.md b/docs/development.md index f976549ef6..22548cb1ee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -460,7 +460,7 @@ install. Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing. -Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases. +Canonical automation uses `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases. ```bash npm run cli -- ls -a -g # List all agents globally diff --git a/packages/cli/src/commands/workspace/index.ts b/packages/cli/src/commands/workspace/index.ts index 27d7131dce..bf143fa137 100644 --- a/packages/cli/src/commands/workspace/index.ts +++ b/packages/cli/src/commands/workspace/index.ts @@ -4,6 +4,7 @@ import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { runArchiveCommand } from "./archive.js"; import { runCreateCommand } from "./create.js"; import { runLsCommand } from "./ls.js"; +import { runRenameCommand } from "./rename.js"; export function createWorkspaceCommand(): Command { const workspace = new Command("workspace").description("Manage workspaces"); @@ -32,6 +33,18 @@ export function createWorkspaceCommand(): Command { withOutput(runLsCommand), ); + addJsonAndDaemonHostOptions( + workspace + .command("rename") + .description("Set a workspace's user-visible title") + .argument("", "Workspace id") + .argument("[title]", "New workspace title") + .option("--reset", "Clear the title and revert to the branch or directory name") + // Commander 12 accepts excess positionals by default, which would rename to + // the first word of an unquoted multi-word title and silently drop the rest. + .allowExcessArguments(false), + ).action(withOutput(runRenameCommand)); + addJsonAndDaemonHostOptions( workspace .command("archive") diff --git a/packages/cli/src/commands/workspace/rename.test.ts b/packages/cli/src/commands/workspace/rename.test.ts new file mode 100644 index 0000000000..587a4810bd --- /dev/null +++ b/packages/cli/src/commands/workspace/rename.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { createWorkspaceCommand } from "./index.js"; +import { resolveWorkspaceTitle } from "./rename.js"; + +function catchError(run: () => unknown): unknown { + try { + run(); + return null; + } catch (error) { + return error; + } +} + +describe("workspace rename title", () => { + it("trims the requested title", () => { + expect(resolveWorkspaceTitle({ title: " Fix login " })).toBe("Fix login"); + }); + + it("clears the title when resetting to the derived name", () => { + expect(resolveWorkspaceTitle({ reset: true })).toBeNull(); + }); + + it("rejects a reset that also carries a title", () => { + expect( + catchError(() => resolveWorkspaceTitle({ title: "Fix login", reset: true })), + ).toMatchObject({ code: "INVALID_OPTIONS" }); + // An explicit empty title is still a title the caller passed, not a reset. + expect(catchError(() => resolveWorkspaceTitle({ title: "", reset: true }))).toMatchObject({ + code: "INVALID_OPTIONS", + }); + }); + + it("requires a non-empty title when not resetting", () => { + expect(catchError(() => resolveWorkspaceTitle({}))).toMatchObject({ code: "MISSING_TITLE" }); + expect(catchError(() => resolveWorkspaceTitle({ title: " " }))).toMatchObject({ + code: "MISSING_TITLE", + }); + }); +}); + +describe("workspace rename arguments", () => { + function parseRename(argv: string[]): unknown { + const workspace = createWorkspaceCommand() + .exitOverride() + .configureOutput({ writeErr: () => undefined }); + workspace.commands.find((command) => command.name() === "rename")?.exitOverride(); + return catchError(() => workspace.parse(argv, { from: "user" })); + } + + it("rejects an unquoted multi-word title instead of dropping words", () => { + expect(parseRename(["rename", "ws-1", "Auth", "rework"])).toMatchObject({ + code: "commander.excessArguments", + }); + }); +}); diff --git a/packages/cli/src/commands/workspace/rename.ts b/packages/cli/src/commands/workspace/rename.ts new file mode 100644 index 0000000000..fd5857682b --- /dev/null +++ b/packages/cli/src/commands/workspace/rename.ts @@ -0,0 +1,80 @@ +import type { Command } from "commander"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js"; + +interface WorkspaceRenameResult { + workspaceId: string; + /** Raw title override; null once it has been reset to the derived name. */ + title: string | null; +} + +const workspaceRenameSchema: OutputSchema = { + idField: "workspaceId", + columns: [ + { header: "WORKSPACE ID", field: "workspaceId", width: 20 }, + { header: "TITLE", field: "title", width: 30 }, + ], +}; + +export interface WorkspaceRenameOptions { + host?: string; + reset?: boolean; +} + +/** + * Resolve the title to send to the daemon. Null clears the override and + * reverts the workspace to its branch- or directory-derived name. + */ +export function resolveWorkspaceTitle(input: { title?: string; reset?: boolean }): string | null { + const title = input.title?.trim() ?? ""; + if (input.reset) { + if (input.title !== undefined) { + throw { + code: "INVALID_OPTIONS", + message: "--reset cannot be combined with a title", + details: "Pass a title to rename the workspace, or --reset to revert to the derived name", + } satisfies CommandError; + } + return null; + } + if (title.length === 0) { + throw { + code: "MISSING_TITLE", + message: "Title cannot be empty", + details: "Usage: paseo workspace rename | --reset", + } satisfies CommandError; + } + return title; +} + +export async function runRenameCommand( + workspaceId: string, + titleArg: string | undefined, + options: WorkspaceRenameOptions, + _command: Command, +): Promise<SingleResult<WorkspaceRenameResult>> { + const title = resolveWorkspaceTitle({ title: titleArg, reset: options.reset }); + + const host = getDaemonHost({ host: options.host }); + const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + details: "Start the daemon with: paseo daemon start", + } satisfies CommandError; + }); + try { + const applied = await client.setWorkspaceTitle(workspaceId, title); + return { + type: "single", + data: { workspaceId, title: applied.title }, + schema: workspaceRenameSchema, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { code: "WORKSPACE_RENAME_FAILED", message } satisfies CommandError; + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/tests/38-workspace-rename.test.ts b/packages/cli/tests/38-workspace-rename.test.ts new file mode 100644 index 0000000000..50d3b4ce82 --- /dev/null +++ b/packages/cli/tests/38-workspace-rename.test.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert"; +import { basename } from "node:path"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; + +console.log("=== Workspace Rename Command ===\n"); + +const ctx = await createE2ETestContext({ timeout: 30000 }); + +try { + const created = await ctx.paseo([ + "workspace", + "create", + "--isolation", + "local", + "--path", + ctx.workDir, + "--title", + "Original title", + "--json", + ]); + assert.strictEqual(created.exitCode, 0, created.stderr); + const workspaceId = JSON.parse(created.stdout).workspaceId as string; + + const renamed = await ctx.paseo(["workspace", "rename", workspaceId, "Auth rework", "--json"]); + assert.strictEqual(renamed.exitCode, 0, renamed.stderr); + assert.deepStrictEqual(JSON.parse(renamed.stdout), { + workspaceId, + title: "Auth rework", + }); + + const renamedList = await ctx.paseo(["workspace", "ls", "--json"]); + assert.strictEqual(renamedList.exitCode, 0, renamedList.stderr); + assert.strictEqual( + JSON.parse(renamedList.stdout).find( + (workspace: { workspaceId: string }) => workspace.workspaceId === workspaceId, + )?.name, + "Auth rework", + ); + + const reset = await ctx.paseo(["workspace", "rename", workspaceId, "--reset", "--json"]); + assert.strictEqual(reset.exitCode, 0, reset.stderr); + assert.deepStrictEqual(JSON.parse(reset.stdout), { workspaceId, title: null }); + + const resetList = await ctx.paseo(["workspace", "ls", "--json"]); + assert.strictEqual(resetList.exitCode, 0, resetList.stderr); + assert.strictEqual( + JSON.parse(resetList.stdout).find( + (workspace: { workspaceId: string }) => workspace.workspaceId === workspaceId, + )?.name, + basename(ctx.workDir), + ); +} finally { + await ctx.stop(); +} + +console.log("=== Workspace Rename Command Tests Passed ==="); diff --git a/public-docs/cli.md b/public-docs/cli.md index c4880ba2e5..9fb60c6e77 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -76,11 +76,13 @@ paseo workspace create \ --pr-number 2186 ``` -Then list, use, or archive it: +Then list, use, rename, or archive it: ```bash paseo workspace ls paseo run --workspace <workspace-id> "implement authentication" +paseo workspace rename <workspace-id> "Auth rework" +paseo workspace rename <workspace-id> --reset # back to the branch or directory name paseo workspace archive <workspace-id> ``` diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index db43e21c11..3302233851 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -257,6 +257,7 @@ Services additionally get: ```bash paseo workspace ls paseo run --workspace <workspace-id> "implement auth" +paseo workspace rename <workspace-id> "Auth rework" paseo workspace archive <workspace-id> ```