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:
co-authored by
Claude Opus 5
parent
db6520bf36
commit
a39959219f
@@ -121,7 +121,7 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
|
|||||||
- `paseo script ls/start/stop`
|
- `paseo script ls/start/stop`
|
||||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||||
- `paseo heartbeat create/update/delete`
|
- `paseo heartbeat create/update/delete`
|
||||||
- `paseo workspace create/ls/archive`
|
- `paseo workspace create/ls/rename/archive`
|
||||||
- `paseo permit allow/deny/ls`
|
- `paseo permit allow/deny/ls`
|
||||||
- `paseo provider ls/models`
|
- `paseo provider ls/models`
|
||||||
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
|
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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
|
```bash
|
||||||
npm run cli -- ls -a -g # List all agents globally
|
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 { runArchiveCommand } from "./archive.js";
|
||||||
import { runCreateCommand } from "./create.js";
|
import { runCreateCommand } from "./create.js";
|
||||||
import { runLsCommand } from "./ls.js";
|
import { runLsCommand } from "./ls.js";
|
||||||
|
import { runRenameCommand } from "./rename.js";
|
||||||
|
|
||||||
export function createWorkspaceCommand(): Command {
|
export function createWorkspaceCommand(): Command {
|
||||||
const workspace = new Command("workspace").description("Manage workspaces");
|
const workspace = new Command("workspace").description("Manage workspaces");
|
||||||
@@ -32,6 +33,18 @@ export function createWorkspaceCommand(): Command {
|
|||||||
withOutput(runLsCommand),
|
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(
|
addJsonAndDaemonHostOptions(
|
||||||
workspace
|
workspace
|
||||||
.command("archive")
|
.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
@@ -76,11 +76,13 @@ paseo workspace create \
|
|||||||
--pr-number 2186
|
--pr-number 2186
|
||||||
```
|
```
|
||||||
|
|
||||||
Then list, use, or archive it:
|
Then list, use, rename, or archive it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo workspace ls
|
paseo workspace ls
|
||||||
paseo run --workspace <workspace-id> "implement authentication"
|
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>
|
paseo workspace archive <workspace-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ Services additionally get:
|
|||||||
```bash
|
```bash
|
||||||
paseo workspace ls
|
paseo workspace ls
|
||||||
paseo run --workspace <workspace-id> "implement auth"
|
paseo run --workspace <workspace-id> "implement auth"
|
||||||
|
paseo workspace rename <workspace-id> "Auth rework"
|
||||||
paseo workspace archive <workspace-id>
|
paseo workspace archive <workspace-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user