mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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 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
@@ -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
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user