diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..2c950c2ef --- /dev/null +++ b/.prettierignore @@ -0,0 +1,21 @@ +# Dependencies +node_modules + +# Build outputs +dist +.next +.expo +build +*.tsbuildinfo + +# Coverage +coverage + +# Lock files +*.lock +package-lock.json + +# Generated +android +ios +.turbo diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..989e171c0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "printWidth": 100 +} diff --git a/package-lock.json b/package-lock.json index afa5b11bd..1e93cad3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "packages/app", "packages/relay", "packages/website", - "packages/desktop" + "packages/desktop", + "packages/cli" ], "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.11" @@ -24,6 +25,7 @@ "get-port-cli": "^3.0.0", "knip": "^5.82.1", "patch-package": "^8.0.1", + "prettier": "^3.5.3", "typescript": "^5.9.3" } }, @@ -6698,6 +6700,10 @@ "resolved": "packages/app", "link": true }, + "node_modules/@paseo/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@paseo/desktop": { "resolved": "packages/desktop", "link": true @@ -9270,6 +9276,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -26235,6 +26248,19 @@ } } }, + "node_modules/zx": { + "version": "8.8.5", + "resolved": "https://registry.npmjs.org/zx/-/zx-8.8.5.tgz", + "integrity": "sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "zx": "build/cli.js" + }, + "engines": { + "node": ">= 12.17.0" + } + }, "packages/app": { "name": "@paseo/app", "version": "1.0.0", @@ -26344,6 +26370,64 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/cli": { + "name": "@paseo/cli", + "version": "0.1.0", + "dependencies": { + "@paseo/server": "*", + "chalk": "^5.3.0", + "commander": "^12.0.0", + "mime-types": "^2.1.35", + "ws": "^8.14.2", + "yaml": "^2.8.2" + }, + "bin": { + "paseo": "bin/paseo" + }, + "devDependencies": { + "@types/mime-types": "^3.0.1", + "@types/ws": "^8.5.8", + "tsx": "^4.6.0", + "typescript": "^5.2.2", + "zx": "^8.8.5" + } + }, + "packages/cli/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "packages/cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "packages/desktop": { "name": "@paseo/desktop", "version": "1.0.0", diff --git a/package.json b/package.json index 91765d94a..b31dfad95 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "packages/app", "packages/relay", "packages/website", - "packages/desktop" + "packages/desktop", + "packages/cli" ], "scripts": { "dev": "./scripts/dev.sh", @@ -17,6 +18,8 @@ "build": "npm run build --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present", "test": "npm run test --workspaces --if-present", + "format": "prettier --write .", + "format:check": "prettier --check .", "start": "npm run start --workspace=@paseo/server", "android": "npm run android --workspace=@paseo/app", "android:release": "ANDROID_VARIANT=productionRelease npm run android --workspace=@paseo/app", @@ -29,6 +32,7 @@ }, "devDependencies": { "concurrently": "^9.2.1", + "prettier": "^3.5.3", "get-port-cli": "^3.0.0", "knip": "^5.82.1", "patch-package": "^8.0.1", diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 09bf07cfa..718aedffc 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -246,7 +246,6 @@ function normalizeAgentSnapshot( requiresAttention: snapshot.requiresAttention ?? false, attentionReason: snapshot.attentionReason ?? null, attentionTimestamp, - parentAgentId: snapshot.parentAgentId, archivedAt, }; } diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 192197a6b..a0ba5380e 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -62,11 +62,6 @@ export function useAggregatedAgents(): AggregatedAgentsResult { } const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; for (const agent of agents.values()) { - // Use agent's own lastActivityAt field directly - // Skip child agents - only show root agents on homepage - if (agent.parentAgentId) { - continue; - } const nextAgent: AggregatedAgent = { id: agent.id, serverId, @@ -79,7 +74,6 @@ export function useAggregatedAgents(): AggregatedAgentsResult { requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason, attentionTimestamp: agent.attentionTimestamp, - parentAgentId: agent.parentAgentId, archivedAt: agent.archivedAt, }; allAgents.push(nextAgent); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 76b4a6db4..6ef96d195 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -98,7 +98,6 @@ export interface Agent { requiresAttention?: boolean; attentionReason?: "finished" | "error" | "permission" | null; attentionTimestamp?: Date | null; - parentAgentId?: string | null; archivedAt?: Date | null; } @@ -839,7 +838,6 @@ export const useSessionStore = create()( requiresAttention: agent.requiresAttention ?? false, attentionReason: agent.attentionReason ?? null, attentionTimestamp: agent.attentionTimestamp ?? null, - parentAgentId: agent.parentAgentId, }); } return entries; diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index 67190b53c..f3a412921 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -12,6 +12,5 @@ export interface AgentDirectoryEntry { requiresAttention?: boolean; attentionReason?: "finished" | "error" | "permission" | null; attentionTimestamp?: Date | null; - parentAgentId?: string | null; archivedAt?: Date | null; } diff --git a/packages/app/src/utils/agent-grouping.test.ts b/packages/app/src/utils/agent-grouping.test.ts index 54fd231e2..0d33aaede 100644 --- a/packages/app/src/utils/agent-grouping.test.ts +++ b/packages/app/src/utils/agent-grouping.test.ts @@ -16,7 +16,6 @@ function makeAgent(overrides: Partial = {}): AggregatedAgent { requiresAttention: overrides.requiresAttention ?? false, attentionReason: overrides.attentionReason ?? null, attentionTimestamp: overrides.attentionTimestamp ?? null, - parentAgentId: overrides.parentAgentId ?? null, } as AggregatedAgent; } diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index 205596a7f..87f534b6d 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -1162,12 +1162,8 @@ const TOOL_NAME_MAP: Record = { read_file: "Read", apply_patch: "Edit", paseo_worktree_setup: "Setup", - "agent-control.set_title": "Set title", - "agent-control.set_branch": "Set branch", set_title: "Set title", set_branch: "Set branch", - "mcp__agent-control__set_title": "Set title", - "mcp__agent-control__set_branch": "Set branch", thinking: "Thinking", }; diff --git a/packages/cli/bin/paseo b/packages/cli/bin/paseo new file mode 100755 index 000000000..67ea354db --- /dev/null +++ b/packages/cli/bin/paseo @@ -0,0 +1,2 @@ +#!/usr/bin/env npx tsx +import '../src/index.js' diff --git a/packages/cli/docs/output-architecture.md b/packages/cli/docs/output-architecture.md new file mode 100644 index 000000000..186e2ff5e --- /dev/null +++ b/packages/cli/docs/output-architecture.md @@ -0,0 +1,543 @@ +# Output Architecture Design + +This document describes the output abstraction layer for the Paseo CLI, enabling structured data output with multiple format options. + +## Overview + +Commands should return **structured data objects**, not formatted strings. A separate rendering layer transforms this data into the requested output format. This separation enables: + +1. **Testability** - Tests verify structured data without parsing strings +2. **Flexibility** - Easy to add new output formats +3. **Consistency** - Uniform formatting across all commands + +### Inspiration from Existing CLIs + +This design draws from patterns in established CLIs: + +- **Docker CLI** - Uses Go templates with `--format` flag, provides `table` and `json` directives +- **kubectl** - Supports `-o json`, `-o yaml`, `-o wide`, and custom columns +- **GitHub CLI** - Uses `--json` with field selection, plus `--jq` and `--template` post-processors + +Sources: +- [Docker CLI Formatting](https://docs.docker.com/engine/cli/formatting/) +- [kubectl Output Formatting](https://www.baeldung.com/ops/kubectl-output-format) +- [GitHub CLI Formatting](https://cli.github.com/manual/gh_help_formatting) + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Command Execution │ +│ │ +│ parseArgs() → executeCommand() → CommandResult │ +└─────────────────────────────────┬───────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Output Renderer │ +│ │ +│ CommandResult + OutputOptions → formatted string │ +│ │ +│ Renderers: │ +│ - TableRenderer (default, human-readable) │ +│ - JsonRenderer (machine-readable) │ +│ - YamlRenderer (machine-readable) │ +│ - QuietRenderer (minimal, IDs only) │ +└─────────────────────────────────┬───────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ stdout/stderr │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Type Definitions + +### Output Options + +```typescript +type OutputFormat = 'table' | 'json' | 'yaml' + +interface OutputOptions { + format: OutputFormat + quiet: boolean // Minimal output (IDs only) + noHeaders: boolean // Omit table headers + noColor: boolean // Disable color output +} +``` + +### Command Result + +Commands return a `CommandResult` that contains structured data plus metadata for formatting: + +```typescript +interface CommandResult { + /** The structured data to render */ + data: T + + /** Schema describing how to render this data */ + schema: OutputSchema +} + +interface OutputSchema { + /** Field to use for quiet mode (--quiet outputs just this) */ + idField: keyof T | ((item: T) => string) + + /** Column definitions for table output */ + columns: ColumnDef[] + + /** Optional: transform data before JSON/YAML output */ + serialize?: (data: T) => unknown +} + +interface ColumnDef { + /** Header text for the column */ + header: string + + /** Field key or accessor function */ + field: keyof T | ((item: T) => unknown) + + /** Optional width hint (characters) */ + width?: number + + /** Optional alignment */ + align?: 'left' | 'right' | 'center' + + /** Optional color function */ + color?: (value: unknown, item: T) => string | undefined +} +``` + +### Single vs List Results + +Commands may return either a single item or a list: + +```typescript +// For commands returning a single item (e.g., `agent show `) +interface SingleResult extends CommandResult { + type: 'single' + data: T +} + +// For commands returning a list (e.g., `agent list`) +interface ListResult extends CommandResult { + type: 'list' + data: T[] +} + +// Union type for command handlers +type AnyCommandResult = SingleResult | ListResult +``` + +## Example: Agent List Command + +### Data Type + +```typescript +interface AgentListItem { + id: string + title: string + status: 'running' | 'idle' | 'error' + provider: string + cwd: string + createdAt: string +} +``` + +### Schema Definition + +```typescript +const agentListSchema: OutputSchema = { + idField: 'id', + + columns: [ + { + header: 'ID', + field: 'id', + width: 8, + }, + { + header: 'TITLE', + field: 'title', + width: 30, + }, + { + header: 'STATUS', + field: 'status', + color: (value) => { + switch (value) { + case 'running': return 'green' + case 'idle': return 'dim' + case 'error': return 'red' + default: return undefined + } + }, + }, + { + header: 'PROVIDER', + field: 'provider', + }, + { + header: 'CWD', + field: 'cwd', + }, + ], +} +``` + +### Command Implementation + +```typescript +async function agentListCommand(options: CommandOptions): Promise> { + const client = await connectToDaemon(options) + const agents = client.listAgents() + + const data = agents.map(agent => ({ + id: agent.agentId, + title: agent.title ?? '(untitled)', + status: mapLifecycleStatus(agent.lifecycle), + provider: agent.agentType, + cwd: agent.cwd, + createdAt: agent.createdAt, + })) + + return { + type: 'list', + data, + schema: agentListSchema, + } +} +``` + +## Renderer Implementations + +### Table Renderer + +The default renderer for human-readable output: + +```typescript +function renderTable(result: ListResult, options: OutputOptions): string { + const { data, schema } = result + + if (data.length === 0) { + return '' // Or a "no items" message + } + + const rows: string[][] = [] + + // Add header row (unless noHeaders) + if (!options.noHeaders) { + rows.push(schema.columns.map(col => col.header)) + } + + // Add data rows + for (const item of data) { + const row = schema.columns.map(col => { + const value = typeof col.field === 'function' + ? col.field(item) + : item[col.field] + return String(value ?? '') + }) + rows.push(row) + } + + // Calculate column widths + const widths = schema.columns.map((col, i) => { + const maxContent = Math.max(...rows.map(row => stripAnsi(row[i]).length)) + return col.width ? Math.max(col.width, maxContent) : maxContent + }) + + // Format and join + return rows.map((row, rowIndex) => { + return row.map((cell, colIndex) => { + const col = schema.columns[colIndex] + const width = widths[colIndex] + let formatted = padCell(cell, width, col.align ?? 'left') + + // Apply color (skip header row) + if (rowIndex > 0 && col.color && !options.noColor) { + const colorName = col.color(cell, data[rowIndex - 1]) + if (colorName) { + formatted = applyColor(formatted, colorName) + } + } + + return formatted + }).join(' ') + }).join('\n') +} +``` + +### JSON Renderer + +```typescript +function renderJson(result: AnyCommandResult, options: OutputOptions): string { + const { data, schema } = result + const output = schema.serialize ? schema.serialize(data) : data + return JSON.stringify(output, null, 2) +} +``` + +### YAML Renderer + +```typescript +import YAML from 'yaml' + +function renderYaml(result: AnyCommandResult, options: OutputOptions): string { + const { data, schema } = result + const output = schema.serialize ? schema.serialize(data) : data + return YAML.stringify(output) +} +``` + +### Quiet Renderer + +Returns only the ID field(s): + +```typescript +function renderQuiet(result: AnyCommandResult, options: OutputOptions): string { + const { data, schema } = result + const getId = typeof schema.idField === 'function' + ? schema.idField + : (item: T) => String(item[schema.idField as keyof T]) + + if (result.type === 'single') { + return getId(data as T) + } + + return (data as T[]).map(getId).join('\n') +} +``` + +## Error Output + +Errors are handled separately from success output and always go to stderr: + +```typescript +interface CommandError { + code: string // Machine-readable error code + message: string // Human-readable message + details?: unknown // Additional context +} + +function renderError(error: CommandError, options: OutputOptions): string { + if (options.format === 'json') { + return JSON.stringify({ error }, null, 2) + } + + if (options.format === 'yaml') { + return YAML.stringify({ error }) + } + + // Table/default format + return chalk.red(`Error: ${error.message}`) +} +``` + +## Streaming Output + +For commands like `logs -f` and `attach`, streaming requires a different approach: + +```typescript +interface StreamingResult { + type: 'stream' + schema: OutputSchema + + /** Async iterator yielding items as they arrive */ + stream: AsyncIterable +} +``` + +### Streaming Renderer + +```typescript +async function renderStream( + result: StreamingResult, + options: OutputOptions, + write: (chunk: string) => void +): Promise { + const { stream, schema } = result + + // For JSON, output newline-delimited JSON (NDJSON) + if (options.format === 'json') { + for await (const item of stream) { + write(JSON.stringify(item) + '\n') + } + return + } + + // For table format, render each item as a row + let headerWritten = false + for await (const item of stream) { + if (!headerWritten && !options.noHeaders) { + write(renderTableHeader(schema) + '\n') + headerWritten = true + } + write(renderTableRow(item, schema, options) + '\n') + } +} +``` + +### NDJSON for Streaming + +When `--format json` is used with streaming commands, output is newline-delimited JSON (NDJSON) for easy parsing: + +``` +{"timestamp":"2024-01-15T10:30:00Z","type":"stdout","content":"Hello"} +{"timestamp":"2024-01-15T10:30:01Z","type":"stdout","content":"World"} +``` + +This allows consumers to process output line-by-line without buffering the entire stream. + +## Testing + +### Testing Structured Data + +Tests can directly verify the structured data without parsing formatted output: + +```typescript +describe('agent list', () => { + it('returns agents with correct structure', async () => { + const result = await agentListCommand({ host: testHost }) + + expect(result.type).toBe('list') + expect(result.data).toHaveLength(2) + expect(result.data[0]).toMatchObject({ + id: expect.any(String), + title: 'Test Agent', + status: 'running', + }) + }) + + it('uses correct schema for table output', async () => { + const result = await agentListCommand({ host: testHost }) + + expect(result.schema.idField).toBe('id') + expect(result.schema.columns.map(c => c.header)).toEqual([ + 'ID', 'TITLE', 'STATUS', 'PROVIDER', 'CWD' + ]) + }) +}) +``` + +### Testing Renderers + +Renderer tests verify formatting independently: + +```typescript +describe('table renderer', () => { + it('formats data as aligned table', () => { + const result: ListResult = { + type: 'list', + data: [ + { id: 'abc123', title: 'Agent 1', status: 'running', ... }, + { id: 'def456', title: 'Agent 2', status: 'idle', ... }, + ], + schema: agentListSchema, + } + + const output = renderTable(result, { format: 'table', quiet: false, ... }) + + expect(output).toContain('ID') + expect(output).toContain('abc123') + expect(output).toContain('Agent 1') + }) +}) +``` + +### E2E Tests + +E2E tests can verify both structured data (for correctness) and formatted output (for UX): + +```typescript +// Verify JSON output is valid and contains expected data +test('agent list --format json', async () => { + const output = await ctx.paseo('agent list --format json') + const data = JSON.parse(output.stdout) + + expect(data).toBeInstanceOf(Array) + expect(data[0]).toHaveProperty('id') +}) + +// Verify table output looks correct +test('agent list shows table headers', async () => { + const output = await ctx.paseo('agent list') + + expect(output.stdout).toMatch(/ID\s+TITLE\s+STATUS/) +}) +``` + +## Integration with Command Framework + +### Global Options + +Add output options to the root command: + +```typescript +program + .option('-f, --format ', 'Output format: table, json, yaml', 'table') + .option('-q, --quiet', 'Minimal output (IDs only)') + .option('--no-headers', 'Omit table headers') + .option('--no-color', 'Disable colored output') +``` + +### Command Handler Wrapper + +A wrapper function handles the rendering: + +```typescript +function withOutput( + handler: (options: CommandOptions) => Promise> +) { + return async (options: CommandOptions) => { + try { + const result = await handler(options) + const output = render(result, options) + process.stdout.write(output + '\n') + } catch (error) { + const errorOutput = renderError(toCommandError(error), options) + process.stderr.write(errorOutput + '\n') + process.exit(1) + } + } +} + +// Usage +program + .command('list') + .description('List agents') + .action(withOutput(agentListCommand)) +``` + +## Implementation Plan + +1. **Phase 1: Core Types** + - Define `CommandResult`, `OutputSchema`, `ColumnDef` types + - Implement basic table renderer + - Implement JSON renderer + +2. **Phase 2: Integration** + - Add global output options to CLI + - Create `withOutput` wrapper + - Migrate `daemon status` command as proof of concept + +3. **Phase 3: Full Coverage** + - Add YAML renderer + - Add quiet renderer + - Migrate all existing commands + +4. **Phase 4: Streaming** + - Implement `StreamingResult` type + - Add streaming renderers + - Apply to `logs` and `attach` commands + +## Open Questions + +1. **Should we support Go templates like Docker/gh?** This adds flexibility but also complexity. For v1, predefined formats are likely sufficient. + +2. **How to handle nested data in tables?** Options: + - Flatten (e.g., `config.timeout` becomes `TIMEOUT` column) + - Skip in table, include in JSON/YAML + - Use nested tables for detail views + +3. **Should quiet mode support custom fields?** e.g., `--quiet=title` to output titles instead of IDs. diff --git a/packages/cli/docs/type-audit.md b/packages/cli/docs/type-audit.md new file mode 100644 index 000000000..0bb1b528b --- /dev/null +++ b/packages/cli/docs/type-audit.md @@ -0,0 +1,155 @@ +# CLI Type Audit (commands) + +## Scope +- Audited `packages/cli/src/commands/**` for inline type/interface definitions. +- Checked `@paseo/server` exports from `packages/server/src/server/exports.ts`. +- Note: `packages/server/src/index.ts` does **not** exist in this repo; the package export entrypoint is `./src/server/exports.ts` per `packages/server/package.json`. + +## Server Exports (current) +`packages/server/src/server/exports.ts` exports: +- `createPaseoDaemon`, `PaseoDaemon`, `PaseoDaemonConfig` +- `loadConfig`, `resolvePaseoHome` +- `createRootLogger`, `LogLevel`, `LogFormat` +- `loadPersistedConfig`, `PersistedConfig` +- `DaemonClientV2`, `DaemonClientV2Config`, `ConnectionState`, `DaemonEvent` + +No agent snapshot/timeline/permission/message types are exported. + +## Findings by File + +### `packages/cli/src/commands/agent/run.ts` +Inline types: +- `AgentSnapshot` (id/provider/cwd/createdAt/status/title) + +Recommended server type: +- `AgentSnapshotPayload` from `packages/server/src/shared/messages.ts` (daemon client returns this shape). **Not exported** from `@paseo/server` today. + +Notes: +- `AgentRunResult` is CLI output; no server type expected. + +--- + +### `packages/cli/src/commands/agent/ps.ts` +Inline types: +- `AgentSnapshot` (id/provider/cwd/createdAt/status/title/archivedAt?) + +Recommended server type: +- `AgentSnapshotPayload` (includes `archivedAt` and full snapshot fields). **Not exported**. + +Notes: +- `AgentListItem` is CLI output; no server type expected. + +--- + +### `packages/cli/src/commands/agent/send.ts` +Inline types: +- `AgentSnapshot` (id/provider/cwd/createdAt/status/title) + +Recommended server type: +- `AgentSnapshotPayload`. **Not exported**. + +Notes: +- `AgentSendResult` is CLI output; no server type expected. + +--- + +### `packages/cli/src/commands/agent/inspect.ts` +Inline types: +- `AgentSnapshotLike` (snapshot fields + `lastUsage`, `capabilities`, `availableModes`, `pendingPermissions`) + +Recommended server types: +- `AgentSnapshotPayload` (overall snapshot shape). **Not exported**. +- `AgentUsage` for `lastUsage`. **Not exported** (in `packages/server/src/server/agent/agent-sdk-types.ts`). +- `AgentCapabilityFlags` for `capabilities`. **Not exported**. +- `AgentMode` for `availableModes`. **Not exported**. +- `AgentPermissionRequest` for `pendingPermissions`. **Not exported**. + +Notes: +- `pendingPermissions` uses `{ id, tool?: string }` but server type is `AgentPermissionRequest` with `{ name, kind, ... }`; current CLI projection is lossy and field names don’t match (`tool` vs `name`). +- `AgentInspect` and `InspectRow` are CLI output types. + +--- + +### `packages/cli/src/commands/agent/logs.ts` +Inline types: +- `AgentStreamSnapshotMessage` +- `AgentStreamMessage` +- Timeline item shape in `formatTimelineItem` and `extractTimelineFrom*` helpers (`{ type: string; ... }`) + +Recommended server types: +- `AgentStreamSnapshotMessage` from `packages/server/src/shared/messages.ts`. **Not exported**. +- `AgentStreamMessage` from `packages/server/src/shared/messages.ts`. **Not exported**. +- `AgentStreamEventPayload` from `packages/server/src/shared/messages.ts` (for `event` typing). **Not exported**. +- `AgentTimelineItem` from `packages/server/src/server/agent/agent-sdk-types.ts` (for timeline item shape). **Not exported**. + +Notes: +- These are WebSocket message types; they should come from shared message definitions to avoid drift. +- `LogEntry` is CLI output. + +--- + +### `packages/cli/src/commands/agent/mode.ts` +Inline types: +- `ModeListItem` (id/label/description) +- `SetModeResult` (agentId/mode) + +Recommended server type: +- `ModeListItem` duplicates the shape of `AgentMode` (id/label/description) from `packages/server/src/server/agent/agent-sdk-types.ts`. **Not exported**. + +Notes: +- `SetModeResult` is CLI output. + +--- + +### `packages/cli/src/commands/daemon/start.ts` +Inline types: +- `StartOptions` (CLI flags) + +Server type usage: +- CLI-only; no server type expected. + +--- + +### `packages/cli/src/commands/daemon/status.ts` +Inline types: +- `DaemonStatus` +- `StatusRow` + +Server type usage: +- CLI-only; no server type expected. + +--- + +### `packages/cli/src/commands/daemon/restart.ts` +Inline types: +- `RestartResult` + +Server type usage: +- CLI-only; no server type expected. + +--- + +### `packages/cli/src/commands/daemon/stop.ts` +Inline types: +- `StopResult` + +Server type usage: +- CLI-only; no server type expected. + +## Gaps in `@paseo/server` Exports (needed for CLI cleanup) +To replace inline types in CLI commands, `@paseo/server` would need to export (directly or re-export): +- From `packages/server/src/shared/messages.ts`: + - `AgentSnapshotPayload` + - `AgentStreamEventPayload` + - `AgentStreamMessage` + - `AgentStreamSnapshotMessage` + - (optionally) `AgentStateMessage`, `SessionStateMessage`, `SessionOutboundMessage` if CLI starts typing daemon event queues more strictly +- From `packages/server/src/server/agent/agent-sdk-types.ts`: + - `AgentMode` + - `AgentUsage` + - `AgentCapabilityFlags` + - `AgentPermissionRequest` + - `AgentTimelineItem` + +## Summary +Primary inline types that should become server imports are the agent snapshot/timeline/message/permission/mode shapes in `agent/*` commands. All are defined in server shared or agent SDK types today but are not exported through `@paseo/server`. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 000000000..3d6a3cc55 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,29 @@ +{ + "name": "@paseo/cli", + "version": "0.1.0", + "description": "Paseo CLI - control your AI coding agents from the command line", + "type": "module", + "bin": { + "paseo": "./bin/paseo" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test:e2e": "npx zx tests/run-all.ts", + "test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts" + }, + "dependencies": { + "@paseo/server": "*", + "chalk": "^5.3.0", + "commander": "^12.0.0", + "mime-types": "^2.1.35", + "ws": "^8.14.2", + "yaml": "^2.8.2" + }, + "devDependencies": { + "@types/mime-types": "^3.0.1", + "@types/ws": "^8.5.8", + "tsx": "^4.6.0", + "typescript": "^5.2.2", + "zx": "^8.8.5" + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 000000000..52f629110 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,146 @@ +import { Command } from 'commander' +import { createAgentCommand } from './commands/agent/index.js' +import { createDaemonCommand } from './commands/daemon/index.js' +import { createPermitCommand } from './commands/permit/index.js' +import { createProviderCommand } from './commands/provider/index.js' +import { createWorktreeCommand } from './commands/worktree/index.js' +import { runLsCommand } from './commands/agent/ls.js' +import { runRunCommand } from './commands/agent/run.js' +import { runLogsCommand } from './commands/agent/logs.js' +import { runStopCommand } from './commands/agent/stop.js' +import { runSendCommand } from './commands/agent/send.js' +import { runInspectCommand } from './commands/agent/inspect.js' +import { runWaitCommand } from './commands/agent/wait.js' +import { runAttachCommand } from './commands/agent/attach.js' +import { withOutput } from './output/index.js' + +const VERSION = '0.1.0' + +// Helper function to collect multiple option values into an array +function collectMultiple(value: string, previous: string[]): string[] { + return previous.concat([value]) +} + +export function createCli(): Command { + const program = new Command() + + program + .name('paseo') + .description('Paseo CLI - control your AI coding agents from the command line') + .version(VERSION, '-v, --version', 'output the version number') + // Global output options + .option('-f, --format ', 'output format: table, json, yaml', 'table') + .option('-q, --quiet', 'minimal output (IDs only)') + .option('--no-headers', 'omit table headers') + .option('--no-color', 'disable colored output') + + // Primary agent commands (top-level) + program + .command('ls') + .description('List agents. By default shows background agents (without ui=true) in current directory.') + .option('-a, --all', 'Include all statuses (not just running)') + .option('-g, --global', 'Show agents from all directories (not just current)') + .option('--label ', 'Filter by label (can be used multiple times)', collectMultiple, []) + .option('--ui', 'Show only UI agents (equivalent to --label ui=true)') + .option('--json', 'Output in JSON format') + .option('--host ', 'Daemon host:port (default: localhost:6767)') + .action((options, command) => { + if (options.json) { + command.parent.opts().format = 'json' + } + return withOutput(runLsCommand)(options, command) + }) + + program + .command('run') + .description('Create and start an agent with a task') + .argument('', 'The task/prompt for the agent') + .option('-d, --detach', 'Run in background (detached)') + .option('--name ', 'Assign a name/title to the agent') + .option('--provider ', 'Agent provider: claude | codex | opencode', 'claude') + .option('--model ', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)') + .option('--mode ', 'Provider-specific mode (e.g., plan, default, bypass)') + .option('--worktree ', 'Create agent in a new git worktree') + .option('--base ', 'Base branch for worktree (default: current branch)') + .option('--image ', 'Attach image(s) to the initial prompt (can be used multiple times)', collectMultiple, []) + .option('--cwd ', 'Working directory (default: current)') + .option('--label ', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, []) + .option('--ui', 'Mark as UI agent (equivalent to --label ui=true)') + .option('--host ', 'Daemon host:port (default: localhost:6767)') + .action(withOutput(runRunCommand)) + + program + .command('attach') + .description("Attach to a running agent's output stream") + .argument('', 'Agent ID (or prefix)') + .option('--host ', 'Daemon host:port (default: localhost:6767)') + .action(runAttachCommand) + + program + .command('logs') + .description('View agent activity/timeline') + .argument('', 'Agent ID (or prefix)') + .option('-f, --follow', 'Follow log output (streaming)') + .option('--tail ', 'Show last n entries') + .option('--filter ', 'Filter by event type (tools, text, errors, permissions)') + .option('--since