From a39959219fc5f4e7f06d4f13f57f9fc55641bdb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Hanz=C3=ADk?= Date: Tue, 11 Aug 2026 18:36:58 +0200 Subject: [PATCH] feat: Implement workspace rename cli (#3209) * feat(cli): add workspace rename command `paseo workspace rename ` sets a workspace's user-visible title through the existing workspace.title.set RPC, and `--reset` clears the override so the name reverts to the branch or directory. The command reads the descriptor back so the output shows what a reset reverted to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(cli): harden workspace rename against read-back and arg failures Review findings on the rename command: - A failed workspace list sweep after a successful title change reported the whole command as failed. The read-back is display-only, so it now runs outside the mutation's error path and degrades to a null name. - Only --reset needs the descriptor read back; a set title is already the resolved display name, so the common path no longer pages the list. - Dropped the `"code" in error` rethrow. Nothing in that block throws a CommandError, but DaemonRpcError always carries a `code` property even when it is undefined, so the guard let those through to render as UNKNOWN_ERROR with a stack instead of WORKSPACE_RENAME_FAILED. - Commander 12 allows excess positionals, so an unquoted multi-word title renamed to its first word and dropped the rest. The command now rejects excess arguments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(cli): report the rename RPC response instead of reading back Reading the descriptor back to show what --reset reverted to was the only reason rename needed a paged workspace sweep, and that sweep dragged in everything around it: a failure path that could report an applied rename as failed, a nullable name, and a bespoke warning sink to tell the two null cases apart. Sibling commands don't do this. `workspace archive`, `agent mode`, and `schedule update` all report what the RPC returned and stop; the one command that reads back is `agent update`, which has the same "applied but reported as failed" bug. So rename now mirrors `workspace archive`. `--reset` reports `title: null` rather than naming what it reverted to; `paseo workspace ls` shows the resolved name. This also reverts the `collectWorkspaces` extraction, whose only second caller was rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --- docs/architecture.md | 2 +- docs/development.md | 2 +- packages/cli/src/commands/workspace/index.ts | 13 +++ .../cli/src/commands/workspace/rename.test.ts | 55 +++++++++++++ packages/cli/src/commands/workspace/rename.ts | 80 +++++++++++++++++++ .../cli/tests/38-workspace-rename.test.ts | 58 ++++++++++++++ public-docs/cli.md | 4 +- public-docs/worktrees.md | 1 + 8 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/workspace/rename.test.ts create mode 100644 packages/cli/src/commands/workspace/rename.ts create mode 100644 packages/cli/tests/38-workspace-rename.test.ts 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>", "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<WorkspaceRenameResult> = { + 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 <workspace-id> <title> | --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> ```