From ac4331456c997a5e3c161b91e87cce3af1fbb89e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 16 Aug 2026 20:32:08 +0200 Subject: [PATCH] Manage projects from the CLI (#3460) * feat(cli): manage projects Expose the existing project lifecycle through one CLI command group so projects can be managed without opening the app. * fix(cli): preserve remote project paths Project paths belong to the daemon when a target is explicit. Require that path instead of resolving the CLI machine's cwd, and keep local convenience resolution for the default connection. * docs(cli): document project management * docs(cli): remove repeated project reference --- docs/architecture.md | 1 + docs/development.md | 2 +- packages/cli/src/cli-surface.test.ts | 3 +- packages/cli/src/cli.ts | 2 + packages/cli/src/commands/project/create.ts | 52 +++++++++ packages/cli/src/commands/project/delete.ts | 44 +++++++ packages/cli/src/commands/project/index.ts | 41 +++++++ packages/cli/src/commands/project/ls.ts | 24 ++++ .../cli/src/commands/project/project.test.ts | 109 ++++++++++++++++++ packages/cli/src/commands/project/rename.ts | 64 ++++++++++ packages/cli/src/commands/project/shared.ts | 28 +++++ packages/cli/src/run.test.ts | 4 +- public-docs/cli.md | 30 ++++- public-docs/skills.md | 2 +- public-docs/workspaces.md | 2 + skills/paseo/SKILL.md | 16 ++- 16 files changed, 417 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/commands/project/create.ts create mode 100644 packages/cli/src/commands/project/delete.ts create mode 100644 packages/cli/src/commands/project/index.ts create mode 100644 packages/cli/src/commands/project/ls.ts create mode 100644 packages/cli/src/commands/project/project.test.ts create mode 100644 packages/cli/src/commands/project/rename.ts create mode 100644 packages/cli/src/commands/project/shared.ts diff --git a/docs/architecture.md b/docs/architecture.md index 70cd4c623..780f42023 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -134,6 +134,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 project create/ls/rename/delete` - `paseo workspace create/ls/rename/archive` - `paseo permit allow/deny/ls` - `paseo provider ls/models` diff --git a/docs/development.md b/docs/development.md index e36c827ac..01b51cf77 100644 --- a/docs/development.md +++ b/docs/development.md @@ -510,7 +510,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/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. +Canonical automation uses `paseo project create/ls/rename/delete`, `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/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index c0bc30db5..b205278f6 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest"; import { createCli } from "./cli.js"; describe("canonical CLI surface", () => { - it("shows workspace and heartbeat commands while hiding worktree compatibility", () => { + it("shows project, workspace, and heartbeat commands while hiding worktree compatibility", () => { const cli = createCli(); const help = cli.helpInformation(); + expect(help).toContain("project"); expect(help).toContain("workspace"); expect(help).toContain("heartbeat"); expect(help).not.toContain("worktree"); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4ca8e4643..c1e46366a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -4,6 +4,7 @@ import { createDaemonCommand } from "./commands/daemon/index.js"; import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; import { createPluginCommand } from "./commands/plugin/index.js"; +import { createProjectCommand } from "./commands/project/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js"; import { createSpeechCommand } from "./commands/speech/index.js"; import { createScriptCommand } from "./commands/script/index.js"; @@ -194,6 +195,7 @@ export function createCli(): Command { program.addCommand(createSpeechCommand()); // Workspace commands + program.addCommand(createProjectCommand()); program.addCommand(createWorkspaceCommand()); // COMPAT(worktreeCli): legacy command alias added before workspace was the product unit. // Added in v0.2.0; remove after 2027-01-17. diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts new file mode 100644 index 000000000..2210e6c21 --- /dev/null +++ b/packages/cli/src/commands/project/create.ts @@ -0,0 +1,52 @@ +import path from "node:path"; +import type { Command } from "commander"; +import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js"; +import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js"; +import { projectSchema, toProjectRow, type ProjectRow } from "./shared.js"; + +export function resolveProjectPath(input: { + pathArg?: string; + cwd: string; + daemonTarget?: string; +}): string { + if (input.daemonTarget) { + if (input.pathArg === undefined) { + throw { + code: "MISSING_PATH", + message: "Project path is required when targeting a daemon explicitly", + details: "Usage: paseo project create --host ", + } satisfies CommandError; + } + return input.pathArg; + } + + return path.resolve(input.cwd, input.pathArg ?? "."); +} + +export async function runCreateCommand( + pathArg: string | undefined, + options: CommandOptions, + _command: Command, +): Promise> { + const projectPath = resolveProjectPath({ + pathArg, + cwd: process.cwd(), + daemonTarget: options.host ?? process.env.PASEO_HOST, + }); + const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ host: options.host, error }); + }); + + try { + const payload = await client.addProject(projectPath); + if (!payload.project) { + throw new Error(payload.error ?? "Project creation failed"); + } + return { type: "single", data: toProjectRow(payload.project), schema: projectSchema }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { code: "PROJECT_CREATE_FAILED", message } satisfies CommandError; + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts new file mode 100644 index 000000000..05dd657ef --- /dev/null +++ b/packages/cli/src/commands/project/delete.ts @@ -0,0 +1,44 @@ +import type { Command } from "commander"; +import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js"; +import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js"; + +interface ProjectDeleteResult { + projectId: string; + removedWorkspaceIds: string[]; +} + +const projectDeleteSchema = { + idField: "projectId" as const, + columns: [ + { header: "PROJECT ID", field: "projectId" as const, width: 20 }, + { + header: "REMOVED WORKSPACES", + field: (result: ProjectDeleteResult) => result.removedWorkspaceIds.length, + width: 18, + }, + ], +}; + +export async function runDeleteCommand( + projectId: string, + options: CommandOptions, + _command: Command, +): Promise> { + const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ host: options.host, error }); + }); + + try { + const result = await client.removeProject(projectId); + return { + type: "single", + data: { projectId, removedWorkspaceIds: result.removedWorkspaceIds }, + schema: projectDeleteSchema, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { code: "PROJECT_DELETE_FAILED", message } satisfies CommandError; + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts new file mode 100644 index 000000000..b6d7e756c --- /dev/null +++ b/packages/cli/src/commands/project/index.ts @@ -0,0 +1,41 @@ +import { Command } from "commander"; +import { withOutput } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { runCreateCommand } from "./create.js"; +import { runDeleteCommand } from "./delete.js"; +import { runLsCommand } from "./ls.js"; +import { runRenameCommand } from "./rename.js"; + +export function createProjectCommand(): Command { + const project = new Command("project").description("Manage projects"); + + addJsonAndDaemonHostOptions( + project + .command("create") + .description("Register a project directory") + .argument("[path]", "Project directory (default: current directory)"), + ).action(withOutput(runCreateCommand)); + + addJsonAndDaemonHostOptions(project.command("ls").description("List projects")).action( + withOutput(runLsCommand), + ); + + addJsonAndDaemonHostOptions( + project + .command("rename") + .description("Set a project's user-visible name") + .argument("", "Project id") + .argument("[name]", "New project name") + .option("--reset", "Clear the custom name and use the directory name") + .allowExcessArguments(false), + ).action(withOutput(runRenameCommand)); + + addJsonAndDaemonHostOptions( + project + .command("delete") + .description("Delete a project and its workspaces") + .argument("", "Project id"), + ).action(withOutput(runDeleteCommand)); + + return project; +} diff --git a/packages/cli/src/commands/project/ls.ts b/packages/cli/src/commands/project/ls.ts new file mode 100644 index 000000000..750428881 --- /dev/null +++ b/packages/cli/src/commands/project/ls.ts @@ -0,0 +1,24 @@ +import type { Command } from "commander"; +import type { CommandOptions, ListResult } from "../../output/index.js"; +import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js"; +import { projectSchema, toProjectRow, type ProjectRow } from "./shared.js"; + +export async function runLsCommand( + options: CommandOptions, + _command: Command, +): Promise> { + const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ host: options.host, error }); + }); + + try { + const payload = await client.listProjects(); + return { + type: "list", + data: payload.projects.map(toProjectRow), + schema: projectSchema, + }; + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/src/commands/project/project.test.ts b/packages/cli/src/commands/project/project.test.ts new file mode 100644 index 000000000..47ea1cecf --- /dev/null +++ b/packages/cli/src/commands/project/project.test.ts @@ -0,0 +1,109 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveProjectPath, runCreateCommand } from "./create.js"; +import { runDeleteCommand } from "./delete.js"; +import { createProjectCommand } from "./index.js"; +import { runLsCommand } from "./ls.js"; +import { resolveProjectName, runRenameCommand } from "./rename.js"; + +const project = { + projectId: "project-1", + projectDisplayName: "Paseo", + projectCustomName: null, + projectRootPath: "/tmp/paseo", + projectKind: "git" as const, +}; +const addProject = vi.fn(async () => ({ project, error: null })); +const listProjects = vi.fn(async () => ({ projects: [project] })); +const renameProject = vi.fn(async (_projectId: string, name: string | null) => ({ + customName: name, +})); +const removeProject = vi.fn(async () => ({ removedWorkspaceIds: ["workspace-1"] })); +const close = vi.fn(async () => undefined); + +vi.mock("../../utils/client.js", () => ({ + buildDaemonConnectionCommandError: vi.fn(({ error }: { error: unknown }) => error), + connectToDaemon: vi.fn(async () => ({ + addProject, + listProjects, + renameProject, + removeProject, + close, + })), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("project commands", () => { + it("exposes the complete project lifecycle", () => { + expect(createProjectCommand().commands.map((command) => command.name())).toEqual([ + "create", + "ls", + "rename", + "delete", + ]); + }); + + it("creates a project from an absolute directory path", async () => { + const result = await runCreateCommand("relative/project", {}, {} as never); + + expect(addProject).toHaveBeenCalledWith(path.resolve("relative/project")); + expect(result.data).toEqual({ + projectId: "project-1", + name: "Paseo", + kind: "git", + path: "/tmp/paseo", + }); + expect(close).toHaveBeenCalled(); + }); + + it("resolves paths locally and leaves daemon-owned paths unchanged", () => { + expect(resolveProjectPath({ cwd: "/home/user", pathArg: "repo" })).toBe( + path.resolve("/home/user/repo"), + ); + expect(resolveProjectPath({ cwd: "/home/user" })).toBe(path.resolve("/home/user")); + expect( + resolveProjectPath({ cwd: "/home/user", pathArg: "/srv/repo", daemonTarget: "host:6767" }), + ).toBe("/srv/repo"); + expect( + resolveProjectPath({ cwd: "/home/user", pathArg: "~/repo", daemonTarget: "host:6767" }), + ).toBe("~/repo"); + }); + + it("requires a daemon-owned path for an explicit target", () => { + expect(() => resolveProjectPath({ cwd: "/home/user", daemonTarget: "host:6767" })).toThrow(); + }); + + it("lists projects", async () => { + const result = await runLsCommand({}, {} as never); + + expect(listProjects).toHaveBeenCalled(); + expect(result.data).toHaveLength(1); + expect(result.data[0]?.projectId).toBe("project-1"); + }); + + it("renames and resets a project", async () => { + expect(resolveProjectName({ name: " New name " })).toBe("New name"); + expect(resolveProjectName({ reset: true })).toBeNull(); + + const result = await runRenameCommand("project-1", " New name ", {}, {} as never); + expect(renameProject).toHaveBeenCalledWith("project-1", "New name"); + expect(result.data).toEqual({ projectId: "project-1", name: "New name" }); + }); + + it("rejects an empty project name", () => { + expect(() => resolveProjectName({ name: " " })).toThrow(); + }); + + it("deletes a project", async () => { + const result = await runDeleteCommand("project-1", {}, {} as never); + + expect(removeProject).toHaveBeenCalledWith("project-1"); + expect(result.data).toEqual({ + projectId: "project-1", + removedWorkspaceIds: ["workspace-1"], + }); + }); +}); diff --git a/packages/cli/src/commands/project/rename.ts b/packages/cli/src/commands/project/rename.ts new file mode 100644 index 000000000..b00fdb06b --- /dev/null +++ b/packages/cli/src/commands/project/rename.ts @@ -0,0 +1,64 @@ +import type { Command } from "commander"; +import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js"; +import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js"; + +interface ProjectRenameResult { + projectId: string; + name: string | null; +} + +const projectRenameSchema = { + idField: "projectId" as const, + columns: [ + { header: "PROJECT ID", field: "projectId" as const, width: 20 }, + { header: "NAME", field: "name" as const, width: 30 }, + ], +}; + +export function resolveProjectName(input: { name?: string; reset?: boolean }): string | null { + if (input.reset) { + if (input.name !== undefined) { + throw { + code: "INVALID_OPTIONS", + message: "--reset cannot be combined with a name", + } satisfies CommandError; + } + return null; + } + + const name = input.name?.trim() ?? ""; + if (name.length === 0) { + throw { + code: "MISSING_NAME", + message: "Project name cannot be empty", + details: "Usage: paseo project rename | --reset", + } satisfies CommandError; + } + return name; +} + +export async function runRenameCommand( + projectId: string, + nameArg: string | undefined, + options: CommandOptions & { reset?: boolean }, + _command: Command, +): Promise> { + const name = resolveProjectName({ name: nameArg, reset: options.reset }); + const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ host: options.host, error }); + }); + + try { + const applied = await client.renameProject(projectId, name); + return { + type: "single", + data: { projectId, name: applied.customName }, + schema: projectRenameSchema, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { code: "PROJECT_RENAME_FAILED", message } satisfies CommandError; + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/src/commands/project/shared.ts b/packages/cli/src/commands/project/shared.ts new file mode 100644 index 000000000..ea0e12338 --- /dev/null +++ b/packages/cli/src/commands/project/shared.ts @@ -0,0 +1,28 @@ +import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages"; +import type { OutputSchema } from "../../output/index.js"; + +export interface ProjectRow { + projectId: string; + name: string; + kind: "git" | "non_git" | "directory"; + path: string; +} + +export const projectSchema: OutputSchema = { + idField: "projectId", + columns: [ + { header: "PROJECT ID", field: "projectId", width: 20 }, + { header: "NAME", field: "name", width: 24 }, + { header: "KIND", field: "kind", width: 10 }, + { header: "PATH", field: "path", width: 42 }, + ], +}; + +export function toProjectRow(project: WorkspaceProjectDescriptorPayload): ProjectRow { + return { + projectId: project.projectId, + name: project.projectDisplayName, + kind: project.projectKind, + path: project.projectRootPath, + }; +} diff --git a/packages/cli/src/run.test.ts b/packages/cli/src/run.test.ts index 6424f4c0f..8a0a68582 100644 --- a/packages/cli/src/run.test.ts +++ b/packages/cli/src/run.test.ts @@ -54,13 +54,13 @@ describe("runCli", () => { it("classifies existing unknown directories as open-project invocations", () => { const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-")); - const project = path.join(root, "project"); + const project = path.join(root, "repository"); mkdirSync(project); try { expect( createCliParseArgv({ - argv: ["project"], + argv: ["repository"], cwd: root, nodeArgv: ["node", "paseo"], }), diff --git a/public-docs/cli.md b/public-docs/cli.md index 145d2481c..1a017dd7a 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -1,6 +1,6 @@ --- title: CLI -description: "Paseo CLI reference: manage agents, workspaces, plugins, scripts, schedules, daemons, and permissions from your terminal." +description: "Paseo CLI reference: manage projects, workspaces, agents, plugins, scripts, schedules, daemons, and permissions from your terminal." nav: CLI order: 3 category: Getting started @@ -59,6 +59,34 @@ Use `--output-schema` to return only matching JSON output. You can pass a schema By default, `paseo run` waits for completion. Use `--background` to return immediately while the agent keeps running. +## Projects + +Register the current directory as a project, then list the projects known to the daemon: + +```bash +cd ~/dev/my-app +paseo project create +paseo project ls +``` + +Use the project ID from `paseo project ls` to rename, reset, or delete a project: + +```bash +paseo project rename "My app" +paseo project rename --reset +paseo project delete +``` + +`--reset` restores the name derived from the project directory. Deleting a project archives its active workspaces and removes the project from Paseo. It does not delete the project directory. + +For a local daemon, `paseo project create [path]` defaults to the current directory and resolves relative paths on the CLI machine. When you use `--host` or `PASEO_HOST`, provide a path that the target daemon can access: + +```bash +paseo project create /srv/repos/api --host devbox:6767 +``` + +The remote daemon interprets that path on its own machine. See [Workspaces](/docs/workspaces) for how projects group working directories and sessions. + ## Workspaces Create a workspace independently when you want to prepare its files before starting an agent: diff --git a/public-docs/skills.md b/public-docs/skills.md index 2cdeaf03a..88a9c63fa 100644 --- a/public-docs/skills.md +++ b/public-docs/skills.md @@ -23,7 +23,7 @@ When the desktop app finds installed Paseo skills, it keeps the bundled skills u ## `/paseo`, Paseo Reference -The foundational skill. Paseo reference for managing agents and workspaces. Load it when an agent needs to create agents, send them prompts, or manage workspace isolation. +The foundational skill. Paseo reference for managing projects, workspaces, and agents. Load it when an agent needs to register a project, create agents, send them prompts, or manage workspace isolation. Not typically invoked directly by users, it's a reference that other skills depend on. diff --git a/public-docs/workspaces.md b/public-docs/workspaces.md index aa810c9bd..caf59884c 100644 --- a/public-docs/workspaces.md +++ b/public-docs/workspaces.md @@ -27,6 +27,8 @@ my-app Each workspace is a separate place to work. You can keep one for your main checkout, create another for a feature, or open a GitHub PR as another workspace. +Use the [CLI project commands](/docs/cli#projects) to register, list, rename, or delete projects. + ## Workspaces contain sessions Agents run inside a workspace as sessions. A workspace can have one agent session, several agent sessions, terminals, browsers, and diffs open at the same time. diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 8d686fb35..280db160f 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -1,10 +1,24 @@ --- name: paseo -description: Paseo reference for managing workspaces, workspace scripts, agents, schedules, and heartbeats. +description: Paseo reference for managing projects, workspaces, workspace scripts, agents, schedules, and heartbeats. --- Paseo is a remote daemon that manages coding agents, terminals. Control it through MCP tools or the CLI. +## Projects + +Manage the daemon's project registry through the CLI: + +```bash +paseo project create [path] +paseo project ls +paseo project rename +paseo project rename --reset +paseo project delete +``` + +For a local daemon, `project create` defaults to the current directory and resolves relative paths on the CLI machine. With `--host` or `PASEO_HOST`, always provide a path; the target daemon interprets it on its own machine. Deleting a project archives its active workspaces and removes the project from Paseo without deleting the project directory. + ## Workspaces **`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`.