feat: Implement workspace rename cli (#3209)

* feat(cli): add workspace rename command

`paseo workspace rename <workspace-id> <title>` 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>
This commit is contained in:
Martin Hanzík
2026-08-11 16:36:58 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent db6520bf36
commit a39959219f
8 changed files with 212 additions and 3 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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")
@@ -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",
});
});
});
@@ -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);
}
}
@@ -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 ===");
+3 -1
View File
@@ -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>
```
+1
View File
@@ -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>
```