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
This commit is contained in:
Mohamed Boudra
2026-08-16 20:32:08 +02:00
committed by GitHub
parent ac7796035d
commit ac4331456c
16 changed files with 417 additions and 7 deletions
+1
View File
@@ -134,6 +134,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 project create/ls/rename/delete`
- `paseo workspace create/ls/rename/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`
+1 -1
View File
@@ -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. 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 ```bash
npm run cli -- ls -a -g # List all agents globally npm run cli -- ls -a -g # List all agents globally
+2 -1
View File
@@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest";
import { createCli } from "./cli.js"; import { createCli } from "./cli.js";
describe("canonical CLI surface", () => { 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 cli = createCli();
const help = cli.helpInformation(); const help = cli.helpInformation();
expect(help).toContain("project");
expect(help).toContain("workspace"); expect(help).toContain("workspace");
expect(help).toContain("heartbeat"); expect(help).toContain("heartbeat");
expect(help).not.toContain("worktree"); expect(help).not.toContain("worktree");
+2
View File
@@ -4,6 +4,7 @@ import { createDaemonCommand } from "./commands/daemon/index.js";
import { createPermitCommand } from "./commands/permit/index.js"; import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js"; import { createProviderCommand } from "./commands/provider/index.js";
import { createPluginCommand } from "./commands/plugin/index.js"; import { createPluginCommand } from "./commands/plugin/index.js";
import { createProjectCommand } from "./commands/project/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js"; import { createSpeechCommand } from "./commands/speech/index.js";
import { createScriptCommand } from "./commands/script/index.js"; import { createScriptCommand } from "./commands/script/index.js";
@@ -194,6 +195,7 @@ export function createCli(): Command {
program.addCommand(createSpeechCommand()); program.addCommand(createSpeechCommand());
// Workspace commands // Workspace commands
program.addCommand(createProjectCommand());
program.addCommand(createWorkspaceCommand()); program.addCommand(createWorkspaceCommand());
// COMPAT(worktreeCli): legacy command alias added before workspace was the product unit. // COMPAT(worktreeCli): legacy command alias added before workspace was the product unit.
// Added in v0.2.0; remove after 2027-01-17. // Added in v0.2.0; remove after 2027-01-17.
@@ -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 <path> --host <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<SingleResult<ProjectRow>> {
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);
}
}
@@ -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<SingleResult<ProjectDeleteResult>> {
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);
}
}
@@ -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>", "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>", "Project id"),
).action(withOutput(runDeleteCommand));
return project;
}
+24
View File
@@ -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<ListResult<ProjectRow>> {
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);
}
}
@@ -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"],
});
});
});
@@ -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 <project-id> <name> | --reset",
} satisfies CommandError;
}
return name;
}
export async function runRenameCommand(
projectId: string,
nameArg: string | undefined,
options: CommandOptions & { reset?: boolean },
_command: Command,
): Promise<SingleResult<ProjectRenameResult>> {
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);
}
}
@@ -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<ProjectRow> = {
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,
};
}
+2 -2
View File
@@ -54,13 +54,13 @@ describe("runCli", () => {
it("classifies existing unknown directories as open-project invocations", () => { it("classifies existing unknown directories as open-project invocations", () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-")); const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-"));
const project = path.join(root, "project"); const project = path.join(root, "repository");
mkdirSync(project); mkdirSync(project);
try { try {
expect( expect(
createCliParseArgv({ createCliParseArgv({
argv: ["project"], argv: ["repository"],
cwd: root, cwd: root,
nodeArgv: ["node", "paseo"], nodeArgv: ["node", "paseo"],
}), }),
+29 -1
View File
@@ -1,6 +1,6 @@
--- ---
title: CLI 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 nav: CLI
order: 3 order: 3
category: Getting started 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. 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 <project-id> "My app"
paseo project rename <project-id> --reset
paseo project delete <project-id>
```
`--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 ## Workspaces
Create a workspace independently when you want to prepare its files before starting an agent: Create a workspace independently when you want to prepare its files before starting an agent:
+1 -1
View File
@@ -23,7 +23,7 @@ When the desktop app finds installed Paseo skills, it keeps the bundled skills u
## `/paseo`, Paseo Reference ## `/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. Not typically invoked directly by users, it's a reference that other skills depend on.
+2
View File
@@ -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. 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 ## 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. 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.
+15 -1
View File
@@ -1,10 +1,24 @@
--- ---
name: paseo 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. 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 <project-id> <name>
paseo project rename <project-id> --reset
paseo project delete <project-id>
```
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 ## 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`. **`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`.