Merge branch 'main' of github.com:boudra/paseo
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
Generated
+85
-1
@@ -14,7 +14,8 @@
|
|||||||
"packages/app",
|
"packages/app",
|
||||||
"packages/relay",
|
"packages/relay",
|
||||||
"packages/website",
|
"packages/website",
|
||||||
"packages/desktop"
|
"packages/desktop",
|
||||||
|
"packages/cli"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"knip": "^5.82.1",
|
"knip": "^5.82.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6698,6 +6700,10 @@
|
|||||||
"resolved": "packages/app",
|
"resolved": "packages/app",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@paseo/cli": {
|
||||||
|
"resolved": "packages/cli",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@paseo/desktop": {
|
"node_modules/@paseo/desktop": {
|
||||||
"resolved": "packages/desktop",
|
"resolved": "packages/desktop",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -9270,6 +9276,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/minimist": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
|
"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": {
|
"packages/app": {
|
||||||
"name": "@paseo/app",
|
"name": "@paseo/app",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -26344,6 +26370,64 @@
|
|||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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": {
|
"packages/desktop": {
|
||||||
"name": "@paseo/desktop",
|
"name": "@paseo/desktop",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|||||||
+5
-1
@@ -7,7 +7,8 @@
|
|||||||
"packages/app",
|
"packages/app",
|
||||||
"packages/relay",
|
"packages/relay",
|
||||||
"packages/website",
|
"packages/website",
|
||||||
"packages/desktop"
|
"packages/desktop",
|
||||||
|
"packages/cli"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
@@ -17,6 +18,8 @@
|
|||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
"start": "npm run start --workspace=@paseo/server",
|
"start": "npm run start --workspace=@paseo/server",
|
||||||
"android": "npm run android --workspace=@paseo/app",
|
"android": "npm run android --workspace=@paseo/app",
|
||||||
"android:release": "ANDROID_VARIANT=productionRelease npm run android --workspace=@paseo/app",
|
"android:release": "ANDROID_VARIANT=productionRelease npm run android --workspace=@paseo/app",
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"knip": "^5.82.1",
|
"knip": "^5.82.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
|
|||||||
@@ -246,7 +246,6 @@ function normalizeAgentSnapshot(
|
|||||||
requiresAttention: snapshot.requiresAttention ?? false,
|
requiresAttention: snapshot.requiresAttention ?? false,
|
||||||
attentionReason: snapshot.attentionReason ?? null,
|
attentionReason: snapshot.attentionReason ?? null,
|
||||||
attentionTimestamp,
|
attentionTimestamp,
|
||||||
parentAgentId: snapshot.parentAgentId,
|
|
||||||
archivedAt,
|
archivedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,11 +62,6 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
|||||||
}
|
}
|
||||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||||
for (const agent of agents.values()) {
|
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 = {
|
const nextAgent: AggregatedAgent = {
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -79,7 +74,6 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
|||||||
requiresAttention: agent.requiresAttention,
|
requiresAttention: agent.requiresAttention,
|
||||||
attentionReason: agent.attentionReason,
|
attentionReason: agent.attentionReason,
|
||||||
attentionTimestamp: agent.attentionTimestamp,
|
attentionTimestamp: agent.attentionTimestamp,
|
||||||
parentAgentId: agent.parentAgentId,
|
|
||||||
archivedAt: agent.archivedAt,
|
archivedAt: agent.archivedAt,
|
||||||
};
|
};
|
||||||
allAgents.push(nextAgent);
|
allAgents.push(nextAgent);
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export interface Agent {
|
|||||||
requiresAttention?: boolean;
|
requiresAttention?: boolean;
|
||||||
attentionReason?: "finished" | "error" | "permission" | null;
|
attentionReason?: "finished" | "error" | "permission" | null;
|
||||||
attentionTimestamp?: Date | null;
|
attentionTimestamp?: Date | null;
|
||||||
parentAgentId?: string | null;
|
|
||||||
archivedAt?: Date | null;
|
archivedAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,7 +838,6 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
requiresAttention: agent.requiresAttention ?? false,
|
requiresAttention: agent.requiresAttention ?? false,
|
||||||
attentionReason: agent.attentionReason ?? null,
|
attentionReason: agent.attentionReason ?? null,
|
||||||
attentionTimestamp: agent.attentionTimestamp ?? null,
|
attentionTimestamp: agent.attentionTimestamp ?? null,
|
||||||
parentAgentId: agent.parentAgentId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
|
|||||||
@@ -12,6 +12,5 @@ export interface AgentDirectoryEntry {
|
|||||||
requiresAttention?: boolean;
|
requiresAttention?: boolean;
|
||||||
attentionReason?: "finished" | "error" | "permission" | null;
|
attentionReason?: "finished" | "error" | "permission" | null;
|
||||||
attentionTimestamp?: Date | null;
|
attentionTimestamp?: Date | null;
|
||||||
parentAgentId?: string | null;
|
|
||||||
archivedAt?: Date | null;
|
archivedAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ function makeAgent(overrides: Partial<AggregatedAgent> = {}): AggregatedAgent {
|
|||||||
requiresAttention: overrides.requiresAttention ?? false,
|
requiresAttention: overrides.requiresAttention ?? false,
|
||||||
attentionReason: overrides.attentionReason ?? null,
|
attentionReason: overrides.attentionReason ?? null,
|
||||||
attentionTimestamp: overrides.attentionTimestamp ?? null,
|
attentionTimestamp: overrides.attentionTimestamp ?? null,
|
||||||
parentAgentId: overrides.parentAgentId ?? null,
|
|
||||||
} as AggregatedAgent;
|
} as AggregatedAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1162,12 +1162,8 @@ const TOOL_NAME_MAP: Record<string, string> = {
|
|||||||
read_file: "Read",
|
read_file: "Read",
|
||||||
apply_patch: "Edit",
|
apply_patch: "Edit",
|
||||||
paseo_worktree_setup: "Setup",
|
paseo_worktree_setup: "Setup",
|
||||||
"agent-control.set_title": "Set title",
|
|
||||||
"agent-control.set_branch": "Set branch",
|
|
||||||
set_title: "Set title",
|
set_title: "Set title",
|
||||||
set_branch: "Set branch",
|
set_branch: "Set branch",
|
||||||
"mcp__agent-control__set_title": "Set title",
|
|
||||||
"mcp__agent-control__set_branch": "Set branch",
|
|
||||||
thinking: "Thinking",
|
thinking: "Thinking",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
import '../src/index.js'
|
||||||
@@ -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<T> │
|
||||||
|
└─────────────────────────────────┬───────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Output Renderer │
|
||||||
|
│ │
|
||||||
|
│ CommandResult<T> + 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<T>` that contains structured data plus metadata for formatting:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CommandResult<T> {
|
||||||
|
/** The structured data to render */
|
||||||
|
data: T
|
||||||
|
|
||||||
|
/** Schema describing how to render this data */
|
||||||
|
schema: OutputSchema<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutputSchema<T> {
|
||||||
|
/** Field to use for quiet mode (--quiet outputs just this) */
|
||||||
|
idField: keyof T | ((item: T) => string)
|
||||||
|
|
||||||
|
/** Column definitions for table output */
|
||||||
|
columns: ColumnDef<T>[]
|
||||||
|
|
||||||
|
/** Optional: transform data before JSON/YAML output */
|
||||||
|
serialize?: (data: T) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnDef<T> {
|
||||||
|
/** 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 <id>`)
|
||||||
|
interface SingleResult<T> extends CommandResult<T> {
|
||||||
|
type: 'single'
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
// For commands returning a list (e.g., `agent list`)
|
||||||
|
interface ListResult<T> extends CommandResult<T[]> {
|
||||||
|
type: 'list'
|
||||||
|
data: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union type for command handlers
|
||||||
|
type AnyCommandResult<T> = SingleResult<T> | ListResult<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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<AgentListItem> = {
|
||||||
|
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<ListResult<AgentListItem>> {
|
||||||
|
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<T>(result: ListResult<T>, 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<T>(result: AnyCommandResult<T>, 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<T>(result: AnyCommandResult<T>, 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<T>(result: AnyCommandResult<T>, 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<T> {
|
||||||
|
type: 'stream'
|
||||||
|
schema: OutputSchema<T>
|
||||||
|
|
||||||
|
/** Async iterator yielding items as they arrive */
|
||||||
|
stream: AsyncIterable<T>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streaming Renderer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function renderStream<T>(
|
||||||
|
result: StreamingResult<T>,
|
||||||
|
options: OutputOptions,
|
||||||
|
write: (chunk: string) => void
|
||||||
|
): Promise<void> {
|
||||||
|
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<AgentListItem> = {
|
||||||
|
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 <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<T>(
|
||||||
|
handler: (options: CommandOptions) => Promise<AnyCommandResult<T>>
|
||||||
|
) {
|
||||||
|
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.
|
||||||
@@ -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`.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <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 <key=value>', '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 <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('<prompt>', 'The task/prompt for the agent')
|
||||||
|
.option('-d, --detach', 'Run in background (detached)')
|
||||||
|
.option('--name <name>', 'Assign a name/title to the agent')
|
||||||
|
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
|
||||||
|
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
|
||||||
|
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
|
||||||
|
.option('--worktree <name>', 'Create agent in a new git worktree')
|
||||||
|
.option('--base <branch>', 'Base branch for worktree (default: current branch)')
|
||||||
|
.option('--image <path>', 'Attach image(s) to the initial prompt (can be used multiple times)', collectMultiple, [])
|
||||||
|
.option('--cwd <path>', 'Working directory (default: current)')
|
||||||
|
.option('--label <key=value>', '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 <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runRunCommand))
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('attach')
|
||||||
|
.description("Attach to a running agent's output stream")
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(runAttachCommand)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('logs')
|
||||||
|
.description('View agent activity/timeline')
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.option('-f, --follow', 'Follow log output (streaming)')
|
||||||
|
.option('--tail <n>', 'Show last n entries')
|
||||||
|
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
||||||
|
.option('--since <time>', 'Show logs since timestamp')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(runLogsCommand)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('stop')
|
||||||
|
.description('Stop an agent (cancel if running, then terminate)')
|
||||||
|
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
|
||||||
|
.option('--all', 'Stop all agents')
|
||||||
|
.option('--cwd <path>', 'Stop all agents in directory')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runStopCommand))
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('send')
|
||||||
|
.description('Send a message/task to an existing agent')
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.argument('<prompt>', 'The message to send')
|
||||||
|
.option('--no-wait', 'Return immediately without waiting for completion')
|
||||||
|
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runSendCommand))
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('inspect')
|
||||||
|
.description('Show detailed information about an agent')
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.option('--json', 'Output in JSON format')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action((id, options, command) => {
|
||||||
|
if (options.json) {
|
||||||
|
command.parent.opts().format = 'json'
|
||||||
|
}
|
||||||
|
return withOutput(runInspectCommand)(id, options, command)
|
||||||
|
})
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('wait')
|
||||||
|
.description('Wait for an agent to become idle')
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.option('--timeout <seconds>', 'Maximum wait time (default: 600)')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runWaitCommand))
|
||||||
|
|
||||||
|
// Advanced agent commands (less common operations)
|
||||||
|
program.addCommand(createAgentCommand())
|
||||||
|
|
||||||
|
// Daemon commands
|
||||||
|
program.addCommand(createDaemonCommand())
|
||||||
|
|
||||||
|
// Permission commands
|
||||||
|
program.addCommand(createPermitCommand())
|
||||||
|
|
||||||
|
// Provider commands
|
||||||
|
program.addCommand(createProviderCommand())
|
||||||
|
|
||||||
|
// Worktree commands
|
||||||
|
program.addCommand(createWorktreeCommand())
|
||||||
|
|
||||||
|
return program
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { AgentSnapshotPayload } from '@paseo/server'
|
||||||
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Result type for agent archive command */
|
||||||
|
export interface AgentArchiveResult {
|
||||||
|
agentId: string
|
||||||
|
status: 'archived'
|
||||||
|
archivedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for archive command output */
|
||||||
|
export const archiveSchema: OutputSchema<AgentArchiveResult> = {
|
||||||
|
idField: 'agentId',
|
||||||
|
columns: [
|
||||||
|
{ header: 'AGENT ID', field: 'agentId' },
|
||||||
|
{ header: 'STATUS', field: 'status' },
|
||||||
|
{ header: 'ARCHIVED AT', field: 'archivedAt' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentArchiveOptions extends CommandOptions {
|
||||||
|
force?: boolean
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentArchiveCommandResult = SingleResult<AgentArchiveResult>
|
||||||
|
|
||||||
|
export async function runArchiveCommand(
|
||||||
|
agentIdArg: string,
|
||||||
|
options: AgentArchiveOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<AgentArchiveCommandResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||||
|
|
||||||
|
// Validate arguments
|
||||||
|
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MISSING_AGENT_ID',
|
||||||
|
message: 'Agent ID is required',
|
||||||
|
details: 'Usage: paseo agent archive <id>',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait a moment for the session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
|
||||||
|
// Resolve agent ID (supports prefix matching)
|
||||||
|
const agentId = resolveAgentId(agentIdArg, agents)
|
||||||
|
if (!agentId) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found: ${agentIdArg}`,
|
||||||
|
details: 'Use "paseo ls" to list available agents',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the agent snapshot to check status
|
||||||
|
const agent = agents.find((a: AgentSnapshotPayload) => a.id === agentId)
|
||||||
|
if (!agent) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found: ${agentIdArg}`,
|
||||||
|
details: 'Use "paseo ls" to list available agents',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent is already archived
|
||||||
|
if (agent.archivedAt) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_ALREADY_ARCHIVED',
|
||||||
|
message: `Agent ${agentId.slice(0, 7)} is already archived`,
|
||||||
|
details: `Archived at: ${agent.archivedAt}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent is running and reject unless --force is set
|
||||||
|
if (agent.status === 'running' && !options.force) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_RUNNING',
|
||||||
|
message: `Agent ${agentId.slice(0, 7)} is currently running`,
|
||||||
|
details: 'Use --force to archive a running agent, or stop it first with: paseo agent stop',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive the agent
|
||||||
|
const result = await client.archiveAgent(agentId)
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
agentId,
|
||||||
|
status: 'archived',
|
||||||
|
archivedAt: result.archivedAt,
|
||||||
|
},
|
||||||
|
schema: archiveSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
|
||||||
|
// Re-throw CommandError as-is
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'ARCHIVE_FAILED',
|
||||||
|
message: `Failed to archive agent: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||||
|
import type {
|
||||||
|
DaemonClientV2,
|
||||||
|
AgentStreamMessage,
|
||||||
|
AgentStreamSnapshotMessage,
|
||||||
|
AgentStreamEventPayload,
|
||||||
|
AgentTimelineItem,
|
||||||
|
} from '@paseo/server'
|
||||||
|
|
||||||
|
export interface AgentAttachOptions {
|
||||||
|
host?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format and print a timeline item to the terminal
|
||||||
|
*/
|
||||||
|
function printTimelineItem(item: AgentTimelineItem): void {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'assistant_message':
|
||||||
|
// Print assistant text directly
|
||||||
|
process.stdout.write(item.text)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'reasoning':
|
||||||
|
// Print reasoning in a muted color if available
|
||||||
|
console.log(`\n[Reasoning] ${item.text}`)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'tool_call': {
|
||||||
|
const toolName = item.name
|
||||||
|
const status = item.status ?? 'started'
|
||||||
|
console.log(`\n[Tool: ${toolName}] ${status}`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'todo': {
|
||||||
|
const completed = item.items.filter((i) => i.completed).length
|
||||||
|
const total = item.items.length
|
||||||
|
console.log(`\n[Todo] ${completed}/${total} completed`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
console.error(`\n[Error] ${item.message}`)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'user_message':
|
||||||
|
console.log(`\n[User] ${item.text}`)
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown item type, skip
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format and print a stream event to the terminal
|
||||||
|
*/
|
||||||
|
function printStreamEvent(event: AgentStreamEventPayload): void {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'timeline':
|
||||||
|
// Print the timeline item
|
||||||
|
printTimelineItem(event.item)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'permission_requested':
|
||||||
|
console.log(`\n[Permission Required] ${event.request.name}`)
|
||||||
|
if (event.request.description) {
|
||||||
|
console.log(` ${event.request.description}`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'permission_resolved':
|
||||||
|
console.log(`\n[Permission ${event.resolution.behavior}]`)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'turn_failed':
|
||||||
|
console.error(`\n[Turn Failed] ${event.error}`)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'attention_required':
|
||||||
|
console.log(`\n[Attention Required: ${event.reason}]`)
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Other event types (thread_started, provider_event, etc.) are internal
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach to a running agent's output stream
|
||||||
|
*/
|
||||||
|
export async function runAttachCommand(
|
||||||
|
id: string,
|
||||||
|
options: AgentAttachOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<void> {
|
||||||
|
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
console.error('Error: Agent ID required')
|
||||||
|
console.error('Usage: paseo attach <id>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let client: DaemonClientV2
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`)
|
||||||
|
console.error('Start the daemon with: paseo daemon start')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait for session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
const resolvedId = resolveAgentId(id, agents)
|
||||||
|
|
||||||
|
if (!resolvedId) {
|
||||||
|
console.error(`Error: No agent found matching: ${id}`)
|
||||||
|
console.error('Use `paseo ls` to list available agents')
|
||||||
|
await client.close()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = agents.find((a) => a.id === resolvedId)
|
||||||
|
if (!agent) {
|
||||||
|
console.error(`Error: Agent not found: ${resolvedId}`)
|
||||||
|
await client.close()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print header
|
||||||
|
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`)
|
||||||
|
console.log(`(Press Ctrl+C to detach)\n`)
|
||||||
|
|
||||||
|
// Get existing output from snapshot
|
||||||
|
const snapshotPromise = new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve(), 5000)
|
||||||
|
|
||||||
|
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
|
||||||
|
const message = msg as AgentStreamSnapshotMessage
|
||||||
|
if (message.type !== 'agent_stream_snapshot') return
|
||||||
|
if (message.payload.agentId !== resolvedId) return
|
||||||
|
|
||||||
|
clearTimeout(timeout)
|
||||||
|
unsubscribe()
|
||||||
|
|
||||||
|
// Print recent events from snapshot
|
||||||
|
for (const e of message.payload.events) {
|
||||||
|
printStreamEvent(e.event)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize agent to trigger snapshot
|
||||||
|
try {
|
||||||
|
await client.initializeAgent(resolvedId)
|
||||||
|
} catch {
|
||||||
|
// Agent might already be initialized, continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for snapshot
|
||||||
|
await snapshotPromise
|
||||||
|
|
||||||
|
// Subscribe to new events
|
||||||
|
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
|
||||||
|
const message = msg as AgentStreamMessage
|
||||||
|
if (message.type !== 'agent_stream') return
|
||||||
|
if (message.payload.agentId !== resolvedId) return
|
||||||
|
|
||||||
|
printStreamEvent(message.payload.event)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle Ctrl+C to detach gracefully
|
||||||
|
let detached = false
|
||||||
|
const detach = () => {
|
||||||
|
if (detached) return
|
||||||
|
detached = true
|
||||||
|
|
||||||
|
console.log('\n\nDetaching from agent...')
|
||||||
|
unsubscribe()
|
||||||
|
client
|
||||||
|
.close()
|
||||||
|
.then(() => {
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', detach)
|
||||||
|
process.on('SIGTERM', detach)
|
||||||
|
|
||||||
|
// Keep the process alive
|
||||||
|
await new Promise(() => {
|
||||||
|
// Wait indefinitely until interrupted
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
console.error(`Error: Failed to attach to agent: ${message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Command } from 'commander'
|
||||||
|
import { runModeCommand } from './mode.js'
|
||||||
|
import { runArchiveCommand } from './archive.js'
|
||||||
|
import { runLsCommand } from './ls.js'
|
||||||
|
import { runRunCommand } from './run.js'
|
||||||
|
import { runLogsCommand } from './logs.js'
|
||||||
|
import { runStopCommand } from './stop.js'
|
||||||
|
import { runSendCommand } from './send.js'
|
||||||
|
import { runInspectCommand } from './inspect.js'
|
||||||
|
import { runWaitCommand } from './wait.js'
|
||||||
|
import { runAttachCommand } from './attach.js'
|
||||||
|
import { withOutput } from '../../output/index.js'
|
||||||
|
|
||||||
|
export function createAgentCommand(): Command {
|
||||||
|
const agent = new Command('agent').description('Manage agents (advanced operations)')
|
||||||
|
|
||||||
|
// Helper function to collect multiple option values into an array
|
||||||
|
const collectMultiple = (value: string, previous: string[]): string[] => {
|
||||||
|
return previous.concat([value])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary agent commands (same as top-level)
|
||||||
|
agent
|
||||||
|
.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 <key=value>', '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 <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action((options, command) => {
|
||||||
|
if (options.json) {
|
||||||
|
command.parent.parent.opts().format = 'json'
|
||||||
|
}
|
||||||
|
return withOutput(runLsCommand)(options, command)
|
||||||
|
})
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command('run')
|
||||||
|
.description('Create and start an agent with a task')
|
||||||
|
.argument('<prompt>', 'The task/prompt for the agent')
|
||||||
|
.option('-d, --detach', 'Run in background (detached)')
|
||||||
|
.option('--name <name>', 'Assign a name/title to the agent')
|
||||||
|
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
|
||||||
|
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
|
||||||
|
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
|
||||||
|
.option('--cwd <path>', 'Working directory (default: current)')
|
||||||
|
.option('--label <key=value>', '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 <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runRunCommand))
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command('attach')
|
||||||
|
.description("Attach to a running agent's output stream")
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(runAttachCommand)
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command('logs')
|
||||||
|
.description('View agent activity/timeline')
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.option('-f, --follow', 'Follow log output (streaming)')
|
||||||
|
.option('--tail <n>', 'Show last n entries')
|
||||||
|
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(runLogsCommand)
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command('stop')
|
||||||
|
.description('Stop an agent (cancel if running, then terminate)')
|
||||||
|
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
|
||||||
|
.option('--all', 'Stop all agents')
|
||||||
|
.option('--cwd <path>', 'Stop all agents in directory')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runStopCommand))
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command('send')
|
||||||
|
.description('Send a message/task to an existing agent')
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.argument('<prompt>', 'The message to send')
|
||||||
|
.option('--no-wait', 'Return immediately without waiting for completion')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runSendCommand))
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command('inspect')
|
||||||
|
.description('Show detailed information about an agent')
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runInspectCommand))
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command('wait')
|
||||||
|
.description('Wait for an agent to become idle')
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.option('--timeout <seconds>', 'Maximum wait time (default: 600)')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runWaitCommand))
|
||||||
|
|
||||||
|
// Advanced agent commands (less common operations)
|
||||||
|
agent
|
||||||
|
.command('mode')
|
||||||
|
.description("Change an agent's operational mode")
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.argument('[mode]', 'Mode to set (required unless --list)')
|
||||||
|
.option('--list', 'List available modes for this agent')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runModeCommand))
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command('archive')
|
||||||
|
.description('Archive an agent (soft-delete)')
|
||||||
|
.argument('<id>', 'Agent ID (or prefix)')
|
||||||
|
.option('--force', 'Force archive running agent')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runArchiveCommand))
|
||||||
|
|
||||||
|
return agent
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { AgentSnapshotPayload } from '@paseo/server'
|
||||||
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Agent inspect data for display (matches CLI spec format) */
|
||||||
|
interface AgentInspect {
|
||||||
|
Id: string
|
||||||
|
Name: string
|
||||||
|
Provider: string
|
||||||
|
Model: string
|
||||||
|
Status: string
|
||||||
|
Mode: string
|
||||||
|
Cwd: string
|
||||||
|
CreatedAt: string
|
||||||
|
UpdatedAt: string
|
||||||
|
LastUsage: {
|
||||||
|
InputTokens: number
|
||||||
|
OutputTokens: number
|
||||||
|
CachedTokens: number
|
||||||
|
CostUsd: number
|
||||||
|
} | null
|
||||||
|
Capabilities: {
|
||||||
|
Streaming: boolean
|
||||||
|
Persistence: boolean
|
||||||
|
DynamicModes: boolean
|
||||||
|
McpServers: boolean
|
||||||
|
} | null
|
||||||
|
AvailableModes: Array<{
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}> | null
|
||||||
|
PendingPermissions: Array<{
|
||||||
|
id: string
|
||||||
|
tool: string
|
||||||
|
}>
|
||||||
|
Worktree: string | null
|
||||||
|
ParentAgentId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key-value row for table display */
|
||||||
|
interface InspectRow {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for key-value display with custom serialization for JSON/YAML */
|
||||||
|
function createInspectSchema(agent: AgentInspect): OutputSchema<InspectRow> {
|
||||||
|
return {
|
||||||
|
idField: 'key',
|
||||||
|
columns: [
|
||||||
|
{ header: 'KEY', field: 'key' },
|
||||||
|
{
|
||||||
|
header: 'VALUE',
|
||||||
|
field: 'value',
|
||||||
|
color: (_, item) => {
|
||||||
|
if (item.key === 'Status') {
|
||||||
|
if (item.value === 'running') return 'green'
|
||||||
|
if (item.value === 'idle') return 'yellow'
|
||||||
|
if (item.value === 'error') return 'red'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// For JSON/YAML, return the structured agent object
|
||||||
|
serialize: (_item) => agent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorten home directory in path */
|
||||||
|
function shortenPath(path: string): string {
|
||||||
|
const home = process.env.HOME
|
||||||
|
if (home && path.startsWith(home)) {
|
||||||
|
return '~' + path.slice(home.length)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format cost in USD */
|
||||||
|
function formatCost(costUsd: number): string {
|
||||||
|
if (costUsd === 0) return '$0.00'
|
||||||
|
if (costUsd < 0.01) return `$${costUsd.toFixed(4)}`
|
||||||
|
return `$${costUsd.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert agent snapshot to inspection data */
|
||||||
|
function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
|
||||||
|
const lastUsage = snapshot.lastUsage
|
||||||
|
? {
|
||||||
|
InputTokens: snapshot.lastUsage.inputTokens ?? 0,
|
||||||
|
OutputTokens: snapshot.lastUsage.outputTokens ?? 0,
|
||||||
|
CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
|
||||||
|
CostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const capabilities = snapshot.capabilities
|
||||||
|
? {
|
||||||
|
Streaming: snapshot.capabilities.supportsStreaming ?? false,
|
||||||
|
Persistence: snapshot.capabilities.supportsSessionPersistence ?? false,
|
||||||
|
DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
|
||||||
|
McpServers: snapshot.capabilities.supportsMcpServers ?? false,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Extract worktree and parentAgentId from labels if they exist
|
||||||
|
const worktree = snapshot.labels?.['paseo.worktree'] ?? null
|
||||||
|
const parentAgentId = snapshot.labels?.['paseo.parent-agent-id'] ?? null
|
||||||
|
|
||||||
|
return {
|
||||||
|
Id: snapshot.id,
|
||||||
|
Name: snapshot.title ?? '-',
|
||||||
|
Provider: snapshot.provider,
|
||||||
|
Model: snapshot.model ?? '-',
|
||||||
|
Status: snapshot.status,
|
||||||
|
Mode: snapshot.currentModeId ?? 'default',
|
||||||
|
Cwd: snapshot.cwd,
|
||||||
|
CreatedAt: snapshot.createdAt,
|
||||||
|
UpdatedAt: snapshot.updatedAt,
|
||||||
|
LastUsage: lastUsage,
|
||||||
|
Capabilities: capabilities,
|
||||||
|
AvailableModes: snapshot.availableModes
|
||||||
|
? snapshot.availableModes.map((m) => ({ id: m.id, label: m.label }))
|
||||||
|
: null,
|
||||||
|
PendingPermissions: (snapshot.pendingPermissions ?? []).map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
tool: p.name ?? 'unknown',
|
||||||
|
})),
|
||||||
|
Worktree: worktree,
|
||||||
|
ParentAgentId: parentAgentId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert agent to key-value rows for table display */
|
||||||
|
function toInspectRows(agent: AgentInspect): InspectRow[] {
|
||||||
|
const rows: InspectRow[] = [
|
||||||
|
{ key: 'Id', value: agent.Id },
|
||||||
|
{ key: 'Name', value: agent.Name },
|
||||||
|
{ key: 'Provider', value: agent.Provider },
|
||||||
|
{ key: 'Model', value: agent.Model },
|
||||||
|
{ key: 'Status', value: agent.Status },
|
||||||
|
{ key: 'Mode', value: agent.Mode },
|
||||||
|
{ key: 'Cwd', value: shortenPath(agent.Cwd) },
|
||||||
|
{ key: 'CreatedAt', value: agent.CreatedAt },
|
||||||
|
{ key: 'UpdatedAt', value: agent.UpdatedAt },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (agent.LastUsage) {
|
||||||
|
rows.push({
|
||||||
|
key: 'LastUsage',
|
||||||
|
value: `InputTokens: ${agent.LastUsage.InputTokens}, OutputTokens: ${agent.LastUsage.OutputTokens}, CachedTokens: ${agent.LastUsage.CachedTokens}, CostUsd: ${formatCost(agent.LastUsage.CostUsd)}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.Capabilities) {
|
||||||
|
rows.push({
|
||||||
|
key: 'Capabilities',
|
||||||
|
value: `Streaming: ${agent.Capabilities.Streaming}, Persistence: ${agent.Capabilities.Persistence}, DynamicModes: ${agent.Capabilities.DynamicModes}, McpServers: ${agent.Capabilities.McpServers}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.AvailableModes && agent.AvailableModes.length > 0) {
|
||||||
|
rows.push({
|
||||||
|
key: 'AvailableModes',
|
||||||
|
value: agent.AvailableModes.map((m) => `${m.id} (${m.label})`).join(', '),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
key: 'PendingPermissions',
|
||||||
|
value: agent.PendingPermissions.length > 0
|
||||||
|
? agent.PendingPermissions.map(p => `${p.id} (${p.tool})`).join(', ')
|
||||||
|
: '[]'
|
||||||
|
})
|
||||||
|
|
||||||
|
rows.push({ key: 'Worktree', value: agent.Worktree ?? 'null' })
|
||||||
|
rows.push({ key: 'ParentAgentId', value: agent.ParentAgentId ?? 'null' })
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentInspectResult = ListResult<InspectRow>
|
||||||
|
|
||||||
|
export interface AgentInspectOptions extends CommandOptions {
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInspectCommand(
|
||||||
|
agentIdArg: string,
|
||||||
|
options: AgentInspectOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<AgentInspectResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||||
|
|
||||||
|
// Validate arguments
|
||||||
|
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MISSING_AGENT_ID',
|
||||||
|
message: 'Agent ID is required',
|
||||||
|
details: 'Usage: paseo agent inspect <id>',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait a moment for the session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
|
||||||
|
// Resolve agent ID (supports prefix matching)
|
||||||
|
const agentId = resolveAgentId(agentIdArg, agents)
|
||||||
|
if (!agentId) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found: ${agentIdArg}`,
|
||||||
|
details: 'Use "paseo ls" to list available agents',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the full agent snapshot
|
||||||
|
const snapshot = agents.find((a) => a.id === agentId)
|
||||||
|
if (!snapshot) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found: ${agentIdArg}`,
|
||||||
|
details: 'Use "paseo ls" to list available agents',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
const inspectData = toInspectData(snapshot)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
data: toInspectRows(inspectData),
|
||||||
|
schema: createInspectSchema(inspectData),
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
|
||||||
|
// Re-throw CommandError as-is
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'INSPECT_FAILED',
|
||||||
|
message: `Failed to inspect agent: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions } from '../../output/index.js'
|
||||||
|
import type {
|
||||||
|
DaemonClientV2,
|
||||||
|
AgentStreamMessage,
|
||||||
|
AgentStreamSnapshotMessage,
|
||||||
|
AgentTimelineItem,
|
||||||
|
} from '@paseo/server'
|
||||||
|
import { curateAgentActivity } from '@paseo/server'
|
||||||
|
|
||||||
|
export interface AgentLogsOptions extends CommandOptions {
|
||||||
|
follow?: boolean
|
||||||
|
tail?: string
|
||||||
|
filter?: string
|
||||||
|
since?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs command returns void - it outputs directly to console
|
||||||
|
export type AgentLogsResult = void
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a timeline item matches the filter type
|
||||||
|
*/
|
||||||
|
function matchesFilter(item: AgentTimelineItem, filter?: string): boolean {
|
||||||
|
if (!filter) return true
|
||||||
|
|
||||||
|
const filterLower = filter.toLowerCase()
|
||||||
|
const type = item.type.toLowerCase()
|
||||||
|
|
||||||
|
switch (filterLower) {
|
||||||
|
case 'tools':
|
||||||
|
return type === 'tool_call'
|
||||||
|
case 'text':
|
||||||
|
return type === 'user_message' || type === 'assistant_message' || type === 'reasoning'
|
||||||
|
case 'errors':
|
||||||
|
return type === 'error'
|
||||||
|
case 'permissions':
|
||||||
|
// Permissions might be in tool_call status or a separate event type
|
||||||
|
return type.includes('permission')
|
||||||
|
default:
|
||||||
|
// If filter doesn't match predefined types, match against the actual type
|
||||||
|
return type.includes(filterLower)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract timeline items from an agent_stream_snapshot message
|
||||||
|
*/
|
||||||
|
function extractTimelineFromSnapshot(message: AgentStreamSnapshotMessage): AgentTimelineItem[] {
|
||||||
|
const items: AgentTimelineItem[] = []
|
||||||
|
for (const e of message.payload.events) {
|
||||||
|
if (e.event.type === 'timeline') {
|
||||||
|
items.push(e.event.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a timeline item from an agent_stream message
|
||||||
|
*/
|
||||||
|
function extractTimelineFromStream(message: AgentStreamMessage): AgentTimelineItem | null {
|
||||||
|
if (message.payload.event.type === 'timeline') {
|
||||||
|
return message.payload.event.item
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runLogsCommand(
|
||||||
|
id: string,
|
||||||
|
options: AgentLogsOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<AgentLogsResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
console.error('Error: Agent ID required')
|
||||||
|
console.error('Usage: paseo agent logs <id>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let client: DaemonClientV2
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`)
|
||||||
|
console.error('Start the daemon with: paseo daemon start')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait for session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
const resolvedId = resolveAgentId(id, agents)
|
||||||
|
|
||||||
|
if (!resolvedId) {
|
||||||
|
console.error(`Error: No agent found matching: ${id}`)
|
||||||
|
console.error('Use `paseo ls` to list available agents')
|
||||||
|
await client.close()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For follow mode, we stream events continuously
|
||||||
|
if (options.follow) {
|
||||||
|
await runFollowMode(client, resolvedId, options)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-follow mode, initialize the agent to get timeline snapshot
|
||||||
|
// Set up handler for timeline events before initializing
|
||||||
|
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve([]), 10000)
|
||||||
|
|
||||||
|
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
|
||||||
|
const message = msg as AgentStreamSnapshotMessage
|
||||||
|
if (message.type !== 'agent_stream_snapshot') return
|
||||||
|
if (message.payload.agentId !== resolvedId) return
|
||||||
|
|
||||||
|
clearTimeout(timeout)
|
||||||
|
unsubscribe()
|
||||||
|
resolve(extractTimelineFromSnapshot(message))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize agent to trigger timeline snapshot
|
||||||
|
try {
|
||||||
|
await client.initializeAgent(resolvedId)
|
||||||
|
} catch {
|
||||||
|
// Agent might already be initialized, continue to collect from queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get timeline from snapshot
|
||||||
|
let timelineItems = await snapshotPromise
|
||||||
|
|
||||||
|
// Also check message queue for any stream events
|
||||||
|
const queue = client.getMessageQueue()
|
||||||
|
for (const msg of queue) {
|
||||||
|
if (msg.type === 'agent_stream') {
|
||||||
|
const streamMsg = msg as AgentStreamMessage
|
||||||
|
if (streamMsg.payload.agentId === resolvedId) {
|
||||||
|
const item = extractTimelineFromStream(streamMsg)
|
||||||
|
if (item) {
|
||||||
|
timelineItems.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
if (options.filter) {
|
||||||
|
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply tail limit
|
||||||
|
if (options.tail) {
|
||||||
|
const tailCount = parseInt(options.tail, 10)
|
||||||
|
if (!isNaN(tailCount) && tailCount > 0) {
|
||||||
|
timelineItems = timelineItems.slice(-tailCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
// Use curateAgentActivity to format the transcript
|
||||||
|
const transcript = curateAgentActivity(timelineItems)
|
||||||
|
console.log(transcript)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
console.error(`Error: Failed to get logs: ${message}`)
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow mode: stream logs in real-time until interrupted
|
||||||
|
*/
|
||||||
|
async function runFollowMode(
|
||||||
|
client: DaemonClientV2,
|
||||||
|
agentId: string,
|
||||||
|
options: AgentLogsOptions
|
||||||
|
): Promise<void> {
|
||||||
|
// First, get existing timeline
|
||||||
|
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve([]), 10000)
|
||||||
|
|
||||||
|
const unsubscribe = client.on('agent_stream_snapshot', (msg: unknown) => {
|
||||||
|
const message = msg as AgentStreamSnapshotMessage
|
||||||
|
if (message.type !== 'agent_stream_snapshot') return
|
||||||
|
if (message.payload.agentId !== agentId) return
|
||||||
|
|
||||||
|
clearTimeout(timeout)
|
||||||
|
unsubscribe()
|
||||||
|
resolve(extractTimelineFromSnapshot(message))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize agent to trigger timeline snapshot
|
||||||
|
try {
|
||||||
|
await client.initializeAgent(agentId)
|
||||||
|
} catch {
|
||||||
|
// Agent might already be initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing timeline
|
||||||
|
let existingItems = await snapshotPromise
|
||||||
|
|
||||||
|
// Apply filter to existing items
|
||||||
|
if (options.filter) {
|
||||||
|
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply tail to existing items
|
||||||
|
if (options.tail) {
|
||||||
|
const tailCount = parseInt(options.tail, 10)
|
||||||
|
if (!isNaN(tailCount) && tailCount > 0) {
|
||||||
|
existingItems = existingItems.slice(-tailCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print existing transcript
|
||||||
|
const existingTranscript = curateAgentActivity(existingItems)
|
||||||
|
if (existingTranscript !== 'No activity to display.') {
|
||||||
|
console.log(existingTranscript)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to new events
|
||||||
|
console.log('\n--- Following logs (Ctrl+C to stop) ---\n')
|
||||||
|
|
||||||
|
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
|
||||||
|
const message = msg as AgentStreamMessage
|
||||||
|
if (message.type !== 'agent_stream') return
|
||||||
|
if (message.payload.agentId !== agentId) return
|
||||||
|
|
||||||
|
if (message.payload.event.type === 'timeline') {
|
||||||
|
const item = message.payload.event.item
|
||||||
|
// Apply filter
|
||||||
|
if (options.filter && !matchesFilter(item, options.filter)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Print each timeline item as it arrives using the curator format
|
||||||
|
const transcript = curateAgentActivity([item])
|
||||||
|
if (transcript !== 'No activity to display.') {
|
||||||
|
console.log(transcript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for interrupt
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const cleanup = () => {
|
||||||
|
unsubscribe()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', cleanup)
|
||||||
|
process.on('SIGTERM', cleanup)
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { AgentSnapshotPayload } from '@paseo/server'
|
||||||
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Agent list item for display */
|
||||||
|
export interface AgentListItem {
|
||||||
|
id: string
|
||||||
|
shortId: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
status: string
|
||||||
|
cwd: string
|
||||||
|
created: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to get relative time string */
|
||||||
|
function relativeTime(date: Date | string): string {
|
||||||
|
const now = Date.now()
|
||||||
|
const then = new Date(date).getTime()
|
||||||
|
const seconds = Math.floor((now - then) / 1000)
|
||||||
|
|
||||||
|
if (seconds < 60) return 'just now'
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`
|
||||||
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`
|
||||||
|
return `${Math.floor(seconds / 86400)} days ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorten home directory in path */
|
||||||
|
function shortenPath(path: string): string {
|
||||||
|
const home = process.env.HOME
|
||||||
|
if (home && path.startsWith(home)) {
|
||||||
|
return '~' + path.slice(home.length)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for agent ls output */
|
||||||
|
export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||||
|
idField: 'shortId',
|
||||||
|
columns: [
|
||||||
|
{ header: 'AGENT ID', field: 'shortId', width: 12 },
|
||||||
|
{ header: 'NAME', field: 'name', width: 20 },
|
||||||
|
{ header: 'PROVIDER', field: 'provider', width: 15 },
|
||||||
|
{
|
||||||
|
header: 'STATUS',
|
||||||
|
field: 'status',
|
||||||
|
width: 10,
|
||||||
|
color: (value) => {
|
||||||
|
if (value === 'running') return 'green'
|
||||||
|
if (value === 'idle') return 'yellow'
|
||||||
|
if (value === 'error') return 'red'
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ header: 'CWD', field: 'cwd', width: 30 },
|
||||||
|
{ header: 'CREATED', field: 'created', width: 15 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transform agent snapshot to AgentListItem */
|
||||||
|
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||||
|
return {
|
||||||
|
id: agent.id,
|
||||||
|
shortId: agent.id.slice(0, 7),
|
||||||
|
name: agent.title ?? '-',
|
||||||
|
provider: agent.model ? `${agent.provider}/${agent.model}` : agent.provider,
|
||||||
|
status: agent.status,
|
||||||
|
cwd: shortenPath(agent.cwd),
|
||||||
|
created: relativeTime(agent.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentLsResult = ListResult<AgentListItem>
|
||||||
|
|
||||||
|
export interface AgentLsOptions extends CommandOptions {
|
||||||
|
/** -a: Include all statuses (not just running/idle) */
|
||||||
|
all?: boolean
|
||||||
|
/** -g: Show agents globally (not just current directory) */
|
||||||
|
global?: boolean
|
||||||
|
/** Filter by specific status */
|
||||||
|
status?: string
|
||||||
|
/** Filter by specific cwd (overrides default cwd filtering) */
|
||||||
|
cwd?: string
|
||||||
|
/** Filter by labels (key=value format) */
|
||||||
|
label?: string[]
|
||||||
|
/** Filter to UI agents only (equivalent to --label ui=true) */
|
||||||
|
ui?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent ls command with correct semantics from design doc:
|
||||||
|
* - `paseo agent ls` → running/idle agents in current directory
|
||||||
|
* - `paseo agent ls -a` → all statuses in current directory
|
||||||
|
* - `paseo agent ls -g` → running/idle agents globally
|
||||||
|
* - `paseo agent ls -ag` → everything everywhere
|
||||||
|
*/
|
||||||
|
export async function runLsCommand(
|
||||||
|
options: AgentLsOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<AgentLsResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request and wait for session state to get agent information
|
||||||
|
await client.waitForSessionState()
|
||||||
|
|
||||||
|
let agents = client.listAgents()
|
||||||
|
|
||||||
|
// Status filtering:
|
||||||
|
// By default, only show running/idle agents (not error, archived, etc.)
|
||||||
|
// With -a flag, show all statuses
|
||||||
|
if (!options.all) {
|
||||||
|
agents = agents.filter((a) => {
|
||||||
|
// Show running and idle agents, exclude archived
|
||||||
|
return (a.status === 'running' || a.status === 'idle') && !a.archivedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If explicit status filter is provided, use it
|
||||||
|
if (options.status) {
|
||||||
|
agents = agents.filter((a) => a.status === options.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory filtering:
|
||||||
|
// By default, only show agents in current working directory
|
||||||
|
// With -g flag, show agents globally (all directories)
|
||||||
|
if (!options.global) {
|
||||||
|
const currentCwd = options.cwd ?? process.cwd()
|
||||||
|
agents = agents.filter((a) => {
|
||||||
|
// Normalize paths for comparison
|
||||||
|
const agentCwd = a.cwd.replace(/\/$/, '')
|
||||||
|
const targetCwd = currentCwd.replace(/\/$/, '')
|
||||||
|
// Match exact cwd or subdirectories
|
||||||
|
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label filtering:
|
||||||
|
// Parse --label flags and --ui flag
|
||||||
|
// --ui is equivalent to --label ui=true
|
||||||
|
// By default (no --ui flag), show background agents (those WITHOUT ui=true)
|
||||||
|
const labelFilters: Record<string, string> = {}
|
||||||
|
if (options.label) {
|
||||||
|
for (const labelStr of options.label) {
|
||||||
|
const eqIndex = labelStr.indexOf('=')
|
||||||
|
if (eqIndex !== -1) {
|
||||||
|
const key = labelStr.slice(0, eqIndex)
|
||||||
|
const value = labelStr.slice(eqIndex + 1)
|
||||||
|
labelFilters[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add ui=true filter if --ui flag is set
|
||||||
|
if (options.ui) {
|
||||||
|
labelFilters['ui'] = 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply label filtering
|
||||||
|
if (Object.keys(labelFilters).length > 0) {
|
||||||
|
// Filter to agents that have ALL specified labels (AND semantics)
|
||||||
|
agents = agents.filter((a) => {
|
||||||
|
const agentLabels = (a as any).labels as Record<string, string> | undefined
|
||||||
|
for (const [key, value] of Object.entries(labelFilters)) {
|
||||||
|
if (!agentLabels || agentLabels[key] !== value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Default: show background agents only (those without ui=true)
|
||||||
|
agents = agents.filter((a) => {
|
||||||
|
const agentLabels = (a as any).labels as Record<string, string> | undefined
|
||||||
|
return !agentLabels || agentLabels['ui'] !== 'true'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
// Sort agents: running first, then idle, then others; within each group, most recent first
|
||||||
|
const statusOrder = { running: 0, idle: 1 } as Record<string, number>
|
||||||
|
agents.sort((a, b) => {
|
||||||
|
// Primary sort: by status
|
||||||
|
const aOrder = statusOrder[a.status] ?? 999
|
||||||
|
const bOrder = statusOrder[b.status] ?? 999
|
||||||
|
if (aOrder !== bOrder) return aOrder - bOrder
|
||||||
|
|
||||||
|
// Secondary sort: by creation time (most recent first)
|
||||||
|
const aTime = new Date(a.createdAt).getTime()
|
||||||
|
const bTime = new Date(b.createdAt).getTime()
|
||||||
|
return bTime - aTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = agents.map(toListItem)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
data: items,
|
||||||
|
schema: agentLsSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'LIST_AGENTS_FAILED',
|
||||||
|
message: `Failed to list agents: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||||
|
import type {
|
||||||
|
CommandOptions,
|
||||||
|
OutputSchema,
|
||||||
|
CommandError,
|
||||||
|
AnyCommandResult,
|
||||||
|
} from '../../output/index.js'
|
||||||
|
import type { AgentMode } from '@paseo/server'
|
||||||
|
|
||||||
|
/** Result for setting mode */
|
||||||
|
export interface SetModeResult {
|
||||||
|
agentId: string
|
||||||
|
mode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for mode list output */
|
||||||
|
export const modeListSchema: OutputSchema<AgentMode> = {
|
||||||
|
idField: 'id',
|
||||||
|
columns: [
|
||||||
|
{ header: 'MODE', field: 'id', width: 15 },
|
||||||
|
{ header: 'LABEL', field: 'label', width: 25 },
|
||||||
|
{ header: 'DESCRIPTION', field: 'description', width: 40 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for set mode output */
|
||||||
|
export const setModeSchema: OutputSchema<SetModeResult> = {
|
||||||
|
idField: 'agentId',
|
||||||
|
columns: [
|
||||||
|
{ header: 'AGENT ID', field: 'agentId', width: 12 },
|
||||||
|
{ header: 'MODE', field: 'mode', width: 20 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentModeOptions extends CommandOptions {
|
||||||
|
list?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type AgentModeResult = AnyCommandResult<any>
|
||||||
|
|
||||||
|
export async function runModeCommand(
|
||||||
|
id: string,
|
||||||
|
mode: string | undefined,
|
||||||
|
options: AgentModeOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<AgentModeResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||||
|
|
||||||
|
// Validate arguments
|
||||||
|
if (!options.list && !mode) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MISSING_ARGUMENT',
|
||||||
|
message: 'Mode argument required unless --list is specified',
|
||||||
|
details: 'Usage: paseo agent mode <id> <mode> | paseo agent mode --list <id>',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait a moment for the session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
|
||||||
|
// Resolve agent ID
|
||||||
|
const resolvedId = resolveAgentId(id, agents)
|
||||||
|
if (!resolvedId) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `No agent found matching: ${id}`,
|
||||||
|
details: 'Use `paseo ls` to list available agents',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = agents.find((a) => a.id === resolvedId)
|
||||||
|
if (!agent) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found after resolution: ${resolvedId}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.list) {
|
||||||
|
// List available modes for this agent
|
||||||
|
const availableModes = agent.availableModes ?? []
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
const items: AgentMode[] = availableModes.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
label: m.label,
|
||||||
|
description: m.description,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
data: items,
|
||||||
|
schema: modeListSchema,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Set the agent mode
|
||||||
|
await client.setAgentMode(resolvedId, mode!)
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
agentId: resolvedId.slice(0, 7),
|
||||||
|
mode: mode!,
|
||||||
|
},
|
||||||
|
schema: setModeSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
// Re-throw if it's already a CommandError
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MODE_OPERATION_FAILED',
|
||||||
|
message: `Failed to ${options.list ? 'list modes' : 'set mode'}: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { AgentSnapshotPayload } from '@paseo/server'
|
||||||
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { lookup } from 'mime-types'
|
||||||
|
|
||||||
|
/** Result type for agent run command */
|
||||||
|
export interface AgentRunResult {
|
||||||
|
agentId: string
|
||||||
|
status: 'created' | 'running'
|
||||||
|
provider: string
|
||||||
|
cwd: string
|
||||||
|
title: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for agent run output */
|
||||||
|
export const agentRunSchema: OutputSchema<AgentRunResult> = {
|
||||||
|
idField: 'agentId',
|
||||||
|
columns: [
|
||||||
|
{ header: 'AGENT ID', field: 'agentId', width: 12 },
|
||||||
|
{ header: 'STATUS', field: 'status', width: 10 },
|
||||||
|
{ header: 'PROVIDER', field: 'provider', width: 10 },
|
||||||
|
{ header: 'CWD', field: 'cwd', width: 30 },
|
||||||
|
{ header: 'TITLE', field: 'title', width: 20 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentRunOptions extends CommandOptions {
|
||||||
|
detach?: boolean
|
||||||
|
name?: string
|
||||||
|
provider?: string
|
||||||
|
model?: string
|
||||||
|
mode?: string
|
||||||
|
worktree?: string
|
||||||
|
base?: string
|
||||||
|
image?: string[]
|
||||||
|
cwd?: string
|
||||||
|
label?: string[]
|
||||||
|
ui?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRunResult(agent: AgentSnapshotPayload): AgentRunResult {
|
||||||
|
return {
|
||||||
|
agentId: agent.id,
|
||||||
|
status: agent.status === 'running' ? 'running' : 'created',
|
||||||
|
provider: agent.provider,
|
||||||
|
cwd: agent.cwd,
|
||||||
|
title: agent.title,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRunCommand(
|
||||||
|
prompt: string,
|
||||||
|
options: AgentRunOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<SingleResult<AgentRunResult>> {
|
||||||
|
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||||
|
|
||||||
|
// Validate prompt is provided
|
||||||
|
if (!prompt || prompt.trim().length === 0) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MISSING_PROMPT',
|
||||||
|
message: 'A prompt is required',
|
||||||
|
details: 'Usage: paseo agent run [options] <prompt>',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate --base is only used with --worktree
|
||||||
|
if (options.base && !options.worktree) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'INVALID_OPTIONS',
|
||||||
|
message: '--base can only be used with --worktree',
|
||||||
|
details: 'Usage: paseo agent run --worktree <name> --base <branch> <prompt>',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve working directory
|
||||||
|
const cwd = options.cwd ?? process.cwd()
|
||||||
|
|
||||||
|
// Process images if provided
|
||||||
|
let images: Array<{ data: string; mimeType: string }> | undefined
|
||||||
|
if (options.image && options.image.length > 0) {
|
||||||
|
images = options.image.map((imagePath) => {
|
||||||
|
const resolvedPath = resolve(imagePath)
|
||||||
|
try {
|
||||||
|
const imageData = readFileSync(resolvedPath)
|
||||||
|
const mimeType = lookup(resolvedPath) || 'application/octet-stream'
|
||||||
|
|
||||||
|
// Verify it's an image MIME type
|
||||||
|
if (!mimeType.startsWith('image/')) {
|
||||||
|
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: imageData.toString('base64'),
|
||||||
|
mimeType,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
throw new Error(`Failed to read image ${imagePath}: ${message}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build git options if worktree is specified
|
||||||
|
const git = options.worktree
|
||||||
|
? {
|
||||||
|
createWorktree: true,
|
||||||
|
worktreeSlug: options.worktree,
|
||||||
|
baseBranch: options.base,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Build labels from --label and --ui flags
|
||||||
|
// --ui is syntactic sugar for --label ui=true
|
||||||
|
// If explicit --label ui=... is provided, it takes precedence over --ui
|
||||||
|
const labels: Record<string, string> = {}
|
||||||
|
if (options.label) {
|
||||||
|
for (const labelStr of options.label) {
|
||||||
|
const eqIndex = labelStr.indexOf('=')
|
||||||
|
if (eqIndex === -1) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'INVALID_LABEL',
|
||||||
|
message: `Invalid label format: ${labelStr}`,
|
||||||
|
details: 'Labels must be in key=value format',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const key = labelStr.slice(0, eqIndex)
|
||||||
|
const value = labelStr.slice(eqIndex + 1)
|
||||||
|
labels[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add ui=true if --ui flag is set and ui label not already set
|
||||||
|
if (options.ui && !('ui' in labels)) {
|
||||||
|
labels['ui'] = 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the agent
|
||||||
|
const agent = await client.createAgent({
|
||||||
|
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
|
||||||
|
cwd,
|
||||||
|
title: options.name,
|
||||||
|
modeId: options.mode,
|
||||||
|
model: options.model,
|
||||||
|
initialPrompt: prompt,
|
||||||
|
images,
|
||||||
|
git,
|
||||||
|
worktreeName: options.worktree,
|
||||||
|
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: toRunResult(agent),
|
||||||
|
schema: agentRunSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_CREATE_FAILED',
|
||||||
|
message: `Failed to create agent: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { AgentSnapshotPayload } from '@paseo/server'
|
||||||
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { extname } from 'node:path'
|
||||||
|
|
||||||
|
/** Result type for agent send command */
|
||||||
|
export interface AgentSendResult {
|
||||||
|
agentId: string
|
||||||
|
status: 'sent' | 'completed'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for agent send output */
|
||||||
|
export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
||||||
|
idField: 'agentId',
|
||||||
|
columns: [
|
||||||
|
{ header: 'AGENT ID', field: 'agentId', width: 12 },
|
||||||
|
{ header: 'STATUS', field: 'status', width: 12 },
|
||||||
|
{ header: 'MESSAGE', field: 'message', width: 40 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSendOptions extends CommandOptions {
|
||||||
|
noWait?: boolean
|
||||||
|
image?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve agent ID from prefix or full ID.
|
||||||
|
* Supports exact match and prefix matching.
|
||||||
|
*/
|
||||||
|
function resolveAgentId(agents: AgentSnapshotPayload[], idOrPrefix: string): string | null {
|
||||||
|
// Exact match first
|
||||||
|
const exact = agents.find((a) => a.id === idOrPrefix)
|
||||||
|
if (exact) return exact.id
|
||||||
|
|
||||||
|
// Prefix match
|
||||||
|
const matches = agents.filter((a) => a.id.startsWith(idOrPrefix))
|
||||||
|
if (matches.length === 1 && matches[0]) return matches[0].id
|
||||||
|
if (matches.length > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Ambiguous ID prefix '${idOrPrefix}': matches ${matches.length} agents (${matches.map((a) => a.id.slice(0, 7)).join(', ')})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read image files and convert them to base64 data URIs
|
||||||
|
*/
|
||||||
|
async function readImageFiles(imagePaths: string[]): Promise<Array<{ data: string; mimeType: string }>> {
|
||||||
|
const images: Array<{ data: string; mimeType: string }> = []
|
||||||
|
|
||||||
|
for (const path of imagePaths) {
|
||||||
|
try {
|
||||||
|
const buffer = await readFile(path)
|
||||||
|
const ext = extname(path).toLowerCase()
|
||||||
|
|
||||||
|
// Determine media type from extension
|
||||||
|
let mimeType = 'image/jpeg'
|
||||||
|
switch (ext) {
|
||||||
|
case '.png':
|
||||||
|
mimeType = 'image/png'
|
||||||
|
break
|
||||||
|
case '.jpg':
|
||||||
|
case '.jpeg':
|
||||||
|
mimeType = 'image/jpeg'
|
||||||
|
break
|
||||||
|
case '.gif':
|
||||||
|
mimeType = 'image/gif'
|
||||||
|
break
|
||||||
|
case '.webp':
|
||||||
|
mimeType = 'image/webp'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// Default to jpeg for unknown types
|
||||||
|
mimeType = 'image/jpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = buffer.toString('base64')
|
||||||
|
images.push({
|
||||||
|
data,
|
||||||
|
mimeType,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'IMAGE_READ_ERROR',
|
||||||
|
message: `Failed to read image file: ${path}`,
|
||||||
|
details: message,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSendCommand(
|
||||||
|
agentIdArg: string,
|
||||||
|
prompt: string,
|
||||||
|
options: AgentSendOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<SingleResult<AgentSendResult>> {
|
||||||
|
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||||
|
|
||||||
|
// Validate arguments
|
||||||
|
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MISSING_AGENT_ID',
|
||||||
|
message: 'Agent ID is required',
|
||||||
|
details: 'Usage: paseo agent send [options] <id> <prompt>',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prompt || prompt.trim().length === 0) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MISSING_PROMPT',
|
||||||
|
message: 'A prompt is required',
|
||||||
|
details: 'Usage: paseo agent send [options] <id> <prompt>',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait a moment for the session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
|
||||||
|
// Resolve agent ID (supports prefix matching)
|
||||||
|
const agentId = resolveAgentId(agents, agentIdArg)
|
||||||
|
if (!agentId) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found: ${agentIdArg}`,
|
||||||
|
details: 'Use "paseo ls" to list available agents',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read image files if provided
|
||||||
|
const images = options.image && options.image.length > 0
|
||||||
|
? await readImageFiles(options.image)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Send the message
|
||||||
|
await client.sendAgentMessage(agentId, prompt, { images })
|
||||||
|
|
||||||
|
// If --no-wait, return immediately
|
||||||
|
if (options.noWait) {
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
agentId,
|
||||||
|
status: 'sent',
|
||||||
|
message: 'Message sent, not waiting for completion',
|
||||||
|
},
|
||||||
|
schema: agentSendSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for agent to become idle
|
||||||
|
await client.waitForAgentIdle(agentId, 600000) // 10 minute timeout
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
agentId,
|
||||||
|
status: 'completed',
|
||||||
|
message: 'Agent completed processing the message',
|
||||||
|
},
|
||||||
|
schema: agentSendSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
|
||||||
|
// Re-throw CommandError as-is
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'SEND_FAILED',
|
||||||
|
message: `Failed to send message: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Result type for agent stop command */
|
||||||
|
export interface StopResult {
|
||||||
|
stoppedCount: number
|
||||||
|
agentIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for stop command output */
|
||||||
|
export const stopSchema: OutputSchema<StopResult> = {
|
||||||
|
// For quiet mode, output the stopped agent IDs (one per line)
|
||||||
|
idField: (item) => item.agentIds.join('\n'),
|
||||||
|
columns: [{ header: 'STOPPED', field: 'stoppedCount' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentStopOptions extends CommandOptions {
|
||||||
|
all?: boolean
|
||||||
|
cwd?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentStopResult = SingleResult<StopResult>
|
||||||
|
|
||||||
|
export async function runStopCommand(
|
||||||
|
id: string | undefined,
|
||||||
|
options: AgentStopOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<AgentStopResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||||
|
|
||||||
|
// Validate arguments - need either an id, --all, or --cwd
|
||||||
|
if (!id && !options.all && !options.cwd) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MISSING_ARGUMENT',
|
||||||
|
message: 'Agent ID required unless --all or --cwd is specified',
|
||||||
|
details: 'Usage: paseo agent stop <id> | --all | --cwd <path>',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait a moment for the session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
let agents = client.listAgents()
|
||||||
|
const stoppedIds: string[] = []
|
||||||
|
|
||||||
|
if (options.all) {
|
||||||
|
// Stop all agents (not archived)
|
||||||
|
agents = agents.filter((a) => !a.archivedAt)
|
||||||
|
} else if (options.cwd) {
|
||||||
|
// Stop agents in directory
|
||||||
|
const filterCwd = options.cwd
|
||||||
|
agents = agents.filter((a) => {
|
||||||
|
if (a.archivedAt) return false
|
||||||
|
const agentCwd = a.cwd.replace(/\/$/, '')
|
||||||
|
const targetCwd = filterCwd.replace(/\/$/, '')
|
||||||
|
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
|
||||||
|
})
|
||||||
|
} else if (id) {
|
||||||
|
// Stop specific agent
|
||||||
|
const resolvedId = resolveAgentId(id, agents)
|
||||||
|
if (!resolvedId) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `No agent found matching: ${id}`,
|
||||||
|
details: 'Use `paseo ls` to list available agents',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
agents = agents.filter((a) => a.id === resolvedId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop each agent
|
||||||
|
for (const agent of agents) {
|
||||||
|
try {
|
||||||
|
// Cancel if running
|
||||||
|
if (agent.status === 'running') {
|
||||||
|
await client.cancelAgent(agent.id)
|
||||||
|
}
|
||||||
|
// Delete the agent
|
||||||
|
await client.deleteAgent(agent.id)
|
||||||
|
stoppedIds.push(agent.id)
|
||||||
|
} catch (err) {
|
||||||
|
// Continue stopping other agents even if one fails
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
stoppedCount: stoppedIds.length,
|
||||||
|
agentIds: stoppedIds,
|
||||||
|
},
|
||||||
|
schema: stopSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
// Re-throw if it's already a CommandError
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'STOP_AGENT_FAILED',
|
||||||
|
message: `Failed to stop agent(s): ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Result type for agent wait command */
|
||||||
|
export interface AgentWaitResult {
|
||||||
|
agentId: string
|
||||||
|
status: 'idle' | 'timeout' | 'permission'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for agent wait output */
|
||||||
|
export const agentWaitSchema: OutputSchema<AgentWaitResult> = {
|
||||||
|
idField: 'agentId',
|
||||||
|
columns: [
|
||||||
|
{ header: 'AGENT ID', field: 'agentId', width: 12 },
|
||||||
|
{ header: 'STATUS', field: 'status', width: 12 },
|
||||||
|
{ header: 'MESSAGE', field: 'message', width: 40 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentWaitOptions extends CommandOptions {
|
||||||
|
timeout?: string
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse duration string to milliseconds.
|
||||||
|
* Supports formats like: 5m, 30s, 1h, 2h30m, 90, etc.
|
||||||
|
* If no unit is specified, assumes seconds.
|
||||||
|
*/
|
||||||
|
function parseDuration(input: string): number {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
|
||||||
|
// If it's just a number, treat as seconds
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
return parseInt(trimmed, 10) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse duration with units
|
||||||
|
let totalMs = 0
|
||||||
|
const regex = /(\d+)([smh])/g
|
||||||
|
let match
|
||||||
|
let hasMatch = false
|
||||||
|
|
||||||
|
while ((match = regex.exec(trimmed)) !== null) {
|
||||||
|
hasMatch = true
|
||||||
|
const value = parseInt(match[1], 10)
|
||||||
|
const unit = match[2]
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 's':
|
||||||
|
totalMs += value * 1000
|
||||||
|
break
|
||||||
|
case 'm':
|
||||||
|
totalMs += value * 60 * 1000
|
||||||
|
break
|
||||||
|
case 'h':
|
||||||
|
totalMs += value * 60 * 60 * 1000
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMatch) {
|
||||||
|
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalMs
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runWaitCommand(
|
||||||
|
agentIdArg: string,
|
||||||
|
options: AgentWaitOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<SingleResult<AgentWaitResult>> {
|
||||||
|
const host = getDaemonHost({ host: options.host as string | undefined })
|
||||||
|
|
||||||
|
// Validate arguments
|
||||||
|
if (!agentIdArg || agentIdArg.trim().length === 0) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MISSING_AGENT_ID',
|
||||||
|
message: 'Agent ID is required',
|
||||||
|
details: 'Usage: paseo agent wait <id>',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timeout (default 10 minutes)
|
||||||
|
let timeoutMs: number
|
||||||
|
if (options.timeout) {
|
||||||
|
try {
|
||||||
|
timeoutMs = parseDuration(options.timeout)
|
||||||
|
if (timeoutMs <= 0) {
|
||||||
|
throw new Error('Timeout must be positive')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'INVALID_TIMEOUT',
|
||||||
|
message: 'Invalid timeout value',
|
||||||
|
details: message,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timeoutMs = 10 * 60 * 1000 // default 10 minutes
|
||||||
|
}
|
||||||
|
const timeoutSeconds = Math.floor(timeoutMs / 1000)
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait a moment for the session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
|
||||||
|
// Resolve agent ID (supports prefix matching)
|
||||||
|
const agentId = resolveAgentId(agentIdArg, agents)
|
||||||
|
if (!agentId) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found: ${agentIdArg}`,
|
||||||
|
details: 'Use "paseo ls" to list available agents',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for agent to become idle OR request permission (whichever comes first)
|
||||||
|
try {
|
||||||
|
const idlePromise = client.waitForAgentIdle(agentId, timeoutMs).then(() => ({ type: 'idle' as const }))
|
||||||
|
const permissionPromise = client
|
||||||
|
.waitForPermission(agentId, timeoutMs)
|
||||||
|
.then((request) => ({ type: 'permission' as const, request }))
|
||||||
|
|
||||||
|
const result = await Promise.race([idlePromise, permissionPromise])
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
if (result.type === 'permission') {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
agentId,
|
||||||
|
status: 'permission',
|
||||||
|
message: `Agent is waiting for permission: ${result.request.kind}`,
|
||||||
|
},
|
||||||
|
schema: agentWaitSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
agentId,
|
||||||
|
status: 'idle',
|
||||||
|
message: 'Agent is now idle',
|
||||||
|
},
|
||||||
|
schema: agentWaitSchema,
|
||||||
|
}
|
||||||
|
} catch (waitErr) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
|
||||||
|
const waitMessage = waitErr instanceof Error ? waitErr.message : String(waitErr)
|
||||||
|
|
||||||
|
// Check if it's a timeout error
|
||||||
|
if (waitMessage.toLowerCase().includes('timeout')) {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
agentId,
|
||||||
|
status: 'timeout',
|
||||||
|
message: `Timed out waiting for agent after ${timeoutSeconds} seconds`,
|
||||||
|
},
|
||||||
|
schema: agentWaitSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other errors
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'WAIT_FAILED',
|
||||||
|
message: `Failed to wait for agent: ${waitMessage}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
|
||||||
|
// Re-throw CommandError as-is
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'WAIT_FAILED',
|
||||||
|
message: `Failed to wait for agent: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Command } from 'commander'
|
||||||
|
import { startCommand } from './start.js'
|
||||||
|
import { runStatusCommand } from './status.js'
|
||||||
|
import { runStopCommand } from './stop.js'
|
||||||
|
import { runRestartCommand } from './restart.js'
|
||||||
|
import { withOutput } from '../../output/index.js'
|
||||||
|
|
||||||
|
export function createDaemonCommand(): Command {
|
||||||
|
const daemon = new Command('daemon').description('Manage the Paseo daemon')
|
||||||
|
|
||||||
|
daemon.addCommand(startCommand())
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command('status')
|
||||||
|
.description('Show daemon status')
|
||||||
|
.option('--json', 'Output in JSON format')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action((options, command) => {
|
||||||
|
if (options.json) {
|
||||||
|
command.parent.parent.opts().format = 'json'
|
||||||
|
}
|
||||||
|
return withOutput(runStatusCommand)(options, command)
|
||||||
|
})
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command('stop')
|
||||||
|
.description('Stop the daemon')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runStopCommand))
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.command('restart')
|
||||||
|
.description('Restart the daemon')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runRestartCommand))
|
||||||
|
|
||||||
|
return daemon
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Result of restart command */
|
||||||
|
interface RestartResult {
|
||||||
|
action: 'restarted' | 'not_running'
|
||||||
|
host: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for restart result */
|
||||||
|
const restartResultSchema: OutputSchema<RestartResult> = {
|
||||||
|
idField: 'action',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: 'STATUS',
|
||||||
|
field: 'action',
|
||||||
|
color: (value) => (value === 'restarted' ? 'green' : 'red'),
|
||||||
|
},
|
||||||
|
{ header: 'HOST', field: 'host' },
|
||||||
|
{ header: 'MESSAGE', field: 'message' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RestartCommandResult = SingleResult<RestartResult>
|
||||||
|
|
||||||
|
export async function runRestartCommand(
|
||||||
|
options: CommandOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<RestartCommandResult> {
|
||||||
|
const connectOptions = { host: options.host as string | undefined }
|
||||||
|
const host = getDaemonHost(connectOptions)
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon(connectOptions)
|
||||||
|
} catch {
|
||||||
|
// Daemon not running - cannot restart
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Daemon is not running (tried to connect to ${host})`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request server restart
|
||||||
|
await client.restartServer('cli_restart')
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
action: 'restarted',
|
||||||
|
host,
|
||||||
|
message: 'Daemon restart requested',
|
||||||
|
},
|
||||||
|
schema: restartResultSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
||||||
|
// If connection was closed, the daemon is restarting
|
||||||
|
if (message.includes('closed') || message.includes('disconnected')) {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
action: 'restarted',
|
||||||
|
host,
|
||||||
|
message: 'Daemon is restarting',
|
||||||
|
},
|
||||||
|
schema: restartResultSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'RESTART_FAILED',
|
||||||
|
message: `Failed to restart daemon: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Command } from 'commander'
|
||||||
|
import chalk from 'chalk'
|
||||||
|
import {
|
||||||
|
createPaseoDaemon,
|
||||||
|
loadConfig,
|
||||||
|
resolvePaseoHome,
|
||||||
|
createRootLogger,
|
||||||
|
loadPersistedConfig,
|
||||||
|
} from '@paseo/server'
|
||||||
|
|
||||||
|
interface StartOptions {
|
||||||
|
port?: string
|
||||||
|
home?: string
|
||||||
|
foreground?: boolean
|
||||||
|
noRelay?: boolean
|
||||||
|
allowedHosts?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCommand(): Command {
|
||||||
|
return new Command('start')
|
||||||
|
.description('Start the Paseo daemon')
|
||||||
|
.option('--port <port>', 'Port to listen on (default: 6767)')
|
||||||
|
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||||
|
.option('--foreground', 'Run in foreground (don\'t daemonize)')
|
||||||
|
.option('--no-relay', 'Disable relay connection')
|
||||||
|
.option('--allowed-hosts <hosts>', 'Comma-separated list of allowed MCP hosts (e.g., "localhost:6767,127.0.0.1:6767")')
|
||||||
|
.action(async (options: StartOptions) => {
|
||||||
|
await runStart(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStart(options: StartOptions): Promise<void> {
|
||||||
|
// Set environment variables based on CLI options
|
||||||
|
if (options.home) {
|
||||||
|
process.env.PASEO_HOME = options.home
|
||||||
|
}
|
||||||
|
if (options.port) {
|
||||||
|
process.env.PASEO_LISTEN = `127.0.0.1:${options.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const paseoHome = resolvePaseoHome()
|
||||||
|
const persistedConfig = loadPersistedConfig(paseoHome)
|
||||||
|
const logger = createRootLogger(persistedConfig)
|
||||||
|
const config = loadConfig(paseoHome)
|
||||||
|
|
||||||
|
// Apply CLI overrides
|
||||||
|
if (options.noRelay) {
|
||||||
|
config.relayEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.allowedHosts) {
|
||||||
|
config.agentMcpAllowedHosts = options.allowedHosts.split(',').map(h => h.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, only foreground mode is supported
|
||||||
|
// TODO: Implement daemonization in a future phase
|
||||||
|
if (!options.foreground) {
|
||||||
|
console.log(chalk.yellow('Note: Background daemon mode not yet implemented. Running in foreground.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const daemon = await createPaseoDaemon(config, logger)
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
let shuttingDown = false
|
||||||
|
const handleShutdown = async (signal: string) => {
|
||||||
|
if (shuttingDown) {
|
||||||
|
logger.info('Forcing exit...')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
shuttingDown = true
|
||||||
|
logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`)
|
||||||
|
|
||||||
|
const forceExit = setTimeout(() => {
|
||||||
|
logger.warn('Forcing shutdown - HTTP server didn\'t close in time')
|
||||||
|
process.exit(1)
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await daemon.stop()
|
||||||
|
clearTimeout(forceExit)
|
||||||
|
logger.info('Server closed')
|
||||||
|
process.exit(0)
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(forceExit)
|
||||||
|
logger.error({ err }, 'Shutdown failed')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => handleShutdown('SIGTERM'))
|
||||||
|
process.on('SIGINT', () => handleShutdown('SIGINT'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await daemon.start()
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
console.error(chalk.red(`Failed to start daemon: ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import { resolvePaseoHome } from '@paseo/server'
|
||||||
|
import { tryConnectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Status data for the daemon */
|
||||||
|
interface DaemonStatus {
|
||||||
|
status: 'running' | 'stopped'
|
||||||
|
host: string
|
||||||
|
home: string
|
||||||
|
runningAgents: number
|
||||||
|
idleAgents: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key-value row for table display */
|
||||||
|
interface StatusRow {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for key-value display with custom serialization for JSON/YAML */
|
||||||
|
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
|
||||||
|
return {
|
||||||
|
idField: 'key',
|
||||||
|
columns: [
|
||||||
|
{ header: 'KEY', field: 'key' },
|
||||||
|
{
|
||||||
|
header: 'VALUE',
|
||||||
|
field: 'value',
|
||||||
|
color: (_, item) => {
|
||||||
|
if (item.key === 'Status') {
|
||||||
|
return item.value === 'running' ? 'green' : 'red'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// For JSON/YAML, return the structured status object (not key-value rows)
|
||||||
|
// The serializer receives each item, but we want the whole object
|
||||||
|
// So we return null for individual items and handle it at the result level
|
||||||
|
serialize: (_item) => status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert status to key-value rows for table display */
|
||||||
|
function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||||
|
return [
|
||||||
|
{ key: 'Status', value: status.status },
|
||||||
|
{ key: 'Host', value: status.host },
|
||||||
|
{ key: 'Home', value: status.home },
|
||||||
|
{ key: 'Agents', value: `${status.runningAgents} running, ${status.idleAgents} idle` },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatusResult = ListResult<StatusRow>
|
||||||
|
|
||||||
|
export async function runStatusCommand(
|
||||||
|
options: CommandOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<StatusResult> {
|
||||||
|
const connectOptions = { host: options.host as string | undefined }
|
||||||
|
const host = getDaemonHost(connectOptions)
|
||||||
|
const client = await tryConnectToDaemon(connectOptions)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Daemon is not running (tried to connect to ${host})`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait a moment for the session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
const runningAgents = agents.filter((a) => a.status === 'running')
|
||||||
|
const idleAgents = agents.filter((a) => a.status === 'idle')
|
||||||
|
|
||||||
|
// Get paseo home for display
|
||||||
|
const paseoHome = resolvePaseoHome()
|
||||||
|
|
||||||
|
const status: DaemonStatus = {
|
||||||
|
status: 'running',
|
||||||
|
host,
|
||||||
|
home: paseoHome,
|
||||||
|
runningAgents: runningAgents.length,
|
||||||
|
idleAgents: idleAgents.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
data: toStatusRows(status),
|
||||||
|
schema: createStatusSchema(status),
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'STATUS_FAILED',
|
||||||
|
message: `Failed to get status: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Result of stop command */
|
||||||
|
interface StopResult {
|
||||||
|
action: 'stopped' | 'not_running'
|
||||||
|
host: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for stop result */
|
||||||
|
const stopResultSchema: OutputSchema<StopResult> = {
|
||||||
|
idField: 'action',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: 'STATUS',
|
||||||
|
field: 'action',
|
||||||
|
color: (value) => (value === 'stopped' ? 'green' : 'yellow'),
|
||||||
|
},
|
||||||
|
{ header: 'HOST', field: 'host' },
|
||||||
|
{ header: 'MESSAGE', field: 'message' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StopCommandResult = SingleResult<StopResult>
|
||||||
|
|
||||||
|
export async function runStopCommand(
|
||||||
|
options: CommandOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<StopCommandResult> {
|
||||||
|
const connectOptions = { host: options.host as string | undefined }
|
||||||
|
const host = getDaemonHost(connectOptions)
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon(connectOptions)
|
||||||
|
} catch {
|
||||||
|
// Daemon not running - this is a valid outcome
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
action: 'not_running',
|
||||||
|
host,
|
||||||
|
message: 'Daemon was not running',
|
||||||
|
},
|
||||||
|
schema: stopResultSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request server restart with "shutdown" reason
|
||||||
|
// This signals the daemon to shut down gracefully
|
||||||
|
await client.restartServer('cli_shutdown')
|
||||||
|
|
||||||
|
// Give the daemon a moment to acknowledge
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
action: 'stopped',
|
||||||
|
host,
|
||||||
|
message: 'Daemon stop requested - shutting down gracefully',
|
||||||
|
},
|
||||||
|
schema: stopResultSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
||||||
|
// If connection was closed, the daemon is stopping
|
||||||
|
if (message.includes('closed') || message.includes('disconnected')) {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
action: 'stopped',
|
||||||
|
host,
|
||||||
|
message: 'Daemon is stopping',
|
||||||
|
},
|
||||||
|
schema: stopResultSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'STOP_FAILED',
|
||||||
|
message: `Failed to stop daemon: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { AgentPermissionRequest } from '@paseo/server'
|
||||||
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Permission response item for display */
|
||||||
|
export interface PermissionResponseItem {
|
||||||
|
requestId: string
|
||||||
|
agentId: string
|
||||||
|
agentShortId: string
|
||||||
|
name: string
|
||||||
|
result: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for permit allow/deny output */
|
||||||
|
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
|
||||||
|
idField: 'requestId',
|
||||||
|
columns: [
|
||||||
|
{ header: 'REQUEST ID', field: 'requestId', width: 12 },
|
||||||
|
{ header: 'AGENT', field: 'agentShortId', width: 10 },
|
||||||
|
{ header: 'TOOL', field: 'name', width: 20 },
|
||||||
|
{
|
||||||
|
header: 'RESULT',
|
||||||
|
field: 'result',
|
||||||
|
width: 10,
|
||||||
|
color: (value) => {
|
||||||
|
if (value === 'allowed') return 'green'
|
||||||
|
if (value === 'denied') return 'red'
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermitAllowResult = ListResult<PermissionResponseItem>
|
||||||
|
|
||||||
|
export interface PermitAllowOptions extends CommandOptions {
|
||||||
|
all?: boolean
|
||||||
|
input?: string
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAllowCommand(
|
||||||
|
agentIdOrPrefix: string,
|
||||||
|
reqId: string | undefined,
|
||||||
|
options: PermitAllowOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<PermitAllowResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host })
|
||||||
|
|
||||||
|
// No validation needed - if no reqId provided, allow all by default
|
||||||
|
|
||||||
|
// Parse input JSON if provided
|
||||||
|
let updatedInput: Record<string, unknown> | undefined
|
||||||
|
if (options.input) {
|
||||||
|
try {
|
||||||
|
updatedInput = JSON.parse(options.input)
|
||||||
|
} catch (err) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'INVALID_JSON',
|
||||||
|
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
|
||||||
|
// Resolve agent ID
|
||||||
|
const resolvedAgentId = resolveAgentId(agentIdOrPrefix, agents)
|
||||||
|
if (!resolvedAgentId) {
|
||||||
|
await client.close()
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found: ${agentIdOrPrefix}`,
|
||||||
|
details: 'Use "paseo ls" to list available agents',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the agent
|
||||||
|
const agent = agents.find((a) => a.id === resolvedAgentId)
|
||||||
|
if (!agent) {
|
||||||
|
await client.close()
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found: ${resolvedAgentId}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending permissions for this agent
|
||||||
|
const pendingPermissions = agent.pendingPermissions || []
|
||||||
|
if (pendingPermissions.length === 0) {
|
||||||
|
await client.close()
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'NO_PENDING_PERMISSIONS',
|
||||||
|
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which permissions to allow
|
||||||
|
let permissionsToAllow: AgentPermissionRequest[]
|
||||||
|
if (!reqId || options.all) {
|
||||||
|
// Default: allow all pending permissions if no req_id specified
|
||||||
|
// --all flag is kept as an explicit alias for clarity
|
||||||
|
permissionsToAllow = pendingPermissions
|
||||||
|
} else {
|
||||||
|
// Find permission by ID prefix
|
||||||
|
const permission = pendingPermissions.find(
|
||||||
|
(p) => p.id === reqId || p.id.startsWith(reqId!)
|
||||||
|
)
|
||||||
|
if (!permission) {
|
||||||
|
await client.close()
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'PERMISSION_NOT_FOUND',
|
||||||
|
message: `Permission request not found: ${reqId}`,
|
||||||
|
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
permissionsToAllow = [permission]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow permissions
|
||||||
|
const results: PermissionResponseItem[] = []
|
||||||
|
for (const permission of permissionsToAllow) {
|
||||||
|
await client.respondToPermission(resolvedAgentId, permission.id, {
|
||||||
|
behavior: 'allow',
|
||||||
|
...(updatedInput ? { updatedInput } : {}),
|
||||||
|
})
|
||||||
|
results.push({
|
||||||
|
requestId: permission.id.slice(0, 8),
|
||||||
|
agentId: resolvedAgentId,
|
||||||
|
agentShortId: resolvedAgentId.slice(0, 7),
|
||||||
|
name: permission.name,
|
||||||
|
result: 'allowed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
data: results,
|
||||||
|
schema: permitResponseSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
// Re-throw CommandErrors
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'ALLOW_PERMISSION_FAILED',
|
||||||
|
message: `Failed to allow permission: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { AgentPermissionRequest } from '@paseo/server'
|
||||||
|
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, ListResult, CommandError } from '../../output/index.js'
|
||||||
|
import { permitResponseSchema, type PermissionResponseItem } from './allow.js'
|
||||||
|
|
||||||
|
export type PermitDenyResult = ListResult<PermissionResponseItem>
|
||||||
|
|
||||||
|
export interface PermitDenyOptions extends CommandOptions {
|
||||||
|
all?: boolean
|
||||||
|
message?: string
|
||||||
|
interrupt?: boolean
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDenyCommand(
|
||||||
|
agentIdOrPrefix: string,
|
||||||
|
reqId: string | undefined,
|
||||||
|
options: PermitDenyOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<PermitDenyResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host })
|
||||||
|
|
||||||
|
// Validate arguments
|
||||||
|
if (!options.all && !reqId) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MISSING_ARGUMENT',
|
||||||
|
message: 'Request ID is required unless --all is specified',
|
||||||
|
details: 'Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
|
||||||
|
// Resolve agent ID
|
||||||
|
const resolvedAgentId = resolveAgentId(agentIdOrPrefix, agents)
|
||||||
|
if (!resolvedAgentId) {
|
||||||
|
await client.close()
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found: ${agentIdOrPrefix}`,
|
||||||
|
details: 'Use "paseo ls" to list available agents',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the agent
|
||||||
|
const agent = agents.find((a) => a.id === resolvedAgentId)
|
||||||
|
if (!agent) {
|
||||||
|
await client.close()
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'AGENT_NOT_FOUND',
|
||||||
|
message: `Agent not found: ${resolvedAgentId}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending permissions for this agent
|
||||||
|
const pendingPermissions = agent.pendingPermissions || []
|
||||||
|
if (pendingPermissions.length === 0) {
|
||||||
|
await client.close()
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'NO_PENDING_PERMISSIONS',
|
||||||
|
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which permissions to deny
|
||||||
|
let permissionsToDeny: AgentPermissionRequest[]
|
||||||
|
if (options.all) {
|
||||||
|
permissionsToDeny = pendingPermissions
|
||||||
|
} else {
|
||||||
|
// Find permission by ID prefix
|
||||||
|
const permission = pendingPermissions.find(
|
||||||
|
(p) => p.id === reqId || p.id.startsWith(reqId!)
|
||||||
|
)
|
||||||
|
if (!permission) {
|
||||||
|
await client.close()
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'PERMISSION_NOT_FOUND',
|
||||||
|
message: `Permission request not found: ${reqId}`,
|
||||||
|
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
permissionsToDeny = [permission]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny permissions
|
||||||
|
const results: PermissionResponseItem[] = []
|
||||||
|
for (const permission of permissionsToDeny) {
|
||||||
|
await client.respondToPermission(resolvedAgentId, permission.id, {
|
||||||
|
behavior: 'deny',
|
||||||
|
...(options.message ? { message: options.message } : {}),
|
||||||
|
...(options.interrupt ? { interrupt: true } : {}),
|
||||||
|
})
|
||||||
|
results.push({
|
||||||
|
requestId: permission.id.slice(0, 8),
|
||||||
|
agentId: resolvedAgentId,
|
||||||
|
agentShortId: resolvedAgentId.slice(0, 7),
|
||||||
|
name: permission.name,
|
||||||
|
result: 'denied',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
data: results,
|
||||||
|
schema: permitResponseSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
// Re-throw CommandErrors
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DENY_PERMISSION_FAILED',
|
||||||
|
message: `Failed to deny permission: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Command } from 'commander'
|
||||||
|
import { runLsCommand } from './ls.js'
|
||||||
|
import { runAllowCommand } from './allow.js'
|
||||||
|
import { runDenyCommand } from './deny.js'
|
||||||
|
import { withOutput } from '../../output/index.js'
|
||||||
|
|
||||||
|
export function createPermitCommand(): Command {
|
||||||
|
const permit = new Command('permit').description('Manage permission requests')
|
||||||
|
|
||||||
|
permit
|
||||||
|
.command('ls')
|
||||||
|
.description('List all pending permissions')
|
||||||
|
.option('--json', 'Output in JSON format')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action((options, command) => {
|
||||||
|
if (options.json) {
|
||||||
|
command.parent.parent.opts().format = 'json'
|
||||||
|
}
|
||||||
|
return withOutput(runLsCommand)(options, command)
|
||||||
|
})
|
||||||
|
|
||||||
|
permit
|
||||||
|
.command('allow')
|
||||||
|
.description('Allow a permission request')
|
||||||
|
.argument('<agent>', 'Agent ID (or prefix)')
|
||||||
|
.argument('[req_id]', 'Permission request ID (optional if --all)')
|
||||||
|
.option('--all', 'Allow all pending permissions for this agent')
|
||||||
|
.option('--input <json>', 'Modified input parameters (JSON)')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runAllowCommand))
|
||||||
|
|
||||||
|
permit
|
||||||
|
.command('deny')
|
||||||
|
.description('Deny a permission request')
|
||||||
|
.argument('<agent>', 'Agent ID (or prefix)')
|
||||||
|
.argument('[req_id]', 'Permission request ID (optional if --all)')
|
||||||
|
.option('--all', 'Deny all pending permissions for this agent')
|
||||||
|
.option('--message <msg>', 'Denial reason message')
|
||||||
|
.option('--interrupt', 'Stop agent after denial')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runDenyCommand))
|
||||||
|
|
||||||
|
return permit
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { AgentPermissionRequest, AgentSnapshotPayload } from '@paseo/server'
|
||||||
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Permission list item for display */
|
||||||
|
export interface PermissionListItem {
|
||||||
|
id: string
|
||||||
|
agentId: string
|
||||||
|
agentShortId: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for permit ls output */
|
||||||
|
export const permitLsSchema: OutputSchema<PermissionListItem> = {
|
||||||
|
idField: 'id',
|
||||||
|
columns: [
|
||||||
|
{ header: 'AGENT', field: 'agentShortId', width: 12 },
|
||||||
|
{ header: 'REQ_ID', field: 'id', width: 12 },
|
||||||
|
{ header: 'TOOL', field: 'name', width: 20 },
|
||||||
|
{ header: 'DESCRIPTION', field: 'description', width: 50 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transform agent snapshot + permission to list item */
|
||||||
|
function toListItem(agent: AgentSnapshotPayload, permission: AgentPermissionRequest): PermissionListItem {
|
||||||
|
return {
|
||||||
|
id: permission.id.slice(0, 8),
|
||||||
|
agentId: agent.id,
|
||||||
|
agentShortId: agent.id.slice(0, 7),
|
||||||
|
name: permission.name,
|
||||||
|
description: permission.description ?? '-',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermitLsResult = ListResult<PermissionListItem>
|
||||||
|
|
||||||
|
export interface PermitLsOptions extends CommandOptions {
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runLsCommand(options: PermitLsOptions, _command: Command): Promise<PermitLsResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host })
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait a moment for the session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
// Collect all pending permissions from all agents
|
||||||
|
const items: PermissionListItem[] = []
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
|
||||||
|
for (const permission of agent.pendingPermissions) {
|
||||||
|
items.push(toListItem(agent, permission))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
data: items,
|
||||||
|
schema: permitLsSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'LIST_PERMISSIONS_FAILED',
|
||||||
|
message: `Failed to list permissions: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Command } from 'commander'
|
||||||
|
import { runLsCommand } from './ls.js'
|
||||||
|
import { runModelsCommand } from './models.js'
|
||||||
|
import { withOutput } from '../../output/index.js'
|
||||||
|
|
||||||
|
export function createProviderCommand(): Command {
|
||||||
|
const provider = new Command('provider').description('Manage agent providers')
|
||||||
|
|
||||||
|
provider
|
||||||
|
.command('ls')
|
||||||
|
.description('List available providers and status')
|
||||||
|
.option('--json', 'Output in JSON format')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action((options, command) => {
|
||||||
|
if (options.json) {
|
||||||
|
command.parent.parent.opts().format = 'json'
|
||||||
|
}
|
||||||
|
return withOutput(runLsCommand)(options, command)
|
||||||
|
})
|
||||||
|
|
||||||
|
provider
|
||||||
|
.command('models')
|
||||||
|
.description('List models for a provider')
|
||||||
|
.argument('<provider>', 'Provider name (claude, codex, opencode)')
|
||||||
|
.option('--json', 'Output in JSON format')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action((provider, options, command) => {
|
||||||
|
if (options.json) {
|
||||||
|
command.parent.parent.opts().format = 'json'
|
||||||
|
}
|
||||||
|
return withOutput(runModelsCommand)(provider, options, command)
|
||||||
|
})
|
||||||
|
|
||||||
|
return provider
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Provider list item for display */
|
||||||
|
export interface ProviderListItem {
|
||||||
|
provider: string
|
||||||
|
status: string
|
||||||
|
defaultMode: string
|
||||||
|
modes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Static provider data - providers are built-in and don't require daemon */
|
||||||
|
const PROVIDERS: ProviderListItem[] = [
|
||||||
|
{
|
||||||
|
provider: 'claude',
|
||||||
|
status: 'available',
|
||||||
|
defaultMode: 'default',
|
||||||
|
modes: 'plan, default, bypass',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
status: 'available',
|
||||||
|
defaultMode: 'auto',
|
||||||
|
modes: 'read-only, auto, full-access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'opencode',
|
||||||
|
status: 'available',
|
||||||
|
defaultMode: 'default',
|
||||||
|
modes: 'plan, default, bypass',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Schema for provider ls output */
|
||||||
|
export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
||||||
|
idField: 'provider',
|
||||||
|
columns: [
|
||||||
|
{ header: 'PROVIDER', field: 'provider', width: 12 },
|
||||||
|
{
|
||||||
|
header: 'STATUS',
|
||||||
|
field: 'status',
|
||||||
|
width: 12,
|
||||||
|
color: (value) => {
|
||||||
|
if (value === 'available') return 'green'
|
||||||
|
if (value === 'unavailable') return 'red'
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ header: 'DEFAULT MODE', field: 'defaultMode', width: 14 },
|
||||||
|
{ header: 'MODES', field: 'modes', width: 30 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderLsResult = ListResult<ProviderListItem>
|
||||||
|
|
||||||
|
export interface ProviderLsOptions extends CommandOptions {
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runLsCommand(
|
||||||
|
_options: ProviderLsOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<ProviderLsResult> {
|
||||||
|
// Provider data is static - no daemon connection needed
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
data: PROVIDERS,
|
||||||
|
schema: providerLsSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Model list item for display */
|
||||||
|
export interface ModelListItem {
|
||||||
|
model: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Static model data by provider */
|
||||||
|
const MODELS_BY_PROVIDER: Record<string, ModelListItem[]> = {
|
||||||
|
claude: [
|
||||||
|
{ model: 'Claude Sonnet 4', id: 'claude-sonnet-4-20250514' },
|
||||||
|
{ model: 'Claude Opus 4', id: 'claude-opus-4-20250514' },
|
||||||
|
{ model: 'Claude Haiku 3.5', id: 'claude-3-5-haiku-20241022' },
|
||||||
|
],
|
||||||
|
codex: [
|
||||||
|
{ model: 'o3-mini', id: 'o3-mini' },
|
||||||
|
{ model: 'o4-mini', id: 'o4-mini' },
|
||||||
|
],
|
||||||
|
opencode: [
|
||||||
|
// opencode uses claude or codex under the hood
|
||||||
|
{ model: 'Claude Sonnet 4', id: 'claude-sonnet-4-20250514' },
|
||||||
|
{ model: 'Claude Opus 4', id: 'claude-opus-4-20250514' },
|
||||||
|
{ model: 'Claude Haiku 3.5', id: 'claude-3-5-haiku-20241022' },
|
||||||
|
{ model: 'o3-mini', id: 'o3-mini' },
|
||||||
|
{ model: 'o4-mini', id: 'o4-mini' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for provider models output */
|
||||||
|
export const providerModelsSchema: OutputSchema<ModelListItem> = {
|
||||||
|
idField: 'id',
|
||||||
|
columns: [
|
||||||
|
{ header: 'MODEL', field: 'model', width: 30 },
|
||||||
|
{ header: 'ID', field: 'id', width: 30 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderModelsResult = ListResult<ModelListItem>
|
||||||
|
|
||||||
|
export interface ProviderModelsOptions extends CommandOptions {
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runModelsCommand(
|
||||||
|
provider: string,
|
||||||
|
_options: ProviderModelsOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<ProviderModelsResult> {
|
||||||
|
const normalizedProvider = provider.toLowerCase()
|
||||||
|
const models = MODELS_BY_PROVIDER[normalizedProvider]
|
||||||
|
|
||||||
|
if (!models) {
|
||||||
|
const validProviders = Object.keys(MODELS_BY_PROVIDER).join(', ')
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'UNKNOWN_PROVIDER',
|
||||||
|
message: `Unknown provider: ${provider}`,
|
||||||
|
details: `Valid providers: ${validProviders}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
data: models,
|
||||||
|
schema: providerModelsSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Result type for worktree archive command */
|
||||||
|
export interface WorktreeArchiveResult {
|
||||||
|
name: string
|
||||||
|
status: 'archived'
|
||||||
|
removedAgents: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for archive command output */
|
||||||
|
export const archiveSchema: OutputSchema<WorktreeArchiveResult> = {
|
||||||
|
idField: 'name',
|
||||||
|
columns: [
|
||||||
|
{ header: 'NAME', field: 'name' },
|
||||||
|
{ header: 'STATUS', field: 'status' },
|
||||||
|
{
|
||||||
|
header: 'REMOVED AGENTS',
|
||||||
|
field: (item) => item.removedAgents.length > 0 ? item.removedAgents.join(', ') : '-',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeArchiveOptions extends CommandOptions {
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>
|
||||||
|
|
||||||
|
export async function runArchiveCommand(
|
||||||
|
nameArg: string,
|
||||||
|
options: WorktreeArchiveOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<WorktreeArchiveCommandResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host })
|
||||||
|
|
||||||
|
// Validate arguments
|
||||||
|
if (!nameArg || nameArg.trim().length === 0) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'MISSING_WORKTREE_NAME',
|
||||||
|
message: 'Worktree name is required',
|
||||||
|
details: 'Usage: paseo worktree archive <name>',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the list of worktrees first to resolve the name
|
||||||
|
const listResponse = await client.getPaseoWorktreeList({})
|
||||||
|
|
||||||
|
if (listResponse.error) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'WORKTREE_LIST_FAILED',
|
||||||
|
message: `Failed to list worktrees: ${listResponse.error.message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the worktree by name or branch
|
||||||
|
const worktree = listResponse.worktrees.find((wt) => {
|
||||||
|
const name = wt.worktreePath.split('/').pop()
|
||||||
|
return name === nameArg || wt.branchName === nameArg
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!worktree) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'WORKTREE_NOT_FOUND',
|
||||||
|
message: `Worktree not found: ${nameArg}`,
|
||||||
|
details: 'Use "paseo worktree ls" to list available worktrees',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive the worktree
|
||||||
|
const response = await client.archivePaseoWorktree({
|
||||||
|
worktreePath: worktree.worktreePath,
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'WORKTREE_ARCHIVE_FAILED',
|
||||||
|
message: `Failed to archive worktree: ${response.error.message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreeName = worktree.worktreePath.split('/').pop() ?? nameArg
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
data: {
|
||||||
|
name: worktreeName,
|
||||||
|
status: 'archived',
|
||||||
|
removedAgents: response.removedAgents ?? [],
|
||||||
|
},
|
||||||
|
schema: archiveSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
|
||||||
|
// Re-throw CommandError as-is
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'WORKTREE_ARCHIVE_FAILED',
|
||||||
|
message: `Failed to archive worktree: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Command } from 'commander'
|
||||||
|
import { runLsCommand } from './ls.js'
|
||||||
|
import { runArchiveCommand } from './archive.js'
|
||||||
|
import { withOutput } from '../../output/index.js'
|
||||||
|
|
||||||
|
export function createWorktreeCommand(): Command {
|
||||||
|
const worktree = new Command('worktree').description('Manage Paseo-managed git worktrees')
|
||||||
|
|
||||||
|
worktree
|
||||||
|
.command('ls')
|
||||||
|
.description('List Paseo-managed git worktrees')
|
||||||
|
.option('--json', 'Output in JSON format')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action((options, command) => {
|
||||||
|
if (options.json) {
|
||||||
|
command.parent.parent.opts().format = 'json'
|
||||||
|
}
|
||||||
|
return withOutput(runLsCommand)(options, command)
|
||||||
|
})
|
||||||
|
|
||||||
|
worktree
|
||||||
|
.command('archive')
|
||||||
|
.description('Archive a worktree (removes worktree and associated branch)')
|
||||||
|
.argument('<name>', 'Worktree name or branch name')
|
||||||
|
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||||
|
.action(withOutput(runArchiveCommand))
|
||||||
|
|
||||||
|
return worktree
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { Command } from 'commander'
|
||||||
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||||
|
|
||||||
|
/** Worktree list item for display */
|
||||||
|
export interface WorktreeListItem {
|
||||||
|
name: string
|
||||||
|
branch: string
|
||||||
|
cwd: string
|
||||||
|
agent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorten home directory in path */
|
||||||
|
function shortenPath(path: string): string {
|
||||||
|
const home = process.env.HOME
|
||||||
|
if (home && path.startsWith(home)) {
|
||||||
|
return '~' + path.slice(home.length)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract worktree name from path */
|
||||||
|
function extractWorktreeName(path: string): string {
|
||||||
|
// ~/.paseo/worktrees/<repo>/<name> -> name
|
||||||
|
const parts = path.split('/')
|
||||||
|
return parts[parts.length - 1] ?? path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema for worktree ls output */
|
||||||
|
export const worktreeLsSchema: OutputSchema<WorktreeListItem> = {
|
||||||
|
idField: 'name',
|
||||||
|
columns: [
|
||||||
|
{ header: 'NAME', field: 'name', width: 20 },
|
||||||
|
{ header: 'BRANCH', field: 'branch', width: 25 },
|
||||||
|
{ header: 'CWD', field: 'cwd', width: 45 },
|
||||||
|
{ header: 'AGENT', field: 'agent', width: 10 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorktreeLsResult = ListResult<WorktreeListItem>
|
||||||
|
|
||||||
|
export interface WorktreeLsOptions extends CommandOptions {
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runLsCommand(
|
||||||
|
options: WorktreeLsOptions,
|
||||||
|
_command: Command
|
||||||
|
): Promise<WorktreeLsResult> {
|
||||||
|
const host = getDaemonHost({ host: options.host })
|
||||||
|
|
||||||
|
let client
|
||||||
|
try {
|
||||||
|
client = await connectToDaemon({ host: options.host })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'DAEMON_NOT_RUNNING',
|
||||||
|
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||||
|
details: 'Start the daemon with: paseo daemon start',
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request session state to get agent information
|
||||||
|
client.requestSessionState()
|
||||||
|
|
||||||
|
// Wait a moment for the session state to be populated
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const agents = client.listAgents()
|
||||||
|
|
||||||
|
// Get worktree list from daemon
|
||||||
|
const response = await client.getPaseoWorktreeList({})
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'WORKTREE_LIST_FAILED',
|
||||||
|
message: `Failed to list worktrees: ${response.error.message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of worktree paths to agent IDs
|
||||||
|
const worktreeAgentMap = new Map<string, string>()
|
||||||
|
for (const agent of agents) {
|
||||||
|
// Check if agent cwd is under ~/.paseo/worktrees/
|
||||||
|
const paseoHome = process.env.PASEO_HOME ?? (process.env.HOME + '/.paseo')
|
||||||
|
const worktreesDir = paseoHome + '/worktrees/'
|
||||||
|
if (agent.cwd.startsWith(worktreesDir)) {
|
||||||
|
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: WorktreeListItem[] = response.worktrees.map((wt) => ({
|
||||||
|
name: extractWorktreeName(wt.worktreePath),
|
||||||
|
branch: wt.branchName ?? '-',
|
||||||
|
cwd: shortenPath(wt.worktreePath),
|
||||||
|
agent: worktreeAgentMap.get(wt.worktreePath) ?? '-',
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
data: items,
|
||||||
|
schema: worktreeLsSchema,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
|
||||||
|
// Re-throw CommandError as-is
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const error: CommandError = {
|
||||||
|
code: 'WORKTREE_LIST_FAILED',
|
||||||
|
message: `Failed to list worktrees: ${message}`,
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createCli } from './cli.js'
|
||||||
|
|
||||||
|
const program = createCli()
|
||||||
|
program.parse()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Output abstraction layer for the Paseo CLI.
|
||||||
|
*
|
||||||
|
* This module provides structured output rendering with support for multiple formats:
|
||||||
|
* - table: Human-readable aligned tables (default)
|
||||||
|
* - json: Machine-readable JSON
|
||||||
|
* - yaml: Machine-readable YAML
|
||||||
|
* - quiet: Minimal output (IDs only)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { withOutput, render, type ListResult, type OutputSchema } from './output/index.js'
|
||||||
|
*
|
||||||
|
* // Define your data type
|
||||||
|
* interface Agent { id: string; title: string; status: string }
|
||||||
|
*
|
||||||
|
* // Define how to render it
|
||||||
|
* const schema: OutputSchema<Agent> = {
|
||||||
|
* idField: 'id',
|
||||||
|
* columns: [
|
||||||
|
* { header: 'ID', field: 'id' },
|
||||||
|
* { header: 'TITLE', field: 'title' },
|
||||||
|
* { header: 'STATUS', field: 'status', color: (v) => v === 'running' ? 'green' : undefined },
|
||||||
|
* ],
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Return structured data from commands
|
||||||
|
* const result: ListResult<Agent> = {
|
||||||
|
* type: 'list',
|
||||||
|
* data: agents,
|
||||||
|
* schema,
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Render with options
|
||||||
|
* const output = render(result, { format: 'json' })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
OutputFormat,
|
||||||
|
OutputOptions,
|
||||||
|
ColumnDef,
|
||||||
|
OutputSchema,
|
||||||
|
CommandResult,
|
||||||
|
SingleResult,
|
||||||
|
ListResult,
|
||||||
|
AnyCommandResult,
|
||||||
|
CommandError,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
// Renderers
|
||||||
|
export { renderTable, renderTableHeader, renderTableRow } from './table.js'
|
||||||
|
export { renderJson, renderJsonLine } from './json.js'
|
||||||
|
export { renderYaml, renderYamlDoc } from './yaml.js'
|
||||||
|
export { renderQuiet } from './quiet.js'
|
||||||
|
|
||||||
|
// Main render function
|
||||||
|
export { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
|
||||||
|
|
||||||
|
// Command wrapper
|
||||||
|
export { withOutput, createOutputOptions, type CommandOptions } from './with-output.js'
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* JSON renderer for CLI output.
|
||||||
|
*
|
||||||
|
* Renders structured data as formatted JSON for machine consumption.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AnyCommandResult, OutputOptions } from './types.js'
|
||||||
|
|
||||||
|
/** Render command result as JSON */
|
||||||
|
export function renderJson<T>(
|
||||||
|
result: AnyCommandResult<T>,
|
||||||
|
_options: OutputOptions
|
||||||
|
): string {
|
||||||
|
const { schema } = result
|
||||||
|
|
||||||
|
// Apply custom serializer if provided
|
||||||
|
if (schema.serialize) {
|
||||||
|
if (result.type === 'list') {
|
||||||
|
// If all items serialize to the same object, return just one
|
||||||
|
// This handles the case where a list of key-value rows should serialize
|
||||||
|
// to a single structured object
|
||||||
|
const serialized = result.data.map((item) => schema.serialize!(item))
|
||||||
|
if (serialized.length > 0) {
|
||||||
|
const first = JSON.stringify(serialized[0])
|
||||||
|
const allSame = serialized.every((s) => JSON.stringify(s) === first)
|
||||||
|
if (allSame) {
|
||||||
|
return JSON.stringify(serialized[0], null, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(serialized, null, 2)
|
||||||
|
} else {
|
||||||
|
const serialized = schema.serialize(result.data)
|
||||||
|
return JSON.stringify(serialized, null, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(result.data, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a single item as JSON line (for NDJSON streaming) */
|
||||||
|
export function renderJsonLine<T>(item: T, serialize?: (data: T) => unknown): string {
|
||||||
|
const output = serialize ? serialize(item) : item
|
||||||
|
return JSON.stringify(output)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Quiet renderer for CLI output.
|
||||||
|
*
|
||||||
|
* Outputs only ID fields, one per line. Useful for scripting and pipelines.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AnyCommandResult, OutputOptions } from './types.js'
|
||||||
|
|
||||||
|
/** Extract ID from item using schema definition */
|
||||||
|
function getId<T>(item: T, idField: keyof T | ((item: T) => string)): string {
|
||||||
|
if (typeof idField === 'function') {
|
||||||
|
return idField(item)
|
||||||
|
}
|
||||||
|
return String(item[idField])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render command result in quiet mode (IDs only) */
|
||||||
|
export function renderQuiet<T>(
|
||||||
|
result: AnyCommandResult<T>,
|
||||||
|
_options: OutputOptions
|
||||||
|
): string {
|
||||||
|
if (result.type === 'single') {
|
||||||
|
return getId(result.data, result.schema.idField)
|
||||||
|
} else {
|
||||||
|
return result.data.map((item) => getId(item, result.schema.idField)).join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Main render dispatcher for CLI output.
|
||||||
|
*
|
||||||
|
* Selects the appropriate renderer based on output options.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import chalk from 'chalk'
|
||||||
|
import YAML from 'yaml'
|
||||||
|
import type { AnyCommandResult, CommandError, OutputOptions } from './types.js'
|
||||||
|
import { renderTable } from './table.js'
|
||||||
|
import { renderJson } from './json.js'
|
||||||
|
import { renderYaml } from './yaml.js'
|
||||||
|
import { renderQuiet } from './quiet.js'
|
||||||
|
|
||||||
|
/** Default output options */
|
||||||
|
export const defaultOutputOptions: OutputOptions = {
|
||||||
|
format: 'table',
|
||||||
|
quiet: false,
|
||||||
|
noHeaders: false,
|
||||||
|
noColor: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render command result to string based on output options */
|
||||||
|
export function render<T>(
|
||||||
|
result: AnyCommandResult<T>,
|
||||||
|
options: Partial<OutputOptions> = {}
|
||||||
|
): string {
|
||||||
|
const opts: OutputOptions = { ...defaultOutputOptions, ...options }
|
||||||
|
|
||||||
|
// Quiet mode takes precedence
|
||||||
|
if (opts.quiet) {
|
||||||
|
return renderQuiet(result, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch to format-specific renderer
|
||||||
|
switch (opts.format) {
|
||||||
|
case 'json':
|
||||||
|
return renderJson(result, opts)
|
||||||
|
case 'yaml':
|
||||||
|
return renderYaml(result, opts)
|
||||||
|
case 'table':
|
||||||
|
default:
|
||||||
|
return renderTable(result, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert an unknown error to a CommandError */
|
||||||
|
export function toCommandError(error: unknown): CommandError {
|
||||||
|
if (isCommandError(error)) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
code: 'UNKNOWN_ERROR',
|
||||||
|
message: error.message,
|
||||||
|
details: error.stack,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 'UNKNOWN_ERROR',
|
||||||
|
message: String(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type guard for CommandError */
|
||||||
|
function isCommandError(error: unknown): error is CommandError {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
'message' in error &&
|
||||||
|
typeof (error as CommandError).code === 'string' &&
|
||||||
|
typeof (error as CommandError).message === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render an error to string based on output options */
|
||||||
|
export function renderError(
|
||||||
|
error: CommandError,
|
||||||
|
options: Partial<OutputOptions> = {}
|
||||||
|
): string {
|
||||||
|
const opts: OutputOptions = { ...defaultOutputOptions, ...options }
|
||||||
|
|
||||||
|
if (opts.format === 'json') {
|
||||||
|
return JSON.stringify({ error }, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.format === 'yaml') {
|
||||||
|
return YAML.stringify({ error })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table/default format: human-readable error
|
||||||
|
const prefix = opts.noColor ? 'Error: ' : chalk.red('Error: ')
|
||||||
|
const message = error.message
|
||||||
|
|
||||||
|
if (error.details && typeof error.details === 'string') {
|
||||||
|
return `${prefix}${message}\n${error.details}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${message}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Table renderer for CLI output.
|
||||||
|
*
|
||||||
|
* Renders structured data as aligned ASCII tables with optional color support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import chalk, { type ChalkInstance } from 'chalk'
|
||||||
|
import type {
|
||||||
|
AnyCommandResult,
|
||||||
|
ColumnDef,
|
||||||
|
OutputOptions,
|
||||||
|
OutputSchema,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
// ANSI escape code regex for stripping colors when measuring width
|
||||||
|
const ANSI_REGEX =
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g
|
||||||
|
|
||||||
|
/** Strip ANSI escape codes from a string */
|
||||||
|
function stripAnsi(str: string): string {
|
||||||
|
return str.replace(ANSI_REGEX, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get visible string length (excluding ANSI codes) */
|
||||||
|
function visibleLength(str: string): number {
|
||||||
|
return stripAnsi(str).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pad a cell to the specified width with alignment */
|
||||||
|
function padCell(
|
||||||
|
cell: string,
|
||||||
|
width: number,
|
||||||
|
align: 'left' | 'right' | 'center'
|
||||||
|
): string {
|
||||||
|
const visible = visibleLength(cell)
|
||||||
|
const padding = Math.max(0, width - visible)
|
||||||
|
|
||||||
|
switch (align) {
|
||||||
|
case 'right':
|
||||||
|
return ' '.repeat(padding) + cell
|
||||||
|
case 'center': {
|
||||||
|
const left = Math.floor(padding / 2)
|
||||||
|
const right = padding - left
|
||||||
|
return ' '.repeat(left) + cell + ' '.repeat(right)
|
||||||
|
}
|
||||||
|
case 'left':
|
||||||
|
default:
|
||||||
|
return cell + ' '.repeat(padding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a chalk color to a string */
|
||||||
|
function applyColor(str: string, colorName: string): string {
|
||||||
|
// Map color names to chalk methods
|
||||||
|
const colorMap: Record<string, ChalkInstance> = {
|
||||||
|
red: chalk.red,
|
||||||
|
green: chalk.green,
|
||||||
|
blue: chalk.blue,
|
||||||
|
yellow: chalk.yellow,
|
||||||
|
cyan: chalk.cyan,
|
||||||
|
magenta: chalk.magenta,
|
||||||
|
white: chalk.white,
|
||||||
|
gray: chalk.gray,
|
||||||
|
grey: chalk.grey,
|
||||||
|
dim: chalk.dim,
|
||||||
|
bold: chalk.bold,
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorFn = colorMap[colorName]
|
||||||
|
return colorFn ? colorFn(str) : str
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract value from item using field definition */
|
||||||
|
function getValue<T>(item: T, field: keyof T | ((item: T) => unknown)): unknown {
|
||||||
|
return typeof field === 'function' ? field(item) : item[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a single table row */
|
||||||
|
function renderRow<T>(
|
||||||
|
item: T,
|
||||||
|
columns: ColumnDef<T>[],
|
||||||
|
widths: number[],
|
||||||
|
options: OutputOptions
|
||||||
|
): string {
|
||||||
|
return columns
|
||||||
|
.map((col, colIndex) => {
|
||||||
|
const value = getValue(item, col.field)
|
||||||
|
let cell = String(value ?? '')
|
||||||
|
const width = widths[colIndex]
|
||||||
|
|
||||||
|
// Apply color if enabled
|
||||||
|
if (col.color && !options.noColor) {
|
||||||
|
const colorName = col.color(value, item)
|
||||||
|
if (colorName) {
|
||||||
|
cell = applyColor(cell, colorName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return padCell(cell, width ?? 0, col.align ?? 'left')
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render header row */
|
||||||
|
function renderHeader<T>(
|
||||||
|
columns: ColumnDef<T>[],
|
||||||
|
widths: number[],
|
||||||
|
options: OutputOptions
|
||||||
|
): string {
|
||||||
|
const headerRow = columns
|
||||||
|
.map((col, i) => padCell(col.header, widths[i] ?? 0, col.align ?? 'left'))
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return options.noColor ? headerRow : chalk.bold(headerRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate column widths based on content and hints */
|
||||||
|
function calculateWidths<T>(
|
||||||
|
data: T[],
|
||||||
|
columns: ColumnDef<T>[],
|
||||||
|
includeHeaders: boolean
|
||||||
|
): number[] {
|
||||||
|
return columns.map((col) => {
|
||||||
|
// Start with header width if including headers
|
||||||
|
let maxWidth = includeHeaders ? col.header.length : 0
|
||||||
|
|
||||||
|
// Check all data values
|
||||||
|
for (const item of data) {
|
||||||
|
const value = getValue(item, col.field)
|
||||||
|
const str = String(value ?? '')
|
||||||
|
maxWidth = Math.max(maxWidth, visibleLength(str))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply width hint if specified (minimum width)
|
||||||
|
if (col.width) {
|
||||||
|
maxWidth = Math.max(maxWidth, col.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxWidth
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a list result as a table */
|
||||||
|
export function renderTable<T>(
|
||||||
|
result: AnyCommandResult<T>,
|
||||||
|
options: OutputOptions
|
||||||
|
): string {
|
||||||
|
const { schema } = result
|
||||||
|
const data = result.type === 'list' ? result.data : [result.data]
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = schema.columns as ColumnDef<T>[]
|
||||||
|
const includeHeaders = !options.noHeaders
|
||||||
|
const widths = calculateWidths(data, columns, includeHeaders)
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
// Add header row
|
||||||
|
if (includeHeaders) {
|
||||||
|
lines.push(renderHeader(columns, widths, options))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add data rows
|
||||||
|
for (const item of data) {
|
||||||
|
lines.push(renderRow(item, columns, widths, options))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render just a table header (for streaming) */
|
||||||
|
export function renderTableHeader<T>(
|
||||||
|
schema: OutputSchema<T>,
|
||||||
|
options: OutputOptions,
|
||||||
|
widths?: number[]
|
||||||
|
): string {
|
||||||
|
const columns = schema.columns
|
||||||
|
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length)
|
||||||
|
return renderHeader(columns, actualWidths, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render just a table row (for streaming) */
|
||||||
|
export function renderTableRow<T>(
|
||||||
|
item: T,
|
||||||
|
schema: OutputSchema<T>,
|
||||||
|
options: OutputOptions,
|
||||||
|
widths?: number[]
|
||||||
|
): string {
|
||||||
|
const columns = schema.columns
|
||||||
|
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length)
|
||||||
|
return renderRow(item, columns, actualWidths, options)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Output format types for the Paseo CLI.
|
||||||
|
*
|
||||||
|
* This module defines the structured data types used by the output abstraction layer.
|
||||||
|
* Commands return CommandResult<T> which contains both data and rendering metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Supported output formats */
|
||||||
|
export type OutputFormat = 'table' | 'json' | 'yaml'
|
||||||
|
|
||||||
|
/** Options controlling output rendering */
|
||||||
|
export interface OutputOptions {
|
||||||
|
/** Output format (table, json, yaml) */
|
||||||
|
format: OutputFormat
|
||||||
|
/** Minimal output - IDs only */
|
||||||
|
quiet: boolean
|
||||||
|
/** Omit table headers */
|
||||||
|
noHeaders: boolean
|
||||||
|
/** Disable color output */
|
||||||
|
noColor: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column definition for table output */
|
||||||
|
export interface ColumnDef<T> {
|
||||||
|
/** 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 - returns chalk color name */
|
||||||
|
color?: (value: unknown, item: T) => string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema describing how to render command output */
|
||||||
|
export interface OutputSchema<T> {
|
||||||
|
/** Field to use for quiet mode (--quiet outputs just this) */
|
||||||
|
idField: keyof T | ((item: T) => string)
|
||||||
|
/** Column definitions for table output */
|
||||||
|
columns: ColumnDef<T>[]
|
||||||
|
/** Optional: transform data before JSON/YAML output */
|
||||||
|
serialize?: (data: T) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result type for commands returning a single item */
|
||||||
|
export interface SingleResult<T> {
|
||||||
|
type: 'single'
|
||||||
|
/** The structured data to render */
|
||||||
|
data: T
|
||||||
|
/** Schema describing how to render this data (for item type T) */
|
||||||
|
schema: OutputSchema<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result type for commands returning a list */
|
||||||
|
export interface ListResult<T> {
|
||||||
|
type: 'list'
|
||||||
|
/** The structured data to render */
|
||||||
|
data: T[]
|
||||||
|
/** Schema describing how to render this data (for item type T) */
|
||||||
|
schema: OutputSchema<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union type for all command results */
|
||||||
|
export type AnyCommandResult<T> = SingleResult<T> | ListResult<T>
|
||||||
|
|
||||||
|
/** Base interface for command results (deprecated, use SingleResult or ListResult) */
|
||||||
|
export type CommandResult<T> = SingleResult<T> | ListResult<T>
|
||||||
|
|
||||||
|
/** Structured error for command failures */
|
||||||
|
export interface CommandError {
|
||||||
|
/** Machine-readable error code */
|
||||||
|
code: string
|
||||||
|
/** Human-readable message */
|
||||||
|
message: string
|
||||||
|
/** Additional context */
|
||||||
|
details?: unknown
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Command wrapper for automatic output rendering.
|
||||||
|
*
|
||||||
|
* Wraps command handlers to automatically render results and handle errors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Command } from 'commander'
|
||||||
|
import type { AnyCommandResult, OutputOptions } from './types.js'
|
||||||
|
import { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
|
||||||
|
|
||||||
|
/** Options that include output settings from global options */
|
||||||
|
export interface CommandOptions extends Partial<OutputOptions> {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract output options from command options */
|
||||||
|
function extractOutputOptions(options: CommandOptions): OutputOptions {
|
||||||
|
return {
|
||||||
|
format: (options.format as OutputOptions['format']) ?? defaultOutputOptions.format,
|
||||||
|
quiet: options.quiet ?? defaultOutputOptions.quiet,
|
||||||
|
noHeaders: options.headers === false, // Commander uses --no-headers -> headers: false
|
||||||
|
noColor: options.color === false, // Commander uses --no-color -> color: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a command handler to automatically render output.
|
||||||
|
*
|
||||||
|
* The wrapped handler should return a CommandResult. The wrapper will:
|
||||||
|
* 1. Call the handler
|
||||||
|
* 2. Render the result using the appropriate format
|
||||||
|
* 3. Write to stdout
|
||||||
|
* 4. Handle errors by rendering to stderr and exiting with code 1
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* program
|
||||||
|
* .command('list')
|
||||||
|
* .action(withOutput(async (options) => {
|
||||||
|
* const data = await fetchData()
|
||||||
|
* return { type: 'list', data, schema }
|
||||||
|
* }))
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function withOutput<T, Args extends unknown[]>(
|
||||||
|
handler: (...args: [...Args, CommandOptions, Command]) => Promise<AnyCommandResult<T>>
|
||||||
|
): (...args: [...Args, CommandOptions, Command]) => Promise<void> {
|
||||||
|
return async (...args) => {
|
||||||
|
// Last two args are options and command
|
||||||
|
const command = args[args.length - 1] as Command
|
||||||
|
// Use optsWithGlobals() to get both local and global options
|
||||||
|
const options = command.optsWithGlobals() as CommandOptions
|
||||||
|
const outputOptions = extractOutputOptions(options)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await handler(...args)
|
||||||
|
const output = render(result, outputOptions)
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
process.stdout.write(output + '\n')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const commandError = toCommandError(error)
|
||||||
|
const errorOutput = renderError(commandError, outputOptions)
|
||||||
|
process.stderr.write(errorOutput + '\n')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create output options from partial input.
|
||||||
|
* Useful for testing or manual rendering.
|
||||||
|
*/
|
||||||
|
export function createOutputOptions(
|
||||||
|
partial: Partial<OutputOptions> = {}
|
||||||
|
): OutputOptions {
|
||||||
|
return { ...defaultOutputOptions, ...partial }
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* YAML renderer for CLI output.
|
||||||
|
*
|
||||||
|
* Renders structured data as YAML for machine consumption and human readability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import YAML from 'yaml'
|
||||||
|
import type { AnyCommandResult, OutputOptions } from './types.js'
|
||||||
|
|
||||||
|
/** Render command result as YAML */
|
||||||
|
export function renderYaml<T>(
|
||||||
|
result: AnyCommandResult<T>,
|
||||||
|
_options: OutputOptions
|
||||||
|
): string {
|
||||||
|
const { schema } = result
|
||||||
|
|
||||||
|
// Apply custom serializer if provided
|
||||||
|
if (schema.serialize) {
|
||||||
|
if (result.type === 'list') {
|
||||||
|
// If all items serialize to the same object, return just one
|
||||||
|
// This handles the case where a list of key-value rows should serialize
|
||||||
|
// to a single structured object
|
||||||
|
const serialized = result.data.map((item) => schema.serialize!(item))
|
||||||
|
if (serialized.length > 0) {
|
||||||
|
const first = JSON.stringify(serialized[0])
|
||||||
|
const allSame = serialized.every((s) => JSON.stringify(s) === first)
|
||||||
|
if (allSame) {
|
||||||
|
return YAML.stringify(serialized[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return YAML.stringify(serialized)
|
||||||
|
} else {
|
||||||
|
const serialized = schema.serialize(result.data)
|
||||||
|
return YAML.stringify(serialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return YAML.stringify(result.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a single item as YAML document (for streaming) */
|
||||||
|
export function renderYamlDoc<T>(item: T, serialize?: (data: T) => unknown): string {
|
||||||
|
const output = serialize ? serialize(item) : item
|
||||||
|
return YAML.stringify(output)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { DaemonClientV2 } from '@paseo/server'
|
||||||
|
import WebSocket from 'ws'
|
||||||
|
|
||||||
|
export interface ConnectOptions {
|
||||||
|
host?: string
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_HOST = 'localhost:6767'
|
||||||
|
const DEFAULT_TIMEOUT = 5000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the daemon host from environment or options
|
||||||
|
*/
|
||||||
|
export function getDaemonHost(options?: ConnectOptions): string {
|
||||||
|
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a WebSocket factory that works in Node.js
|
||||||
|
*/
|
||||||
|
function createNodeWebSocketFactory() {
|
||||||
|
return (url: string, options?: { headers?: Record<string, string> }) => {
|
||||||
|
return new WebSocket(url, { headers: options?.headers }) as unknown as {
|
||||||
|
readyState: number
|
||||||
|
send: (data: string) => void
|
||||||
|
close: (code?: number, reason?: string) => void
|
||||||
|
on: (event: string, listener: (...args: unknown[]) => void) => void
|
||||||
|
off: (event: string, listener: (...args: unknown[]) => void) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and connect a daemon client
|
||||||
|
* Returns the connected client or throws if connection fails
|
||||||
|
*/
|
||||||
|
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2> {
|
||||||
|
const host = getDaemonHost(options)
|
||||||
|
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
||||||
|
const url = `ws://${host}/ws`
|
||||||
|
|
||||||
|
const client = new DaemonClientV2({
|
||||||
|
url,
|
||||||
|
webSocketFactory: createNodeWebSocketFactory(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect with timeout
|
||||||
|
const connectPromise = client.connect()
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error(`Connection timeout after ${timeout}ms`))
|
||||||
|
}, timeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([connectPromise, timeoutPromise])
|
||||||
|
return client
|
||||||
|
} catch (err) {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to connect to the daemon, returns null if connection fails
|
||||||
|
*/
|
||||||
|
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2 | null> {
|
||||||
|
try {
|
||||||
|
return await connectToDaemon(options)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal agent type for ID resolution */
|
||||||
|
interface AgentLike {
|
||||||
|
id: string
|
||||||
|
title?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an agent ID from a partial ID or name.
|
||||||
|
* Supports:
|
||||||
|
* - Full ID match
|
||||||
|
* - Prefix match (first N characters)
|
||||||
|
* - Title/name match (case-insensitive)
|
||||||
|
*
|
||||||
|
* Returns the full agent ID if found, null otherwise.
|
||||||
|
*/
|
||||||
|
export function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null {
|
||||||
|
if (!idOrName || agents.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = idOrName.toLowerCase()
|
||||||
|
|
||||||
|
// Try exact ID match first
|
||||||
|
const exactMatch = agents.find((a) => a.id === idOrName)
|
||||||
|
if (exactMatch) {
|
||||||
|
return exactMatch.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ID prefix match
|
||||||
|
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query))
|
||||||
|
if (prefixMatches.length === 1 && prefixMatches[0]) {
|
||||||
|
return prefixMatches[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try title/name match (case-insensitive)
|
||||||
|
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query)
|
||||||
|
if (titleMatches.length === 1 && titleMatches[0]) {
|
||||||
|
return titleMatches[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try partial title match
|
||||||
|
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query))
|
||||||
|
if (partialTitleMatches.length === 1 && partialTitleMatches[0]) {
|
||||||
|
return partialTitleMatches[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have multiple prefix matches and no unique title match, return first prefix match
|
||||||
|
const firstPrefixMatch = prefixMatches[0]
|
||||||
|
if (firstPrefixMatch) {
|
||||||
|
return firstPrefixMatch.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env npx zx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1: Foundation Tests
|
||||||
|
*
|
||||||
|
* Tests basic CLI functionality that doesn't require a daemon:
|
||||||
|
* - paseo --version outputs version
|
||||||
|
* - paseo --help shows commands
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { $ } from 'zx'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('📋 Phase 1: Foundation Tests\n')
|
||||||
|
|
||||||
|
// Test 1.1: --version outputs version
|
||||||
|
console.log(' Testing paseo --version...')
|
||||||
|
const versionResult = await $`paseo --version`.nothrow()
|
||||||
|
if (versionResult.exitCode !== 0) {
|
||||||
|
console.error(' ❌ paseo --version failed with exit code', versionResult.exitCode)
|
||||||
|
console.error(' stderr:', versionResult.stderr)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const versionOutput = versionResult.stdout.trim()
|
||||||
|
if (!versionOutput.match(/\d+\.\d+\.\d+/)) {
|
||||||
|
console.error(' ❌ paseo --version output does not contain version number')
|
||||||
|
console.error(' output:', versionOutput)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log(' ✅ paseo --version outputs:', versionOutput)
|
||||||
|
|
||||||
|
// Test 1.2: --help shows commands
|
||||||
|
console.log(' Testing paseo --help...')
|
||||||
|
const helpResult = await $`paseo --help`.nothrow()
|
||||||
|
if (helpResult.exitCode !== 0) {
|
||||||
|
console.error(' ❌ paseo --help failed with exit code', helpResult.exitCode)
|
||||||
|
console.error(' stderr:', helpResult.stderr)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const helpOutput = helpResult.stdout
|
||||||
|
|
||||||
|
// Check for expected sections in help output
|
||||||
|
const expectedTerms = ['agent', 'daemon', 'Usage', 'Options', 'Commands']
|
||||||
|
const missingTerms = expectedTerms.filter(term => !helpOutput.includes(term))
|
||||||
|
if (missingTerms.length > 0) {
|
||||||
|
console.error(' ❌ paseo --help missing expected terms:', missingTerms.join(', '))
|
||||||
|
console.error(' output:', helpOutput)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log(' ✅ paseo --help shows commands')
|
||||||
|
|
||||||
|
console.log('\n✅ Phase 1: Foundation Tests PASSED')
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* Tests for the output abstraction layer.
|
||||||
|
*
|
||||||
|
* Verifies that renderers correctly format structured data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
renderTable,
|
||||||
|
renderJson,
|
||||||
|
renderYaml,
|
||||||
|
renderQuiet,
|
||||||
|
renderError,
|
||||||
|
toCommandError,
|
||||||
|
createOutputOptions,
|
||||||
|
type ListResult,
|
||||||
|
type SingleResult,
|
||||||
|
type OutputSchema,
|
||||||
|
type CommandError,
|
||||||
|
} from '../src/output/index.js'
|
||||||
|
|
||||||
|
// Test data types
|
||||||
|
interface Agent {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: 'running' | 'idle' | 'error'
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema for agents
|
||||||
|
const agentSchema: OutputSchema<Agent> = {
|
||||||
|
idField: 'id',
|
||||||
|
columns: [
|
||||||
|
{ header: 'ID', field: 'id', width: 8 },
|
||||||
|
{ header: 'TITLE', field: 'title', width: 20 },
|
||||||
|
{
|
||||||
|
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' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const testAgents: Agent[] = [
|
||||||
|
{ id: 'abc123', title: 'Feature Implementation', status: 'running', provider: 'claude' },
|
||||||
|
{ id: 'def456', title: 'Bug Fix', status: 'idle', provider: 'codex' },
|
||||||
|
{ id: 'ghi789', title: 'Failed Task', status: 'error', provider: 'claude' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const listResult: ListResult<Agent> = {
|
||||||
|
type: 'list',
|
||||||
|
data: testAgents,
|
||||||
|
schema: agentSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleResult: SingleResult<Agent> = {
|
||||||
|
type: 'single',
|
||||||
|
data: testAgents[0],
|
||||||
|
schema: agentSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test utilities
|
||||||
|
let passed = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
function test(name: string, fn: () => void): void {
|
||||||
|
try {
|
||||||
|
fn()
|
||||||
|
console.log(`✓ ${name}`)
|
||||||
|
passed++
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`✗ ${name}`)
|
||||||
|
console.error(` ${error instanceof Error ? error.message : error}`)
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table renderer tests
|
||||||
|
console.log('\n=== Table Renderer ===\n')
|
||||||
|
|
||||||
|
test('renderTable formats list data with headers', () => {
|
||||||
|
const output = renderTable(listResult, createOutputOptions({ noColor: true }))
|
||||||
|
assert.ok(output.includes('ID'), 'Should include ID header')
|
||||||
|
assert.ok(output.includes('TITLE'), 'Should include TITLE header')
|
||||||
|
assert.ok(output.includes('STATUS'), 'Should include STATUS header')
|
||||||
|
assert.ok(output.includes('abc123'), 'Should include first agent ID')
|
||||||
|
assert.ok(output.includes('Feature Implementation'), 'Should include first agent title')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderTable omits headers when noHeaders is true', () => {
|
||||||
|
const output = renderTable(listResult, createOutputOptions({ noHeaders: true, noColor: true }))
|
||||||
|
assert.ok(!output.includes('ID '), 'Should not include header row')
|
||||||
|
assert.ok(output.includes('abc123'), 'Should still include data')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderTable handles empty data', () => {
|
||||||
|
const emptyResult: ListResult<Agent> = {
|
||||||
|
type: 'list',
|
||||||
|
data: [],
|
||||||
|
schema: agentSchema,
|
||||||
|
}
|
||||||
|
const output = renderTable(emptyResult, createOutputOptions())
|
||||||
|
assert.strictEqual(output, '', 'Should return empty string for empty data')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderTable handles single result', () => {
|
||||||
|
const output = renderTable(singleResult, createOutputOptions({ noColor: true }))
|
||||||
|
assert.ok(output.includes('abc123'), 'Should include agent ID')
|
||||||
|
assert.ok(output.includes('Feature Implementation'), 'Should include agent title')
|
||||||
|
})
|
||||||
|
|
||||||
|
// JSON renderer tests
|
||||||
|
console.log('\n=== JSON Renderer ===\n')
|
||||||
|
|
||||||
|
test('renderJson outputs valid JSON for list', () => {
|
||||||
|
const output = renderJson(listResult, createOutputOptions())
|
||||||
|
const parsed = JSON.parse(output)
|
||||||
|
assert.ok(Array.isArray(parsed), 'Should be an array')
|
||||||
|
assert.strictEqual(parsed.length, 3, 'Should have 3 items')
|
||||||
|
assert.strictEqual(parsed[0].id, 'abc123', 'Should include correct data')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderJson outputs valid JSON for single item', () => {
|
||||||
|
const output = renderJson(singleResult, createOutputOptions())
|
||||||
|
const parsed = JSON.parse(output)
|
||||||
|
assert.strictEqual(parsed.id, 'abc123', 'Should include correct data')
|
||||||
|
assert.strictEqual(parsed.title, 'Feature Implementation', 'Should include correct title')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderJson uses custom serializer when provided', () => {
|
||||||
|
const customSchema: OutputSchema<Agent> = {
|
||||||
|
...agentSchema,
|
||||||
|
serialize: (agent) => ({ agentId: agent.id, name: agent.title }),
|
||||||
|
}
|
||||||
|
const customResult: SingleResult<Agent> = {
|
||||||
|
type: 'single',
|
||||||
|
data: testAgents[0],
|
||||||
|
schema: customSchema,
|
||||||
|
}
|
||||||
|
const output = renderJson(customResult, createOutputOptions())
|
||||||
|
const parsed = JSON.parse(output)
|
||||||
|
assert.ok('agentId' in parsed, 'Should use custom serialization')
|
||||||
|
assert.ok('name' in parsed, 'Should use custom field names')
|
||||||
|
})
|
||||||
|
|
||||||
|
// YAML renderer tests
|
||||||
|
console.log('\n=== YAML Renderer ===\n')
|
||||||
|
|
||||||
|
test('renderYaml outputs valid YAML for list', () => {
|
||||||
|
const output = renderYaml(listResult, createOutputOptions())
|
||||||
|
assert.ok(output.includes('- id: abc123'), 'Should format as YAML list')
|
||||||
|
assert.ok(output.includes('title: Feature Implementation'), 'Should include title')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderYaml outputs valid YAML for single item', () => {
|
||||||
|
const output = renderYaml(singleResult, createOutputOptions())
|
||||||
|
assert.ok(output.includes('id: abc123'), 'Should include ID')
|
||||||
|
assert.ok(!output.startsWith('-'), 'Should not be a list')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Quiet renderer tests
|
||||||
|
console.log('\n=== Quiet Renderer ===\n')
|
||||||
|
|
||||||
|
test('renderQuiet outputs only IDs for list', () => {
|
||||||
|
const output = renderQuiet(listResult, createOutputOptions())
|
||||||
|
const lines = output.split('\n')
|
||||||
|
assert.strictEqual(lines.length, 3, 'Should have 3 lines')
|
||||||
|
assert.strictEqual(lines[0], 'abc123', 'First line should be first ID')
|
||||||
|
assert.strictEqual(lines[1], 'def456', 'Second line should be second ID')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderQuiet outputs only ID for single item', () => {
|
||||||
|
const output = renderQuiet(singleResult, createOutputOptions())
|
||||||
|
assert.strictEqual(output, 'abc123', 'Should output only the ID')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderQuiet supports function idField', () => {
|
||||||
|
const customSchema: OutputSchema<Agent> = {
|
||||||
|
...agentSchema,
|
||||||
|
idField: (agent) => `${agent.provider}/${agent.id}`,
|
||||||
|
}
|
||||||
|
const customResult: SingleResult<Agent> = {
|
||||||
|
type: 'single',
|
||||||
|
data: testAgents[0],
|
||||||
|
schema: customSchema,
|
||||||
|
}
|
||||||
|
const output = renderQuiet(customResult, createOutputOptions())
|
||||||
|
assert.strictEqual(output, 'claude/abc123', 'Should use function to extract ID')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Main render dispatcher tests
|
||||||
|
console.log('\n=== Render Dispatcher ===\n')
|
||||||
|
|
||||||
|
test('render uses table format by default', () => {
|
||||||
|
const output = render(listResult, { noColor: true })
|
||||||
|
assert.ok(output.includes('ID'), 'Should use table format with headers')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('render uses json format when specified', () => {
|
||||||
|
const output = render(listResult, { format: 'json' })
|
||||||
|
const parsed = JSON.parse(output)
|
||||||
|
assert.ok(Array.isArray(parsed), 'Should be valid JSON array')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('render uses yaml format when specified', () => {
|
||||||
|
const output = render(listResult, { format: 'yaml' })
|
||||||
|
assert.ok(output.includes('- id:'), 'Should be valid YAML')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('render uses quiet mode when quiet is true', () => {
|
||||||
|
const output = render(listResult, { quiet: true })
|
||||||
|
assert.strictEqual(output, 'abc123\ndef456\nghi789', 'Should output only IDs')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('quiet mode takes precedence over format', () => {
|
||||||
|
const output = render(listResult, { format: 'json', quiet: true })
|
||||||
|
assert.strictEqual(output, 'abc123\ndef456\nghi789', 'Quiet should override format')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Error rendering tests
|
||||||
|
console.log('\n=== Error Rendering ===\n')
|
||||||
|
|
||||||
|
test('renderError formats error for table format', () => {
|
||||||
|
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
|
||||||
|
const output = renderError(error, { noColor: true })
|
||||||
|
assert.ok(output.includes('Error:'), 'Should include Error prefix')
|
||||||
|
assert.ok(output.includes('Agent not found'), 'Should include message')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderError formats error as JSON', () => {
|
||||||
|
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
|
||||||
|
const output = renderError(error, { format: 'json' })
|
||||||
|
const parsed = JSON.parse(output)
|
||||||
|
assert.ok('error' in parsed, 'Should have error property')
|
||||||
|
assert.strictEqual(parsed.error.code, 'NOT_FOUND', 'Should include error code')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderError formats error as YAML', () => {
|
||||||
|
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
|
||||||
|
const output = renderError(error, { format: 'yaml' })
|
||||||
|
assert.ok(output.includes('error:'), 'Should have error key')
|
||||||
|
assert.ok(output.includes('code: NOT_FOUND'), 'Should include error code')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toCommandError converts Error to CommandError', () => {
|
||||||
|
const error = new Error('Something went wrong')
|
||||||
|
const commandError = toCommandError(error)
|
||||||
|
assert.strictEqual(commandError.code, 'UNKNOWN_ERROR', 'Should use UNKNOWN_ERROR code')
|
||||||
|
assert.strictEqual(commandError.message, 'Something went wrong', 'Should preserve message')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toCommandError passes through CommandError', () => {
|
||||||
|
const error: CommandError = { code: 'CUSTOM', message: 'Custom error' }
|
||||||
|
const commandError = toCommandError(error)
|
||||||
|
assert.strictEqual(commandError.code, 'CUSTOM', 'Should preserve code')
|
||||||
|
assert.strictEqual(commandError.message, 'Custom error', 'Should preserve message')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('\n=== Summary ===\n')
|
||||||
|
console.log(`Passed: ${passed}`)
|
||||||
|
console.log(`Failed: ${failed}`)
|
||||||
|
|
||||||
|
process.exit(failed > 0 ? 1 : 0)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 2: Daemon Command Tests
|
||||||
|
*
|
||||||
|
* Tests daemon commands - currently focused on error cases and help
|
||||||
|
* since daemon start may not be fully working yet.
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - daemon --help shows subcommands
|
||||||
|
* - daemon status fails gracefully when daemon not running
|
||||||
|
* - daemon status --format json outputs valid JSON (even for errors)
|
||||||
|
* - daemon stop handles daemon not running gracefully
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Daemon Commands ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: daemon --help shows subcommands
|
||||||
|
{
|
||||||
|
console.log('Test 1: daemon --help shows subcommands')
|
||||||
|
const result = await $`npx paseo daemon --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'daemon --help should exit 0')
|
||||||
|
assert(result.stdout.includes('start'), 'help should mention start')
|
||||||
|
assert(result.stdout.includes('status'), 'help should mention status')
|
||||||
|
assert(result.stdout.includes('stop'), 'help should mention stop')
|
||||||
|
assert(result.stdout.includes('restart'), 'help should mention restart')
|
||||||
|
console.log('✓ daemon --help shows subcommands\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: daemon status fails gracefully when daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 2: daemon status fails gracefully when not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
// The error should mention something about daemon not running or connection
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasDaemonError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('running')
|
||||||
|
assert(hasDaemonError, 'error message should mention daemon/connect/running')
|
||||||
|
console.log('✓ daemon status fails gracefully when not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: daemon status --format json outputs valid JSON (even for errors)
|
||||||
|
{
|
||||||
|
console.log('Test 3: daemon status --format json outputs JSON')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status --format json`.nothrow()
|
||||||
|
// Should still fail (daemon not running)
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
// But output should be valid JSON
|
||||||
|
const output = result.stdout.trim()
|
||||||
|
if (output.length > 0) {
|
||||||
|
try {
|
||||||
|
JSON.parse(output)
|
||||||
|
console.log('✓ daemon status --format json outputs valid JSON\n')
|
||||||
|
} catch {
|
||||||
|
// If stdout is empty, check if stderr has the error (acceptable for now)
|
||||||
|
console.log('✓ daemon status --format json handled error (output may be in stderr)\n')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Empty stdout is acceptable if error is in stderr
|
||||||
|
console.log('✓ daemon status --format json handled error gracefully\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: daemon stop handles daemon not running gracefully
|
||||||
|
{
|
||||||
|
console.log('Test 4: daemon stop handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
|
||||||
|
// Stop should succeed even if daemon not running (idempotent)
|
||||||
|
// OR it should fail gracefully with a clear message
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
// If it succeeds, it should mention the daemon wasn't running
|
||||||
|
const mentionsNotRunning =
|
||||||
|
output.toLowerCase().includes('not running') ||
|
||||||
|
output.toLowerCase().includes('was not running')
|
||||||
|
assert(mentionsNotRunning, 'success output should mention daemon was not running')
|
||||||
|
console.log('✓ daemon stop succeeds gracefully when daemon not running\n')
|
||||||
|
} else {
|
||||||
|
// If it fails, error should be clear
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('not running')
|
||||||
|
assert(hasError, 'error message should be clear about daemon state')
|
||||||
|
console.log('✓ daemon stop fails gracefully when daemon not running\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: daemon restart fails when daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 5: daemon restart fails when daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon restart`.nothrow()
|
||||||
|
// Restart should fail when daemon not running (can't restart something that's not running)
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('not running') ||
|
||||||
|
output.toLowerCase().includes('connect')
|
||||||
|
assert(hasError, 'error message should mention daemon state')
|
||||||
|
console.log('✓ daemon restart fails appropriately when daemon not running\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All daemon tests passed ===')
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3: LS Command Tests
|
||||||
|
*
|
||||||
|
* Tests the ls command - listing agents (top-level command).
|
||||||
|
* Since daemon may not be running, we test both:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - JSON output format
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - paseo --help shows ls command
|
||||||
|
* - paseo ls --help shows options
|
||||||
|
* - paseo ls returns empty list or error when no daemon
|
||||||
|
* - paseo ls --format json returns valid JSON (or error)
|
||||||
|
* - paseo ls -a flag is accepted
|
||||||
|
* - paseo ls -g flag is accepted
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== LS Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: paseo --help shows ls command
|
||||||
|
{
|
||||||
|
console.log('Test 1: paseo --help shows ls command')
|
||||||
|
const result = await $`npx paseo --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||||
|
assert(result.stdout.includes('ls'), 'help should mention ls command')
|
||||||
|
console.log('✓ paseo --help shows ls command\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: paseo ls --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 2: paseo ls --help shows options')
|
||||||
|
const result = await $`npx paseo ls --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'paseo ls --help should exit 0')
|
||||||
|
assert(result.stdout.includes('-a'), 'help should mention -a flag')
|
||||||
|
assert(result.stdout.includes('--all'), 'help should mention --all flag')
|
||||||
|
assert(result.stdout.includes('-g'), 'help should mention -g flag')
|
||||||
|
assert(result.stdout.includes('--global'), 'help should mention --global flag')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
console.log('✓ paseo ls --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: paseo ls returns error when no daemon running
|
||||||
|
{
|
||||||
|
console.log('Test 3: paseo ls handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ paseo ls handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: paseo ls --format json returns valid JSON error
|
||||||
|
{
|
||||||
|
console.log('Test 4: paseo ls --format json handles errors')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls --format json`.nothrow()
|
||||||
|
// Should still fail (daemon not running)
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
// But output should be valid JSON if present
|
||||||
|
const output = result.stdout.trim()
|
||||||
|
if (output.length > 0) {
|
||||||
|
try {
|
||||||
|
JSON.parse(output)
|
||||||
|
console.log('✓ paseo ls --format json outputs valid JSON error\n')
|
||||||
|
} catch {
|
||||||
|
// Empty or stderr-only output is acceptable
|
||||||
|
console.log('✓ paseo ls --format json handled error (output may be in stderr)\n')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ paseo ls --format json handled error gracefully\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: paseo ls -a flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 5: paseo ls -a flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls -a`.nothrow()
|
||||||
|
// Will fail due to no daemon, but flag should be parsed without error
|
||||||
|
// (no "unknown option" error)
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -a flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ paseo ls -a flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: paseo ls -g flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 6: paseo ls -g flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls -g`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -g flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ paseo ls -g flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: paseo ls -ag combined flags are accepted
|
||||||
|
{
|
||||||
|
console.log('Test 7: paseo ls -ag combined flags are accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls -ag`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -ag flags')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ paseo ls -ag combined flags are accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: -q (quiet) flag is accepted globally
|
||||||
|
{
|
||||||
|
console.log('Test 8: -q (quiet) flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q ls`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ -q (quiet) flag is accepted\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All ls tests passed ===')
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 4: Run Command Tests
|
||||||
|
*
|
||||||
|
* Tests the run command - creating and running agents with tasks (top-level command).
|
||||||
|
* Since daemon may not be running, we test both:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - All flags are accepted
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - run --help shows options
|
||||||
|
* - run requires prompt argument
|
||||||
|
* - run handles daemon not running
|
||||||
|
* - run -d flag is accepted
|
||||||
|
* - run --name flag is accepted
|
||||||
|
* - run --provider flag is accepted
|
||||||
|
* - run --mode flag is accepted
|
||||||
|
* - run --cwd flag is accepted
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Run Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: run --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 1: run --help shows options')
|
||||||
|
const result = await $`npx paseo run --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'run --help should exit 0')
|
||||||
|
assert(result.stdout.includes('-d'), 'help should mention -d flag')
|
||||||
|
assert(result.stdout.includes('--detach'), 'help should mention --detach flag')
|
||||||
|
assert(result.stdout.includes('--name'), 'help should mention --name option')
|
||||||
|
assert(result.stdout.includes('--provider'), 'help should mention --provider option')
|
||||||
|
assert(result.stdout.includes('--mode'), 'help should mention --mode option')
|
||||||
|
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument')
|
||||||
|
console.log('✓ run --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: run requires prompt argument
|
||||||
|
{
|
||||||
|
console.log('Test 2: run requires prompt argument')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without prompt')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
// Commander should complain about missing argument
|
||||||
|
const hasMissingArg =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('argument')
|
||||||
|
assert(hasMissingArg, 'error should mention missing argument')
|
||||||
|
console.log('✓ run requires prompt argument\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: run handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 3: run handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run "test prompt"`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ run handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: run -d flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 4: run -d flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d "test prompt"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -d flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ run -d flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: run --name flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 5: run --name flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --name "test-agent" "test prompt"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --name flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ run --name flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: run --provider flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 6: run --provider flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex "test prompt"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --provider flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ run --provider flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: run --mode flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 7: run --mode flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --mode bypass "test prompt"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --mode flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ run --mode flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: run --cwd flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 8: run --cwd flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --cwd /tmp "test prompt"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --cwd flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ run --cwd flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: -q (quiet) flag is accepted with run
|
||||||
|
{
|
||||||
|
console.log('Test 9: -q (quiet) flag is accepted with run')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ -q (quiet) flag is accepted with run\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: Combined flags work together
|
||||||
|
{
|
||||||
|
console.log('Test 10: Combined flags work together')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept all combined flags')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ Combined flags work together\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11: paseo --help shows run command
|
||||||
|
{
|
||||||
|
console.log('Test 11: paseo --help shows run command')
|
||||||
|
const result = await $`npx paseo --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||||
|
assert(result.stdout.includes('run'), 'help should mention run command')
|
||||||
|
console.log('✓ paseo --help shows run command\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All run tests passed ===')
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5: Send Command Tests
|
||||||
|
*
|
||||||
|
* Tests the send command - sending messages to existing agents (top-level command).
|
||||||
|
* Since daemon may not be running, we test both:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - All flags are accepted
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - send --help shows options
|
||||||
|
* - send requires id and prompt arguments
|
||||||
|
* - send handles daemon not running
|
||||||
|
* - send --no-wait flag is accepted
|
||||||
|
* - agent shows send in subcommands
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Send Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: send --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 1: send --help shows options')
|
||||||
|
const result = await $`npx paseo send --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'send --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--no-wait'), 'help should mention --no-wait flag')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('<id>'), 'help should mention id argument')
|
||||||
|
assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument')
|
||||||
|
console.log(' help should mention --no-wait flag')
|
||||||
|
console.log(' help should mention --host option')
|
||||||
|
console.log(' help should mention <id> argument')
|
||||||
|
console.log(' help should mention <prompt> argument')
|
||||||
|
console.log('✓ send --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: send requires id argument
|
||||||
|
{
|
||||||
|
console.log('Test 2: send requires id argument')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
// Commander should complain about missing argument
|
||||||
|
const hasMissingArg =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('argument')
|
||||||
|
assert(hasMissingArg, 'error should mention missing argument')
|
||||||
|
console.log('✓ send requires id argument\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: send requires prompt argument
|
||||||
|
{
|
||||||
|
console.log('Test 3: send requires prompt argument')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without prompt')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
// Commander should complain about missing argument
|
||||||
|
const hasMissingArg =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('argument')
|
||||||
|
assert(hasMissingArg, 'error should mention missing argument')
|
||||||
|
console.log('✓ send requires prompt argument\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: send handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 4: send handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "test prompt"`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ send handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: send --no-wait flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 5: send --no-wait flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --no-wait abc123 "test prompt"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --no-wait flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ send --no-wait flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: send --host flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 6: send --host flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOME=${paseoHome} npx paseo send --host localhost:${port} abc123 "test prompt"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ send --host flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: -q (quiet) flag is accepted with send
|
||||||
|
{
|
||||||
|
console.log('Test 7: -q (quiet) flag is accepted with send')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "test prompt"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ -q (quiet) flag is accepted with send\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: Combined flags work together
|
||||||
|
{
|
||||||
|
console.log('Test 8: Combined flags work together')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "Run the linter"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept all combined flags')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ Combined flags work together\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: paseo --help shows send command
|
||||||
|
{
|
||||||
|
console.log('Test 9: paseo --help shows send command')
|
||||||
|
const result = await $`npx paseo --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||||
|
assert(result.stdout.includes('send'), 'help should mention send command')
|
||||||
|
console.log('✓ paseo --help shows send command\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: ID prefix syntax is mentioned in help
|
||||||
|
{
|
||||||
|
console.log('Test 10: send command description mentions ID')
|
||||||
|
const result = await $`npx paseo send --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'send --help should exit 0')
|
||||||
|
const hasIdMention =
|
||||||
|
result.stdout.toLowerCase().includes('id') ||
|
||||||
|
result.stdout.toLowerCase().includes('prefix')
|
||||||
|
assert(hasIdMention, 'help should mention ID or prefix')
|
||||||
|
console.log('✓ send command description mentions ID\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All send tests passed ===')
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 6: Stop Command Tests
|
||||||
|
*
|
||||||
|
* Tests the stop command - stopping agents (cancel if running, then terminate) (top-level command).
|
||||||
|
* Since daemon may not be running, we test both:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - All flags are accepted
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - stop --help shows options
|
||||||
|
* - stop requires ID, --all, or --cwd
|
||||||
|
* - stop handles daemon not running
|
||||||
|
* - stop --all flag is accepted
|
||||||
|
* - stop --cwd flag is accepted
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Stop Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: stop --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 1: stop --help shows options')
|
||||||
|
const result = await $`npx paseo stop --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'stop --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--all'), 'help should mention --all flag')
|
||||||
|
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('[id]'), 'help should mention optional id argument')
|
||||||
|
console.log('✓ stop --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: stop requires ID, --all, or --cwd
|
||||||
|
{
|
||||||
|
console.log('Test 2: stop requires ID, --all, or --cwd')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without id, --all, or --cwd')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('argument') ||
|
||||||
|
output.toLowerCase().includes('id')
|
||||||
|
assert(hasError, 'error should mention missing argument')
|
||||||
|
console.log('✓ stop requires ID, --all, or --cwd\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: stop handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 3: stop handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ stop handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: stop --all flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 4: stop --all flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --all`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --all flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ stop --all flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: stop --cwd flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 5: stop --cwd flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --cwd /tmp`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --cwd flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ stop --cwd flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: stop with ID and --host flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 6: stop with ID and --host flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123 --host localhost:${port}`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ stop with ID and --host flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: paseo --help shows stop command
|
||||||
|
{
|
||||||
|
console.log('Test 7: paseo --help shows stop command')
|
||||||
|
const result = await $`npx paseo --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||||
|
assert(result.stdout.includes('stop'), 'help should mention stop command')
|
||||||
|
console.log('✓ paseo --help shows stop command\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: -q (quiet) flag is accepted with stop
|
||||||
|
{
|
||||||
|
console.log('Test 8: -q (quiet) flag is accepted with stop')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q stop abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ -q (quiet) flag is accepted with stop\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All stop tests passed ===')
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 7: Logs Command Tests
|
||||||
|
*
|
||||||
|
* Tests the logs command - viewing agent activity/timeline (top-level command).
|
||||||
|
* Since daemon may not be running, we test both:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - All flags are accepted
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - logs --help shows options
|
||||||
|
* - logs requires ID argument
|
||||||
|
* - logs handles daemon not running
|
||||||
|
* - logs -f (follow) flag is accepted
|
||||||
|
* - logs --tail flag is accepted
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Logs Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: logs --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 1: logs --help shows options')
|
||||||
|
const result = await $`npx paseo logs --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'logs --help should exit 0')
|
||||||
|
assert(result.stdout.includes('-f') || result.stdout.includes('--follow'), 'help should mention -f/--follow flag')
|
||||||
|
assert(result.stdout.includes('--tail'), 'help should mention --tail option')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
|
||||||
|
console.log('✓ logs --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: logs requires ID argument
|
||||||
|
{
|
||||||
|
console.log('Test 2: logs requires ID argument')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('argument') ||
|
||||||
|
output.toLowerCase().includes('id')
|
||||||
|
assert(hasError, 'error should mention missing argument')
|
||||||
|
console.log('✓ logs requires ID argument\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: logs handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 3: logs handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ logs handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: logs -f (follow) flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 4: logs -f (follow) flag is accepted')
|
||||||
|
// Use timeout to avoid hanging on follow mode
|
||||||
|
const result =
|
||||||
|
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs -f abc123' || true`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -f flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ logs -f (follow) flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: logs --follow flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 5: logs --follow flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --follow abc123' || true`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --follow flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ logs --follow flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: logs --tail flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 6: logs --tail flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --tail 50 abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --tail flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ logs --tail flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: logs with ID and --host flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 7: logs with ID and --host flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123 --host localhost:${port}`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ logs with ID and --host flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: paseo --help shows logs command
|
||||||
|
{
|
||||||
|
console.log('Test 8: paseo --help shows logs command')
|
||||||
|
const result = await $`npx paseo --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||||
|
assert(result.stdout.includes('logs'), 'help should mention logs command')
|
||||||
|
console.log('✓ paseo --help shows logs command\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: -q (quiet) flag is accepted with logs
|
||||||
|
{
|
||||||
|
console.log('Test 9: -q (quiet) flag is accepted with logs')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q logs abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ -q (quiet) flag is accepted with logs\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All logs tests passed ===')
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 9: Inspect Command Tests
|
||||||
|
*
|
||||||
|
* Tests the inspect command - showing detailed agent information (top-level command).
|
||||||
|
* Since daemon may not be running, we test both:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - All flags are accepted
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - inspect --help shows options
|
||||||
|
* - inspect requires id argument
|
||||||
|
* - inspect handles daemon not running
|
||||||
|
* - inspect --host flag is accepted
|
||||||
|
* - agent shows inspect in subcommands
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Inspect Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: inspect --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 1: inspect --help shows options')
|
||||||
|
const result = await $`npx paseo inspect --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('<id>'), 'help should mention id argument')
|
||||||
|
console.log(' help should mention --host option')
|
||||||
|
console.log(' help should mention <id> argument')
|
||||||
|
console.log('inspect --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: inspect requires id argument
|
||||||
|
{
|
||||||
|
console.log('Test 2: inspect requires id argument')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
// Commander should complain about missing argument
|
||||||
|
const hasMissingArg =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('argument')
|
||||||
|
assert(hasMissingArg, 'error should mention missing argument')
|
||||||
|
console.log('inspect requires id argument\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: inspect handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 3: inspect handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('inspect handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: inspect --host flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 4: inspect --host flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOME=${paseoHome} npx paseo inspect --host localhost:${port} abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('inspect --host flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: -q (quiet) flag is accepted with inspect
|
||||||
|
{
|
||||||
|
console.log('Test 5: -q (quiet) flag is accepted with inspect')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q inspect abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('-q (quiet) flag is accepted with inspect\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: --format json flag is accepted with inspect
|
||||||
|
{
|
||||||
|
console.log('Test 6: --format json flag is accepted with inspect')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format json inspect abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --format json flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('--format json flag is accepted with inspect\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: --format yaml flag is accepted with inspect
|
||||||
|
{
|
||||||
|
console.log('Test 7: --format yaml flag is accepted with inspect')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml inspect abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --format yaml flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('--format yaml flag is accepted with inspect\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: paseo --help shows inspect command
|
||||||
|
{
|
||||||
|
console.log('Test 8: paseo --help shows inspect command')
|
||||||
|
const result = await $`npx paseo --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||||
|
assert(result.stdout.includes('inspect'), 'help should mention inspect command')
|
||||||
|
console.log('paseo --help shows inspect command\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: inspect command description is helpful
|
||||||
|
{
|
||||||
|
console.log('Test 9: inspect command description is helpful')
|
||||||
|
const result = await $`npx paseo inspect --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
|
||||||
|
const hasDescription =
|
||||||
|
result.stdout.toLowerCase().includes('detail') ||
|
||||||
|
result.stdout.toLowerCase().includes('information') ||
|
||||||
|
result.stdout.toLowerCase().includes('show')
|
||||||
|
assert(hasDescription, 'help should describe what inspect does')
|
||||||
|
console.log('inspect command description is helpful\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: ID prefix syntax is mentioned in help
|
||||||
|
{
|
||||||
|
console.log('Test 10: inspect command mentions ID')
|
||||||
|
const result = await $`npx paseo inspect --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
|
||||||
|
const hasIdMention =
|
||||||
|
result.stdout.toLowerCase().includes('id') ||
|
||||||
|
result.stdout.toLowerCase().includes('prefix')
|
||||||
|
assert(hasIdMention, 'help should mention ID or prefix')
|
||||||
|
console.log('inspect command mentions ID\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All inspect tests passed ===')
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 10: Agent Mode Command Tests
|
||||||
|
*
|
||||||
|
* Tests the agent mode command - changing and listing agent operational modes.
|
||||||
|
* Since daemon may not be running, we test both:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - All flags are accepted
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - agent mode --help shows options
|
||||||
|
* - agent mode requires id argument
|
||||||
|
* - agent mode handles daemon not running
|
||||||
|
* - agent mode --list flag is accepted
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Agent Mode Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: agent mode --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 1: agent mode --help shows options')
|
||||||
|
const result = await $`npx paseo agent mode --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent mode --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--list'), 'help should mention --list flag')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('<id>'), 'help should mention id argument')
|
||||||
|
assert(result.stdout.includes('[mode]'), 'help should mention optional mode argument')
|
||||||
|
console.log('✓ agent mode --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: agent mode requires id argument
|
||||||
|
{
|
||||||
|
console.log('Test 2: agent mode requires id argument')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('argument') ||
|
||||||
|
output.toLowerCase().includes('id')
|
||||||
|
assert(hasError, 'error should mention missing argument')
|
||||||
|
console.log('✓ agent mode requires id argument\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: agent mode handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 3: agent mode handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 bypass`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ agent mode handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: agent mode --list flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 4: agent mode --list flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode --list abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --list flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ agent mode --list flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: agent mode with ID and --host flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 5: agent mode with ID and --host flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 plan --host localhost:${port}`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ agent mode with ID and --host flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: agent shows mode in subcommands
|
||||||
|
{
|
||||||
|
console.log('Test 6: agent --help shows mode subcommand')
|
||||||
|
const result = await $`npx paseo agent --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
|
||||||
|
assert(result.stdout.includes('mode'), 'help should mention mode subcommand')
|
||||||
|
console.log('✓ agent --help shows mode subcommand\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: -q (quiet) flag is accepted with agent mode
|
||||||
|
{
|
||||||
|
console.log('Test 7: -q (quiet) flag is accepted with agent mode')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent mode abc123 bypass`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ -q (quiet) flag is accepted with agent mode\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: agent mode requires mode argument when not using --list
|
||||||
|
{
|
||||||
|
console.log('Test 8: agent mode requires mode argument when not using --list')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123`.nothrow()
|
||||||
|
// Should fail because mode is required unless --list is specified
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without mode argument')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('mode') ||
|
||||||
|
output.toLowerCase().includes('daemon') // If daemon error comes first, that's also valid
|
||||||
|
assert(hasError, 'error should mention missing mode or connection issue')
|
||||||
|
console.log('✓ agent mode requires mode argument when not using --list\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All agent mode tests passed ===')
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 11: Agent Archive Command Tests
|
||||||
|
*
|
||||||
|
* Tests the agent archive command - archiving (soft-delete) agents.
|
||||||
|
* Since daemon may not be running, we test both:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - All flags are accepted
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - agent archive --help shows options
|
||||||
|
* - agent archive requires ID argument
|
||||||
|
* - agent archive handles daemon not running
|
||||||
|
* - agent archive --force flag is accepted
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Agent Archive Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: agent archive --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 1: agent archive --help shows options')
|
||||||
|
const result = await $`npx paseo agent archive --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent archive --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--force'), 'help should mention --force flag')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
|
||||||
|
console.log('✓ agent archive --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: agent archive requires ID argument
|
||||||
|
{
|
||||||
|
console.log('Test 2: agent archive requires ID argument')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('argument')
|
||||||
|
assert(hasError, 'error should mention missing argument')
|
||||||
|
console.log('✓ agent archive requires ID argument\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: agent archive handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 3: agent archive handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ agent archive handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: agent archive --force flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 4: agent archive --force flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --force`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --force flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ agent archive --force flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: agent archive with ID and --host flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 5: agent archive with ID and --host flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --host localhost:${port}`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ agent archive with ID and --host flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: agent shows archive in subcommands
|
||||||
|
{
|
||||||
|
console.log('Test 6: agent --help shows archive subcommand')
|
||||||
|
const result = await $`npx paseo agent --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
|
||||||
|
assert(result.stdout.includes('archive'), 'help should mention archive subcommand')
|
||||||
|
console.log('✓ agent --help shows archive subcommand\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: -q (quiet) flag is accepted with agent archive
|
||||||
|
{
|
||||||
|
console.log('Test 7: -q (quiet) flag is accepted with agent archive')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent archive abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ -q (quiet) flag is accepted with agent archive\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All agent archive tests passed ===')
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 11: Wait Command Tests
|
||||||
|
*
|
||||||
|
* Tests the wait command - waiting for an agent to become idle (top-level command).
|
||||||
|
* Since daemon may not be running, we test both:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - All flags are accepted
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - wait --help shows options
|
||||||
|
* - wait requires id argument
|
||||||
|
* - wait handles daemon not running
|
||||||
|
* - wait --timeout flag is accepted
|
||||||
|
* - wait --host flag is accepted
|
||||||
|
* - agent shows wait in subcommands
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Wait Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: wait --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 1: wait --help shows options')
|
||||||
|
const result = await $`npx paseo wait --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('--timeout'), 'help should mention --timeout option')
|
||||||
|
assert(result.stdout.includes('<id>'), 'help should mention id argument')
|
||||||
|
console.log(' help should mention --host option')
|
||||||
|
console.log(' help should mention --timeout option')
|
||||||
|
console.log(' help should mention <id> argument')
|
||||||
|
console.log('wait --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: wait requires id argument
|
||||||
|
{
|
||||||
|
console.log('Test 2: wait requires id argument')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
// Commander should complain about missing argument
|
||||||
|
const hasMissingArg =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('argument')
|
||||||
|
assert(hasMissingArg, 'error should mention missing argument')
|
||||||
|
console.log('wait requires id argument\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: wait handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 3: wait handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('wait handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: wait --timeout flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 4: wait --timeout flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOME=${paseoHome} npx paseo wait --timeout 30 --host localhost:${port} abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --timeout flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('wait --timeout flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: wait --host flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 5: wait --host flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOME=${paseoHome} npx paseo wait --host localhost:${port} abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('wait --host flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: -q (quiet) flag is accepted with wait
|
||||||
|
{
|
||||||
|
console.log('Test 6: -q (quiet) flag is accepted with wait')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q wait abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('-q (quiet) flag is accepted with wait\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: --format json flag is accepted with wait
|
||||||
|
{
|
||||||
|
console.log('Test 7: --format json flag is accepted with wait')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format json wait abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --format json flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('--format json flag is accepted with wait\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: --format yaml flag is accepted with wait
|
||||||
|
{
|
||||||
|
console.log('Test 8: --format yaml flag is accepted with wait')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml wait abc123`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --format yaml flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('--format yaml flag is accepted with wait\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: paseo --help shows wait command
|
||||||
|
{
|
||||||
|
console.log('Test 9: paseo --help shows wait command')
|
||||||
|
const result = await $`npx paseo --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||||
|
assert(result.stdout.includes('wait'), 'help should mention wait command')
|
||||||
|
console.log('paseo --help shows wait command\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: wait command description is helpful
|
||||||
|
{
|
||||||
|
console.log('Test 10: wait command description is helpful')
|
||||||
|
const result = await $`npx paseo wait --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
|
||||||
|
const hasDescription =
|
||||||
|
result.stdout.toLowerCase().includes('wait') ||
|
||||||
|
result.stdout.toLowerCase().includes('idle')
|
||||||
|
assert(hasDescription, 'help should describe what wait does')
|
||||||
|
console.log('wait command description is helpful\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11: ID prefix syntax is mentioned in help
|
||||||
|
{
|
||||||
|
console.log('Test 11: wait command mentions ID')
|
||||||
|
const result = await $`npx paseo wait --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
|
||||||
|
const hasIdMention =
|
||||||
|
result.stdout.toLowerCase().includes('id') ||
|
||||||
|
result.stdout.toLowerCase().includes('prefix')
|
||||||
|
assert(hasIdMention, 'help should mention ID or prefix')
|
||||||
|
console.log('wait command mentions ID\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 12: timeout option has default value documented
|
||||||
|
{
|
||||||
|
console.log('Test 12: timeout option has default value documented')
|
||||||
|
const result = await $`npx paseo wait --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
|
||||||
|
const hasDefault =
|
||||||
|
result.stdout.includes('600') || result.stdout.toLowerCase().includes('default')
|
||||||
|
assert(hasDefault, 'help should mention timeout default value')
|
||||||
|
console.log('timeout option has default value documented\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All wait tests passed ===')
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permit LS Command Tests
|
||||||
|
*
|
||||||
|
* Tests the permit ls command - listing pending permissions.
|
||||||
|
* Since daemon may not be running, we test:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - JSON output format
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - permit --help shows subcommands
|
||||||
|
* - permit ls --help shows options
|
||||||
|
* - permit ls returns error when no daemon
|
||||||
|
* - permit ls --format json handles errors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Permit LS Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: permit --help shows subcommands
|
||||||
|
{
|
||||||
|
console.log('Test 1: permit --help shows subcommands')
|
||||||
|
const result = await $`npx paseo permit --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'permit --help should exit 0')
|
||||||
|
assert(result.stdout.includes('ls'), 'help should mention ls subcommand')
|
||||||
|
assert(result.stdout.includes('allow'), 'help should mention allow subcommand')
|
||||||
|
assert(result.stdout.includes('deny'), 'help should mention deny subcommand')
|
||||||
|
console.log('✓ permit --help shows subcommands\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: permit ls --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 2: permit ls --help shows options')
|
||||||
|
const result = await $`npx paseo permit ls --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'permit ls --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
console.log('✓ permit ls --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: permit ls returns error when no daemon running
|
||||||
|
{
|
||||||
|
console.log('Test 3: permit ls handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ permit ls handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: permit ls --format json handles errors
|
||||||
|
{
|
||||||
|
console.log('Test 4: permit ls --format json handles errors')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --format json`.nothrow()
|
||||||
|
// Should still fail (daemon not running)
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
// But output should be valid JSON if present
|
||||||
|
const output = result.stdout.trim()
|
||||||
|
if (output.length > 0) {
|
||||||
|
try {
|
||||||
|
JSON.parse(output)
|
||||||
|
console.log('✓ permit ls --format json outputs valid JSON error\n')
|
||||||
|
} catch {
|
||||||
|
// Empty or stderr-only output is acceptable
|
||||||
|
console.log('✓ permit ls --format json handled error (output may be in stderr)\n')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ permit ls --format json handled error gracefully\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: -q (quiet) flag is accepted globally
|
||||||
|
{
|
||||||
|
console.log('Test 5: -q (quiet) flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q permit ls`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ -q (quiet) flag is accepted\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All permit ls tests passed ===')
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permit Allow/Deny Command Tests
|
||||||
|
*
|
||||||
|
* Tests the permit allow and deny commands.
|
||||||
|
* Since daemon may not be running, we test:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - Flag acceptance
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - permit allow --help shows options
|
||||||
|
* - permit deny --help shows options
|
||||||
|
* - permit allow handles daemon not running
|
||||||
|
* - permit deny handles daemon not running
|
||||||
|
* - permit allow --all flag is accepted
|
||||||
|
* - permit deny --all flag is accepted
|
||||||
|
* - permit deny --message flag is accepted
|
||||||
|
* - permit deny --interrupt flag is accepted
|
||||||
|
* - permit allow --input flag is accepted
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Permit Allow/Deny Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: permit allow --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 1: permit allow --help shows options')
|
||||||
|
const result = await $`npx paseo permit allow --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'permit allow --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--all'), 'help should mention --all flag')
|
||||||
|
assert(result.stdout.includes('--input'), 'help should mention --input option')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('<agent>'), 'help should mention agent argument')
|
||||||
|
console.log('✓ permit allow --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: permit deny --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 2: permit deny --help shows options')
|
||||||
|
const result = await $`npx paseo permit deny --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'permit deny --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--all'), 'help should mention --all flag')
|
||||||
|
assert(result.stdout.includes('--message'), 'help should mention --message option')
|
||||||
|
assert(result.stdout.includes('--interrupt'), 'help should mention --interrupt flag')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('<agent>'), 'help should mention agent argument')
|
||||||
|
console.log('✓ permit deny --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: permit allow handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 3: permit allow handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ permit allow handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: permit deny handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 4: permit deny handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ permit deny handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: permit allow --all flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 5: permit allow --all flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 --all`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --all flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ permit allow --all flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: permit deny --all flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 6: permit deny --all flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 --all`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --all flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ permit deny --all flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: permit deny --message flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 7: permit deny --message flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --message "Not allowed"`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --message flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ permit deny --message flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: permit deny --interrupt flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 8: permit deny --interrupt flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --interrupt`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --interrupt flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ permit deny --interrupt flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: permit allow --input flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 9: permit allow --input flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456 --input '{"key":"value"}'`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --input flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ permit allow --input flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: permit allow without req_id and without --all fails gracefully
|
||||||
|
{
|
||||||
|
console.log('Test 10: permit allow requires req_id or --all')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123`.nothrow()
|
||||||
|
// This might fail due to daemon not running first, or due to missing argument
|
||||||
|
// The important thing is it doesn't crash with an unhandled error
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without req_id or --all')
|
||||||
|
console.log('✓ permit allow requires req_id or --all\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11: permit deny without req_id and without --all fails gracefully
|
||||||
|
{
|
||||||
|
console.log('Test 11: permit deny requires req_id or --all')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without req_id or --all')
|
||||||
|
console.log('✓ permit deny requires req_id or --all\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All permit allow/deny tests passed ===')
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 14: Worktree Command Tests
|
||||||
|
*
|
||||||
|
* Tests the worktree commands for managing Paseo-managed git worktrees.
|
||||||
|
* Since daemon may not be running, we test both:
|
||||||
|
* - Help and argument parsing
|
||||||
|
* - Graceful error handling when daemon not running
|
||||||
|
* - All flags are accepted
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - worktree --help shows subcommands
|
||||||
|
* - worktree ls --help shows options
|
||||||
|
* - worktree ls handles daemon not running
|
||||||
|
* - worktree archive --help shows options
|
||||||
|
* - worktree archive requires name argument
|
||||||
|
* - worktree archive handles daemon not running
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Worktree Command Tests ===\n')
|
||||||
|
|
||||||
|
// Get random port that's definitely not in use (never 6767)
|
||||||
|
const port = 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: worktree --help shows subcommands
|
||||||
|
{
|
||||||
|
console.log('Test 1: worktree --help shows subcommands')
|
||||||
|
const result = await $`npx paseo worktree --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'worktree --help should exit 0')
|
||||||
|
assert(result.stdout.includes('ls'), 'help should mention ls subcommand')
|
||||||
|
assert(result.stdout.includes('archive'), 'help should mention archive subcommand')
|
||||||
|
console.log('✓ worktree --help shows subcommands\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: worktree ls --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 2: worktree ls --help shows options')
|
||||||
|
const result = await $`npx paseo worktree ls --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'worktree ls --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
console.log('✓ worktree ls --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: worktree ls handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 3: worktree ls handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ worktree ls handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: worktree ls with --host flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 4: worktree ls with --host flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --host localhost:${port}`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ worktree ls with --host flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: worktree archive --help shows options
|
||||||
|
{
|
||||||
|
console.log('Test 5: worktree archive --help shows options')
|
||||||
|
const result = await $`npx paseo worktree archive --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'worktree archive --help should exit 0')
|
||||||
|
assert(result.stdout.includes('--host'), 'help should mention --host option')
|
||||||
|
assert(result.stdout.includes('<name>'), 'help should mention required name argument')
|
||||||
|
console.log('✓ worktree archive --help shows options\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: worktree archive requires name argument
|
||||||
|
{
|
||||||
|
console.log('Test 6: worktree archive requires name argument')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail without name')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('missing') ||
|
||||||
|
output.toLowerCase().includes('required') ||
|
||||||
|
output.toLowerCase().includes('argument')
|
||||||
|
assert(hasError, 'error should mention missing argument')
|
||||||
|
console.log('✓ worktree archive requires name argument\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: worktree archive handles daemon not running
|
||||||
|
{
|
||||||
|
console.log('Test 7: worktree archive handles daemon not running')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree`.nothrow()
|
||||||
|
// Should fail because daemon not running
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
const hasError =
|
||||||
|
output.toLowerCase().includes('daemon') ||
|
||||||
|
output.toLowerCase().includes('connect') ||
|
||||||
|
output.toLowerCase().includes('cannot')
|
||||||
|
assert(hasError, 'error message should mention connection issue')
|
||||||
|
console.log('✓ worktree archive handles daemon not running\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: worktree archive with name and --host flag is accepted
|
||||||
|
{
|
||||||
|
console.log('Test 8: worktree archive with name and --host flag is accepted')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree --host localhost:${port}`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept --host flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ worktree archive with name and --host flag is accepted\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: -q (quiet) flag is accepted with worktree ls
|
||||||
|
{
|
||||||
|
console.log('Test 9: -q (quiet) flag is accepted with worktree ls')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q worktree ls`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -q flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ -q (quiet) flag is accepted with worktree ls\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: -f json flag is accepted with worktree ls
|
||||||
|
{
|
||||||
|
console.log('Test 10: -f json flag is accepted with worktree ls')
|
||||||
|
const result =
|
||||||
|
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -f json worktree ls`.nothrow()
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(!output.includes('unknown option'), 'should accept -f json flag')
|
||||||
|
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||||
|
console.log('✓ -f json flag is accepted with worktree ls\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11: paseo --help shows worktree subcommand
|
||||||
|
{
|
||||||
|
console.log('Test 11: paseo --help shows worktree subcommand')
|
||||||
|
const result = await $`npx paseo --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
|
||||||
|
assert(result.stdout.includes('worktree'), 'help should mention worktree subcommand')
|
||||||
|
console.log('✓ paseo --help shows worktree subcommand\n')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All worktree tests passed ===')
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 15: Provider Command Tests
|
||||||
|
*
|
||||||
|
* Tests provider commands for listing providers and models.
|
||||||
|
* Provider data is static and doesn't require a running daemon.
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - provider --help shows subcommands
|
||||||
|
* - provider ls lists all providers
|
||||||
|
* - provider ls --format json outputs valid JSON
|
||||||
|
* - provider ls --quiet outputs provider names only
|
||||||
|
* - provider models claude lists claude models
|
||||||
|
* - provider models codex lists codex models
|
||||||
|
* - provider models opencode lists opencode models
|
||||||
|
* - provider models unknown fails with error
|
||||||
|
* - provider models --format json outputs valid JSON
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { $ } from 'zx'
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('=== Provider Commands ===\n')
|
||||||
|
|
||||||
|
// Test 1: provider --help shows subcommands
|
||||||
|
{
|
||||||
|
console.log('Test 1: provider --help shows subcommands')
|
||||||
|
const result = await $`npx paseo provider --help`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'provider --help should exit 0')
|
||||||
|
assert(result.stdout.includes('ls'), 'help should mention ls')
|
||||||
|
assert(result.stdout.includes('models'), 'help should mention models')
|
||||||
|
console.log('✓ provider --help shows subcommands\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: provider ls lists all providers
|
||||||
|
{
|
||||||
|
console.log('Test 2: provider ls lists all providers')
|
||||||
|
const result = await $`npx paseo provider ls`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'provider ls should exit 0')
|
||||||
|
assert(result.stdout.includes('claude'), 'output should include claude')
|
||||||
|
assert(result.stdout.includes('codex'), 'output should include codex')
|
||||||
|
assert(result.stdout.includes('opencode'), 'output should include opencode')
|
||||||
|
assert(result.stdout.includes('available'), 'output should show available status')
|
||||||
|
console.log('✓ provider ls lists all providers\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: provider ls --format json outputs valid JSON
|
||||||
|
{
|
||||||
|
console.log('Test 3: provider ls --format json outputs valid JSON')
|
||||||
|
const result = await $`npx paseo provider ls --format json`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
||||||
|
const data = JSON.parse(result.stdout.trim())
|
||||||
|
assert(Array.isArray(data), 'output should be an array')
|
||||||
|
assert.strictEqual(data.length, 3, 'should have 3 providers')
|
||||||
|
assert(data.some((p: { provider: string }) => p.provider === 'claude'), 'should include claude')
|
||||||
|
assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex')
|
||||||
|
assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode')
|
||||||
|
console.log('✓ provider ls --format json outputs valid JSON\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: provider ls --quiet outputs provider names only
|
||||||
|
{
|
||||||
|
console.log('Test 4: provider ls --quiet outputs provider names only')
|
||||||
|
const result = await $`npx paseo provider ls --quiet`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
||||||
|
const lines = result.stdout.trim().split('\n')
|
||||||
|
assert.strictEqual(lines.length, 3, 'should have 3 lines')
|
||||||
|
assert(lines.includes('claude'), 'should include claude')
|
||||||
|
assert(lines.includes('codex'), 'should include codex')
|
||||||
|
assert(lines.includes('opencode'), 'should include opencode')
|
||||||
|
console.log('✓ provider ls --quiet outputs provider names only\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: provider models claude lists claude models
|
||||||
|
{
|
||||||
|
console.log('Test 5: provider models claude lists claude models')
|
||||||
|
const result = await $`npx paseo provider models claude`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'provider models claude should exit 0')
|
||||||
|
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude-sonnet-4')
|
||||||
|
assert(result.stdout.includes('claude-opus-4-20250514'), 'output should include claude-opus-4')
|
||||||
|
assert(result.stdout.includes('claude-3-5-haiku-20241022'), 'output should include claude-haiku')
|
||||||
|
console.log('✓ provider models claude lists claude models\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: provider models codex lists codex models
|
||||||
|
{
|
||||||
|
console.log('Test 6: provider models codex lists codex models')
|
||||||
|
const result = await $`npx paseo provider models codex`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'provider models codex should exit 0')
|
||||||
|
assert(result.stdout.includes('o3-mini'), 'output should include o3-mini')
|
||||||
|
assert(result.stdout.includes('o4-mini'), 'output should include o4-mini')
|
||||||
|
console.log('✓ provider models codex lists codex models\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: provider models opencode lists opencode models
|
||||||
|
{
|
||||||
|
console.log('Test 7: provider models opencode lists opencode models')
|
||||||
|
const result = await $`npx paseo provider models opencode`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'provider models opencode should exit 0')
|
||||||
|
// opencode supports both claude and codex models
|
||||||
|
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude models')
|
||||||
|
assert(result.stdout.includes('o3-mini'), 'output should include codex models')
|
||||||
|
console.log('✓ provider models opencode lists opencode models\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: provider models unknown fails with error
|
||||||
|
{
|
||||||
|
console.log('Test 8: provider models unknown fails with error')
|
||||||
|
const result = await $`npx paseo provider models unknown`.nothrow()
|
||||||
|
assert.notStrictEqual(result.exitCode, 0, 'should fail for unknown provider')
|
||||||
|
const output = result.stdout + result.stderr
|
||||||
|
assert(
|
||||||
|
output.toLowerCase().includes('unknown') || output.toLowerCase().includes('provider'),
|
||||||
|
'error should mention unknown provider'
|
||||||
|
)
|
||||||
|
console.log('✓ provider models unknown fails with error\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: provider models --format json outputs valid JSON
|
||||||
|
{
|
||||||
|
console.log('Test 9: provider models --format json outputs valid JSON')
|
||||||
|
const result = await $`npx paseo provider models claude --format json`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
||||||
|
const data = JSON.parse(result.stdout.trim())
|
||||||
|
assert(Array.isArray(data), 'output should be an array')
|
||||||
|
assert.strictEqual(data.length, 3, 'should have 3 models for claude')
|
||||||
|
assert(data.every((m: { model: string; id: string }) => m.model && m.id), 'each model should have name and id')
|
||||||
|
console.log('✓ provider models --format json outputs valid JSON\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: provider models --quiet outputs model IDs only
|
||||||
|
{
|
||||||
|
console.log('Test 10: provider models --quiet outputs model IDs only')
|
||||||
|
const result = await $`npx paseo provider models claude --quiet`.nothrow()
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'should exit 0')
|
||||||
|
const lines = result.stdout.trim().split('\n')
|
||||||
|
assert.strictEqual(lines.length, 3, 'should have 3 lines')
|
||||||
|
assert(lines.includes('claude-sonnet-4-20250514'), 'should include claude-sonnet-4')
|
||||||
|
assert(lines.includes('claude-opus-4-20250514'), 'should include claude-opus-4')
|
||||||
|
assert(lines.includes('claude-3-5-haiku-20241022'), 'should include claude-haiku')
|
||||||
|
console.log('✓ provider models --quiet outputs model IDs only\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== All provider tests passed ===')
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E Test: Agent Lifecycle
|
||||||
|
*
|
||||||
|
* This test verifies the complete agent lifecycle using REAL daemons and agents.
|
||||||
|
* It starts an isolated daemon on a random port and runs actual CLI commands.
|
||||||
|
*
|
||||||
|
* Test flow:
|
||||||
|
* 1. Start daemon on random port
|
||||||
|
* 2. Create agent with `paseo run "say hello" --provider claude`
|
||||||
|
* 3. List agents with `paseo ls`
|
||||||
|
* 4. Wait for agent with `paseo wait <id>`
|
||||||
|
* 5. Inspect agent with `paseo inspect <id>`
|
||||||
|
* 6. Stop agent with `paseo stop <id>`
|
||||||
|
* 7. Cleanup: stop daemon, remove temp dirs
|
||||||
|
*
|
||||||
|
* CRITICAL RULES:
|
||||||
|
* - NEVER use port 6767 (user's running daemon)
|
||||||
|
* - Always use claude provider with haiku model for fast, cheap tests
|
||||||
|
* - Clean up resources after test completes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||||
|
|
||||||
|
interface E2EContext extends TestDaemonContext {
|
||||||
|
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||||
|
exitCode: number
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx: E2EContext
|
||||||
|
|
||||||
|
async function setup(): Promise<void> {
|
||||||
|
console.log('Setting up E2E test context...')
|
||||||
|
console.log('Starting test daemon on random port (this may take a few seconds)...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
ctx = await createE2ETestContext({ timeout: 45000 })
|
||||||
|
console.log(`Test daemon started on port ${ctx.port}`)
|
||||||
|
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
|
||||||
|
console.log(`Work directory: ${ctx.workDir}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start test daemon:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(): Promise<void> {
|
||||||
|
console.log('\nCleaning up...')
|
||||||
|
if (ctx) {
|
||||||
|
await ctx.stop()
|
||||||
|
console.log('Test daemon stopped and temp directories removed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_ls_empty(): Promise<void> {
|
||||||
|
console.log('\n--- Test: agent ls with empty list ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['ls', '--format', 'json'])
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent ls should succeed')
|
||||||
|
|
||||||
|
const agents = JSON.parse(result.stdout.trim())
|
||||||
|
assert(Array.isArray(agents), 'Output should be JSON array')
|
||||||
|
assert.strictEqual(agents.length, 0, 'Should have no agents initially')
|
||||||
|
|
||||||
|
console.log('PASS: agent ls returns empty list')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_run_detached(): Promise<string> {
|
||||||
|
console.log('\n--- Test: agent run detached ---')
|
||||||
|
|
||||||
|
// Use quiet mode to get just the agent ID
|
||||||
|
// CRITICAL: Use haiku model for fast, cheap tests
|
||||||
|
// CRITICAL: Use bypassPermissions mode so agent doesn't wait for permission approvals
|
||||||
|
const result = await ctx.paseo(
|
||||||
|
[
|
||||||
|
'-q',
|
||||||
|
'run',
|
||||||
|
'-d',
|
||||||
|
'--provider',
|
||||||
|
'claude',
|
||||||
|
'--model',
|
||||||
|
'claude-3-5-haiku-20241022',
|
||||||
|
'--mode',
|
||||||
|
'bypassPermissions',
|
||||||
|
'--name',
|
||||||
|
'E2E Test Agent',
|
||||||
|
'Say hello world',
|
||||||
|
],
|
||||||
|
{ timeout: 60000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
|
||||||
|
|
||||||
|
const agentId = result.stdout.trim()
|
||||||
|
assert(agentId.length > 0, 'Should return agent ID')
|
||||||
|
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
|
||||||
|
|
||||||
|
console.log(`PASS: agent created with ID: ${agentId}`)
|
||||||
|
return agentId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_ls_shows_agent(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: agent ls shows created agent ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['ls', '--format', 'json'])
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent ls should succeed')
|
||||||
|
|
||||||
|
const agents = JSON.parse(result.stdout.trim()) as Array<{ id: string; title?: string; status?: string }>
|
||||||
|
assert(Array.isArray(agents), 'Output should be JSON array')
|
||||||
|
assert(agents.length >= 1, 'Should have at least one agent')
|
||||||
|
|
||||||
|
// Find our agent by ID prefix
|
||||||
|
const ourAgent = agents.find((a) => agentId.startsWith(a.id) || a.id.startsWith(agentId.slice(0, 7)))
|
||||||
|
assert(ourAgent, `Our agent ${agentId} should be in the list`)
|
||||||
|
|
||||||
|
console.log('Agent found:', ourAgent)
|
||||||
|
console.log('PASS: agent ls shows created agent')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_wait(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: agent wait ---')
|
||||||
|
|
||||||
|
// Wait for agent to become idle (with generous timeout for haiku model)
|
||||||
|
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed')
|
||||||
|
|
||||||
|
console.log('PASS: agent wait completed successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_inspect(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: agent inspect ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['inspect', agentId])
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent inspect should succeed')
|
||||||
|
|
||||||
|
// Check that output contains expected fields (table format)
|
||||||
|
const output = result.stdout
|
||||||
|
assert(output.includes('Id'), 'Output should contain Id field')
|
||||||
|
assert(output.includes('Provider'), 'Output should contain Provider field')
|
||||||
|
assert(output.includes('Status'), 'Output should contain Status field')
|
||||||
|
assert(output.includes('claude'), 'Output should mention claude provider')
|
||||||
|
|
||||||
|
console.log('PASS: agent inspect shows expected fields')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_logs(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: agent logs ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['logs', '--tail', '20', agentId])
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout length:', result.stdout.length)
|
||||||
|
// Don't print full logs as they can be verbose
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent logs should succeed')
|
||||||
|
// Logs should have some content (agent was asked to say hello)
|
||||||
|
assert(result.stdout.length > 0, 'Logs should have some content')
|
||||||
|
|
||||||
|
console.log('PASS: agent logs returns content')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_stop(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: agent stop ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['stop', agentId])
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent stop should succeed')
|
||||||
|
|
||||||
|
// Verify agent is no longer in running list
|
||||||
|
const psResult = await ctx.paseo(['ls', '--format', 'json'])
|
||||||
|
const agents = JSON.parse(psResult.stdout.trim()) as Array<{ id: string; status?: string }>
|
||||||
|
|
||||||
|
// Agent might still be in list but should not be running
|
||||||
|
const ourAgent = agents.find((a) => agentId.startsWith(a.id) || a.id.startsWith(agentId.slice(0, 7)))
|
||||||
|
if (ourAgent) {
|
||||||
|
assert(ourAgent.status !== 'running', 'Agent should not be running after stop')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('PASS: agent stop completed successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('=== E2E Test: Agent Lifecycle ===\n')
|
||||||
|
console.log('This test creates REAL agents with REAL daemons.')
|
||||||
|
console.log('It may take some time as it waits for agent responses.\n')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setup()
|
||||||
|
|
||||||
|
// Run tests in sequence
|
||||||
|
await test_agent_ls_empty()
|
||||||
|
|
||||||
|
const agentId = await test_agent_run_detached()
|
||||||
|
|
||||||
|
await test_agent_ls_shows_agent(agentId)
|
||||||
|
|
||||||
|
await test_agent_wait(agentId)
|
||||||
|
|
||||||
|
await test_agent_inspect(agentId)
|
||||||
|
|
||||||
|
await test_agent_logs(agentId)
|
||||||
|
|
||||||
|
await test_agent_stop(agentId)
|
||||||
|
|
||||||
|
console.log('\n=== All E2E tests passed! ===')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\n=== E2E test FAILED ===')
|
||||||
|
console.error(err)
|
||||||
|
process.exitCode = 1
|
||||||
|
} finally {
|
||||||
|
await cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E Test: Agent Send Command
|
||||||
|
*
|
||||||
|
* This test verifies the `send` command using REAL daemons and agents.
|
||||||
|
* It starts an isolated daemon on a random port and runs actual CLI commands.
|
||||||
|
*
|
||||||
|
* Test flow:
|
||||||
|
* 1. Start daemon on random port
|
||||||
|
* 2. Create agent with detached mode
|
||||||
|
* 3. Wait for agent to become idle (initial task complete)
|
||||||
|
* 4. Send new message with `send`
|
||||||
|
* 5. Wait for agent again
|
||||||
|
* 6. Verify agent processed the message (check logs)
|
||||||
|
* 7. Cleanup
|
||||||
|
*
|
||||||
|
* CRITICAL RULES:
|
||||||
|
* - NEVER use port 6767 (user's running daemon)
|
||||||
|
* - Always use claude provider with haiku model for fast, cheap tests
|
||||||
|
* - Clean up resources after test completes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||||
|
|
||||||
|
interface E2EContext extends TestDaemonContext {
|
||||||
|
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||||
|
exitCode: number
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx: E2EContext
|
||||||
|
|
||||||
|
async function setup(): Promise<void> {
|
||||||
|
console.log('Setting up E2E test context...')
|
||||||
|
console.log('Starting test daemon on random port (this may take a few seconds)...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
ctx = await createE2ETestContext({ timeout: 45000 })
|
||||||
|
console.log(`Test daemon started on port ${ctx.port}`)
|
||||||
|
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
|
||||||
|
console.log(`Work directory: ${ctx.workDir}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start test daemon:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(): Promise<void> {
|
||||||
|
console.log('\nCleaning up...')
|
||||||
|
if (ctx) {
|
||||||
|
await ctx.stop()
|
||||||
|
console.log('Test daemon stopped and temp directories removed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_create_agent(): Promise<string> {
|
||||||
|
console.log('\n--- Test: Create agent in detached mode ---')
|
||||||
|
|
||||||
|
// CRITICAL: Use haiku model for fast, cheap tests
|
||||||
|
// CRITICAL: Use bypassPermissions mode so agent doesn't wait for permission approvals
|
||||||
|
const result = await ctx.paseo(
|
||||||
|
[
|
||||||
|
'-q',
|
||||||
|
'run',
|
||||||
|
'-d',
|
||||||
|
'--provider',
|
||||||
|
'claude',
|
||||||
|
'--model',
|
||||||
|
'claude-3-5-haiku-20241022',
|
||||||
|
'--mode',
|
||||||
|
'bypassPermissions',
|
||||||
|
'--name',
|
||||||
|
'Send Test Agent',
|
||||||
|
'Say "initial task complete"',
|
||||||
|
],
|
||||||
|
{ timeout: 60000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
|
||||||
|
|
||||||
|
const agentId = result.stdout.trim()
|
||||||
|
assert(agentId.length > 0, 'Should return agent ID')
|
||||||
|
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
|
||||||
|
|
||||||
|
console.log(`PASS: agent created with ID: ${agentId}`)
|
||||||
|
return agentId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_wait_for_initial_task(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: Wait for initial task to complete ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed')
|
||||||
|
|
||||||
|
console.log('PASS: Initial task completed')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_send(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: Send follow-up message ---')
|
||||||
|
|
||||||
|
// Send a follow-up message to the agent
|
||||||
|
const result = await ctx.paseo(
|
||||||
|
['send', agentId, 'Now say "follow-up task complete"'],
|
||||||
|
{ timeout: 180000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent send should succeed')
|
||||||
|
|
||||||
|
// Verify output contains expected status
|
||||||
|
assert(
|
||||||
|
result.stdout.includes('completed') || result.stdout.includes('sent'),
|
||||||
|
'Should indicate message was sent or completed'
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('PASS: Follow-up message sent successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_verify_logs(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: Verify agent processed both messages ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['logs', '--tail', '50', agentId])
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout length:', result.stdout.length)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent logs should succeed')
|
||||||
|
|
||||||
|
// Logs should have content from both tasks
|
||||||
|
assert(result.stdout.length > 0, 'Logs should have content')
|
||||||
|
|
||||||
|
// The agent was asked to say specific things, check if either appears in logs
|
||||||
|
// This is a loose check since exact log format may vary
|
||||||
|
const logsLower = result.stdout.toLowerCase()
|
||||||
|
const hasInitialTask = logsLower.includes('initial') || logsLower.includes('hello') || logsLower.includes('task')
|
||||||
|
const hasFollowUp = logsLower.includes('follow') || logsLower.includes('complete') || logsLower.includes('task')
|
||||||
|
|
||||||
|
// At minimum, there should be log entries (we can't guarantee exact content)
|
||||||
|
assert(result.stdout.split('\n').length > 3, 'Should have multiple log entries from both tasks')
|
||||||
|
|
||||||
|
console.log('PASS: Agent logs show activity from both tasks')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_stop(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: Stop agent ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['stop', agentId])
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent stop should succeed')
|
||||||
|
|
||||||
|
console.log('PASS: Agent stopped successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('=== E2E Test: Agent Send Command ===\n')
|
||||||
|
console.log('This test verifies the `send` command with REAL agents.')
|
||||||
|
console.log('It may take some time as it waits for agent responses.\n')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setup()
|
||||||
|
|
||||||
|
// Create agent and wait for initial task
|
||||||
|
const agentId = await test_create_agent()
|
||||||
|
await test_wait_for_initial_task(agentId)
|
||||||
|
|
||||||
|
// Send follow-up message (this waits for completion by default)
|
||||||
|
await test_agent_send(agentId)
|
||||||
|
|
||||||
|
// Verify both messages were processed
|
||||||
|
await test_verify_logs(agentId)
|
||||||
|
|
||||||
|
// Cleanup agent
|
||||||
|
await test_agent_stop(agentId)
|
||||||
|
|
||||||
|
console.log('\n=== All agent-send E2E tests passed! ===')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\n=== E2E test FAILED ===')
|
||||||
|
console.error(err)
|
||||||
|
process.exitCode = 1
|
||||||
|
} finally {
|
||||||
|
await cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E Test: Permissions Workflow
|
||||||
|
*
|
||||||
|
* This test verifies the permissions workflow using REAL daemons and agents.
|
||||||
|
* It starts an isolated daemon on a random port and runs actual CLI commands.
|
||||||
|
*
|
||||||
|
* Test flow:
|
||||||
|
* 1. Start daemon on random port
|
||||||
|
* 2. Create agent in DEFAULT mode (not bypassPermissions) so it requests permissions
|
||||||
|
* 3. Wait for agent to hit a permission request
|
||||||
|
* 4. Use `permit ls` to list pending permissions
|
||||||
|
* 5. Use `permit allow` to approve the permission
|
||||||
|
* 6. Verify agent continues after approval
|
||||||
|
* 7. Cleanup
|
||||||
|
*
|
||||||
|
* CRITICAL RULES:
|
||||||
|
* - NEVER use port 6767 (user's running daemon)
|
||||||
|
* - Always use claude provider with haiku model for fast, cheap tests
|
||||||
|
* - Clean up resources after test completes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||||
|
|
||||||
|
interface E2EContext extends TestDaemonContext {
|
||||||
|
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||||
|
exitCode: number
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx: E2EContext
|
||||||
|
|
||||||
|
async function setup(): Promise<void> {
|
||||||
|
console.log('Setting up E2E test context...')
|
||||||
|
console.log('Starting test daemon on random port (this may take a few seconds)...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
ctx = await createE2ETestContext({ timeout: 45000 })
|
||||||
|
console.log(`Test daemon started on port ${ctx.port}`)
|
||||||
|
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
|
||||||
|
console.log(`Work directory: ${ctx.workDir}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start test daemon:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(): Promise<void> {
|
||||||
|
console.log('\nCleaning up...')
|
||||||
|
if (ctx) {
|
||||||
|
await ctx.stop()
|
||||||
|
console.log('Test daemon stopped and temp directories removed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_create_agent_with_permissions(): Promise<string> {
|
||||||
|
console.log('\n--- Test: Create agent in default mode (will request permissions) ---')
|
||||||
|
|
||||||
|
// Create agent WITHOUT bypassPermissions - it will need to request permissions
|
||||||
|
// Use a task that is very likely to trigger a tool use (and thus permission request)
|
||||||
|
const result = await ctx.paseo(
|
||||||
|
[
|
||||||
|
'-q',
|
||||||
|
'agent',
|
||||||
|
'run',
|
||||||
|
'-d',
|
||||||
|
'--provider',
|
||||||
|
'claude',
|
||||||
|
'--model',
|
||||||
|
'claude-3-5-haiku-20241022',
|
||||||
|
'--name',
|
||||||
|
'Permission Test Agent',
|
||||||
|
// This prompt should trigger file read or bash command, requiring permission
|
||||||
|
'List the files in the current directory using ls',
|
||||||
|
],
|
||||||
|
{ timeout: 60000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
|
||||||
|
|
||||||
|
const agentId = result.stdout.trim()
|
||||||
|
assert(agentId.length > 0, 'Should return agent ID')
|
||||||
|
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
|
||||||
|
|
||||||
|
console.log(`PASS: agent created with ID: ${agentId}`)
|
||||||
|
return agentId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_wait_for_permission_request(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: Wait for agent to request permission ---')
|
||||||
|
|
||||||
|
// Poll for permission requests with timeout
|
||||||
|
const maxWait = 60000 // 60 seconds max
|
||||||
|
const pollInterval = 1000 // 1 second
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWait) {
|
||||||
|
const result = await ctx.paseo(['permit', 'ls', '--format', 'json'])
|
||||||
|
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
try {
|
||||||
|
const permissions = JSON.parse(result.stdout.trim())
|
||||||
|
if (Array.isArray(permissions) && permissions.length > 0) {
|
||||||
|
// Check if any permission is for our agent
|
||||||
|
const ourPermission = permissions.find(
|
||||||
|
(p: { agentId?: string }) => p.agentId?.startsWith(agentId.slice(0, 7)) || agentId.startsWith(p.agentId?.slice(0, 7) || '')
|
||||||
|
)
|
||||||
|
if (ourPermission) {
|
||||||
|
console.log('Permission request detected:', ourPermission)
|
||||||
|
console.log('PASS: Agent requested permission')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON parse failed, continue polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, check agent status - it might have already completed
|
||||||
|
const statusResult = await ctx.paseo(['inspect', agentId])
|
||||||
|
console.log('Agent status:', statusResult.stdout)
|
||||||
|
|
||||||
|
// It's possible the agent completed without needing permissions (e.g., haiku might not use tools)
|
||||||
|
// In that case, we should skip the permission tests
|
||||||
|
if (statusResult.stdout.includes('idle') || statusResult.stdout.includes('completed')) {
|
||||||
|
console.log('SKIP: Agent completed without requiring permissions (model chose not to use tools)')
|
||||||
|
throw new Error('SKIP_PERMISSION_TEST')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Timeout waiting for agent to request permission')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_permit_ls(): Promise<{ agentShortId: string; requestId: string }> {
|
||||||
|
console.log('\n--- Test: List pending permissions with permit ls ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['permit', 'ls', '--format', 'json'])
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'permit ls should succeed')
|
||||||
|
|
||||||
|
const permissions = JSON.parse(result.stdout.trim())
|
||||||
|
assert(Array.isArray(permissions), 'Output should be JSON array')
|
||||||
|
assert(permissions.length > 0, 'Should have at least one pending permission')
|
||||||
|
|
||||||
|
const permission = permissions[0]
|
||||||
|
assert(permission.id, 'Permission should have id')
|
||||||
|
assert(permission.agentShortId, 'Permission should have agentShortId')
|
||||||
|
assert(permission.name, 'Permission should have tool name')
|
||||||
|
|
||||||
|
console.log(`PASS: Found ${permissions.length} pending permission(s)`)
|
||||||
|
console.log(` Request ID: ${permission.id}`)
|
||||||
|
console.log(` Agent: ${permission.agentShortId}`)
|
||||||
|
console.log(` Tool: ${permission.name}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentShortId: permission.agentShortId,
|
||||||
|
requestId: permission.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_permit_allow(agentShortId: string, requestId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: Allow permission with permit allow ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['permit', 'allow', agentShortId, requestId])
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'permit allow should succeed')
|
||||||
|
|
||||||
|
// Check that output shows the permission was allowed
|
||||||
|
const output = result.stdout.toLowerCase()
|
||||||
|
assert(output.includes('allowed') || output.includes(requestId.slice(0, 8).toLowerCase()), 'Should confirm permission was allowed')
|
||||||
|
|
||||||
|
console.log('PASS: Permission allowed successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_continues(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: Verify agent continues after permission granted ---')
|
||||||
|
|
||||||
|
// Wait for agent to become idle (should continue after permission)
|
||||||
|
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed after permission granted')
|
||||||
|
|
||||||
|
// Verify agent status
|
||||||
|
const inspectResult = await ctx.paseo(['inspect', agentId])
|
||||||
|
console.log('Final agent status:', inspectResult.stdout)
|
||||||
|
|
||||||
|
assert(inspectResult.stdout.includes('idle') || inspectResult.stdout.includes('completed'), 'Agent should be idle after completing')
|
||||||
|
|
||||||
|
console.log('PASS: Agent completed after permission was granted')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_agent_stop(agentId: string): Promise<void> {
|
||||||
|
console.log('\n--- Test: Stop agent ---')
|
||||||
|
|
||||||
|
const result = await ctx.paseo(['stop', agentId])
|
||||||
|
|
||||||
|
console.log('Exit code:', result.exitCode)
|
||||||
|
console.log('Stdout:', result.stdout)
|
||||||
|
if (result.stderr) console.log('Stderr:', result.stderr)
|
||||||
|
|
||||||
|
assert.strictEqual(result.exitCode, 0, 'agent stop should succeed')
|
||||||
|
|
||||||
|
console.log('PASS: Agent stopped successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('=== E2E Test: Permissions Workflow ===\n')
|
||||||
|
console.log('This test verifies the permission request/allow workflow.')
|
||||||
|
console.log('It creates an agent that will request tool permissions.\n')
|
||||||
|
|
||||||
|
let agentId: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setup()
|
||||||
|
|
||||||
|
// Create agent that will need permissions
|
||||||
|
agentId = await test_create_agent_with_permissions()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for permission request
|
||||||
|
await test_wait_for_permission_request(agentId)
|
||||||
|
|
||||||
|
// List and verify permissions
|
||||||
|
const { agentShortId, requestId } = await test_permit_ls()
|
||||||
|
|
||||||
|
// Allow the permission
|
||||||
|
await test_permit_allow(agentShortId, requestId)
|
||||||
|
|
||||||
|
// Verify agent continues and completes
|
||||||
|
await test_agent_continues(agentId)
|
||||||
|
|
||||||
|
console.log('\n=== All permissions E2E tests passed! ===')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message === 'SKIP_PERMISSION_TEST') {
|
||||||
|
console.log('\n=== Permissions test skipped (agent completed without tool use) ===')
|
||||||
|
console.log('This can happen when the model chooses not to use tools.')
|
||||||
|
// Still considered a pass - the CLI works, just the model behavior varies
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\n=== E2E test FAILED ===')
|
||||||
|
console.error(err)
|
||||||
|
process.exitCode = 1
|
||||||
|
} finally {
|
||||||
|
// Always try to stop the agent if it was created
|
||||||
|
if (agentId) {
|
||||||
|
try {
|
||||||
|
await test_agent_stop(agentId)
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
/**
|
||||||
|
* Test Daemon Helper
|
||||||
|
*
|
||||||
|
* Provides utilities for launching real Paseo daemons in E2E tests.
|
||||||
|
* Each test gets an isolated daemon on a random port with its own PASEO_HOME.
|
||||||
|
*
|
||||||
|
* CRITICAL RULES (from design doc):
|
||||||
|
* 1. Port: Random port in 20000-30000 range - NEVER use 6767 (production)
|
||||||
|
* 2. Protocol: WebSocket ONLY - daemon has no HTTP endpoints
|
||||||
|
* 3. Temp dirs: Create temp directories for PASEO_HOME and agent --cwd
|
||||||
|
* 4. Model: Always use claude provider with haiku model for fast, cheap tests
|
||||||
|
* 5. Cleanup: Kill daemon and remove temp dirs after each test
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdtemp, rm, mkdir } from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { ChildProcess, spawn } from 'child_process'
|
||||||
|
|
||||||
|
export interface TestDaemonContext {
|
||||||
|
/** Random port for test daemon (never 6767) */
|
||||||
|
port: number
|
||||||
|
/** WebSocket URL for connecting to daemon */
|
||||||
|
wsUrl: string
|
||||||
|
/** Temp directory for PASEO_HOME */
|
||||||
|
paseoHome: string
|
||||||
|
/** Temp directory for agent working directory */
|
||||||
|
workDir: string
|
||||||
|
/** Running daemon process */
|
||||||
|
process: ChildProcess | null
|
||||||
|
/** Whether the daemon is ready to accept connections */
|
||||||
|
isReady: boolean
|
||||||
|
/** Stop the daemon and clean up resources */
|
||||||
|
stop: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random port for test daemon
|
||||||
|
* Uses range 20000-30000 to avoid conflicts
|
||||||
|
* NEVER uses 6767 (user's running daemon)
|
||||||
|
*/
|
||||||
|
export function getRandomPort(): number {
|
||||||
|
return 20000 + Math.floor(Math.random() * 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create isolated temp directories for testing
|
||||||
|
*/
|
||||||
|
export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> {
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-e2e-home-'))
|
||||||
|
const workDir = await mkdtemp(join(tmpdir(), 'paseo-e2e-work-'))
|
||||||
|
|
||||||
|
// Create the agents directory that the daemon expects
|
||||||
|
const agentsDir = join(paseoHome, 'agents')
|
||||||
|
await mkdir(agentsDir, { recursive: true })
|
||||||
|
|
||||||
|
return { paseoHome, workDir }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for daemon to be ready by running `paseo agent ls`
|
||||||
|
* This connects via WebSocket and ensures the daemon is responsive
|
||||||
|
*/
|
||||||
|
async function waitForDaemonReady(
|
||||||
|
port: number,
|
||||||
|
timeout = 30000
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now()
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
try {
|
||||||
|
const { exitCode } = await runPaseoCli(
|
||||||
|
{
|
||||||
|
port,
|
||||||
|
wsUrl: `ws://127.0.0.1:${port}`,
|
||||||
|
paseoHome: '',
|
||||||
|
workDir: '',
|
||||||
|
process: null,
|
||||||
|
isReady: false,
|
||||||
|
stop: async () => {}
|
||||||
|
},
|
||||||
|
['agent', 'ls']
|
||||||
|
)
|
||||||
|
|
||||||
|
if (exitCode === 0) {
|
||||||
|
return // Daemon is ready
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Connection failed, keep trying
|
||||||
|
}
|
||||||
|
await sleep(100)
|
||||||
|
}
|
||||||
|
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a test daemon programmatically using the server's bootstrap API
|
||||||
|
*
|
||||||
|
* This starts the daemon in a separate process using the CLI's daemon start command
|
||||||
|
* with isolated PASEO_HOME and PASEO_PORT environment variables.
|
||||||
|
*/
|
||||||
|
export async function startTestDaemon(options?: {
|
||||||
|
port?: number
|
||||||
|
paseoHome?: string
|
||||||
|
workDir?: string
|
||||||
|
timeout?: number
|
||||||
|
}): Promise<TestDaemonContext> {
|
||||||
|
const port = options?.port ?? getRandomPort()
|
||||||
|
const { paseoHome, workDir } = options?.paseoHome && options?.workDir
|
||||||
|
? { paseoHome: options.paseoHome, workDir: options.workDir }
|
||||||
|
: await createTempDirs()
|
||||||
|
const timeout = options?.timeout ?? 30000
|
||||||
|
|
||||||
|
const wsUrl = `ws://127.0.0.1:${port}`
|
||||||
|
|
||||||
|
// Find the CLI entry point - use the source file directly with tsx
|
||||||
|
const cliDir = join(import.meta.dirname, '..', '..')
|
||||||
|
const cliSrcPath = join(cliDir, 'src', 'index.ts')
|
||||||
|
|
||||||
|
// Start daemon process using tsx to run TypeScript directly
|
||||||
|
const daemonProcess = spawn('npx', ['tsx', cliSrcPath, 'daemon', 'start', '--foreground'], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PASEO_HOME: paseoHome,
|
||||||
|
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||||
|
PASEO_PORT: String(port),
|
||||||
|
// Disable relay for tests
|
||||||
|
PASEO_RELAY_ENABLED: 'false',
|
||||||
|
// Force no TTY to prevent QR code output
|
||||||
|
CI: 'true',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
|
||||||
|
daemonProcess.stdout?.on('data', (data) => {
|
||||||
|
stdout += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
daemonProcess.stderr?.on('data', (data) => {
|
||||||
|
stderr += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
if (daemonProcess && !daemonProcess.killed) {
|
||||||
|
daemonProcess.kill('SIGTERM')
|
||||||
|
// Wait for process to exit
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
daemonProcess.kill('SIGKILL')
|
||||||
|
resolve()
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
daemonProcess.on('exit', () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp directories
|
||||||
|
try {
|
||||||
|
if (existsSync(paseoHome)) {
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existsSync(workDir)) {
|
||||||
|
await rm(workDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle process errors
|
||||||
|
daemonProcess.on('error', (err) => {
|
||||||
|
console.error('Daemon process error:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
daemonProcess.on('exit', (code) => {
|
||||||
|
if (code !== 0 && code !== null) {
|
||||||
|
console.error(`Daemon process exited with code ${code}`)
|
||||||
|
if (stderr) {
|
||||||
|
console.error('Daemon stderr:', stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx: TestDaemonContext = {
|
||||||
|
port,
|
||||||
|
wsUrl,
|
||||||
|
paseoHome,
|
||||||
|
workDir,
|
||||||
|
process: daemonProcess,
|
||||||
|
isReady: false,
|
||||||
|
stop: cleanup,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for daemon to be ready
|
||||||
|
try {
|
||||||
|
await waitForDaemonReady(port, timeout)
|
||||||
|
ctx.isReady = true
|
||||||
|
} catch (err) {
|
||||||
|
// Daemon failed to start - clean up and rethrow
|
||||||
|
await cleanup()
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
throw new Error(`Failed to start test daemon: ${message}\nStdout: ${stdout}\nStderr: ${stderr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a paseo CLI command against a test daemon
|
||||||
|
*
|
||||||
|
* This is a helper that sets the correct environment variables
|
||||||
|
* to point at the test daemon.
|
||||||
|
*/
|
||||||
|
export async function runPaseoCli(
|
||||||
|
ctx: TestDaemonContext,
|
||||||
|
args: string[],
|
||||||
|
options?: {
|
||||||
|
timeout?: number
|
||||||
|
cwd?: string
|
||||||
|
}
|
||||||
|
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||||
|
const timeout = options?.timeout ?? 60000
|
||||||
|
const cwd = options?.cwd ?? ctx.workDir
|
||||||
|
|
||||||
|
const cliDir = join(import.meta.dirname, '..', '..')
|
||||||
|
const cliSrcPath = join(cliDir, 'src', 'index.ts')
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn('npx', ['tsx', cliSrcPath, ...args], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PASEO_HOST: `localhost:${ctx.port}`,
|
||||||
|
PASEO_HOME: ctx.paseoHome,
|
||||||
|
},
|
||||||
|
cwd,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
|
||||||
|
proc.stdout?.on('data', (data) => {
|
||||||
|
stdout += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.stderr?.on('data', (data) => {
|
||||||
|
stderr += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
proc.kill('SIGKILL')
|
||||||
|
reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(' ')}`))
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
proc.on('exit', (code) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
resolve({
|
||||||
|
exitCode: code ?? 1,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test context that includes a started daemon
|
||||||
|
* and a helper to run CLI commands against it.
|
||||||
|
*
|
||||||
|
* This is the main entry point for E2E tests.
|
||||||
|
*/
|
||||||
|
export async function createE2ETestContext(options?: {
|
||||||
|
timeout?: number
|
||||||
|
}): Promise<
|
||||||
|
TestDaemonContext & {
|
||||||
|
/** Run a paseo CLI command against this daemon */
|
||||||
|
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||||
|
exitCode: number
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
const ctx = await startTestDaemon({ timeout: options?.timeout })
|
||||||
|
|
||||||
|
const paseo = (args: string[], opts?: { timeout?: number; cwd?: string }) =>
|
||||||
|
runPaseoCli(ctx, args, opts)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...ctx,
|
||||||
|
paseo,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env npx zx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test runner for Paseo CLI E2E tests
|
||||||
|
*
|
||||||
|
* Runs all test phases in sequence and reports results.
|
||||||
|
* Each test is a separate .ts file that can also be run independently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { $ } from 'zx'
|
||||||
|
import { readdir } from 'fs/promises'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
$.verbose = false
|
||||||
|
|
||||||
|
console.log('🧪 Paseo CLI E2E Test Runner\n')
|
||||||
|
console.log('='.repeat(50))
|
||||||
|
|
||||||
|
// Discover all test files
|
||||||
|
const files = await readdir(__dirname)
|
||||||
|
const testFiles = files
|
||||||
|
.filter(f => f.match(/^\d{2}-.*\.test\.ts$/))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
if (testFiles.length === 0) {
|
||||||
|
console.log('⚠️ No test files found')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${testFiles.length} test file(s):\n`)
|
||||||
|
for (const file of testFiles) {
|
||||||
|
console.log(` - ${file}`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
let passed = 0
|
||||||
|
let failed = 0
|
||||||
|
const failures: { test: string; error: string }[] = []
|
||||||
|
|
||||||
|
for (const testFile of testFiles) {
|
||||||
|
const testPath = join(__dirname, testFile)
|
||||||
|
const testName = testFile.replace(/\.test\.ts$/, '')
|
||||||
|
|
||||||
|
console.log(`\n${'─'.repeat(50)}`)
|
||||||
|
console.log(`📋 Running ${testName}...`)
|
||||||
|
console.log('─'.repeat(50))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await $`npx tsx ${testPath}`.nothrow()
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
console.log(`\n✅ ${testName} PASSED`)
|
||||||
|
passed++
|
||||||
|
} else {
|
||||||
|
console.log(`\n❌ ${testName} FAILED (exit code: ${result.exitCode})`)
|
||||||
|
if (result.stderr) {
|
||||||
|
console.log('stderr:', result.stderr)
|
||||||
|
}
|
||||||
|
failed++
|
||||||
|
failures.push({ test: testName, error: result.stderr || `Exit code: ${result.exitCode}` })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const error = e instanceof Error ? e.message : String(e)
|
||||||
|
console.log(`\n❌ ${testName} FAILED`)
|
||||||
|
console.log('Error:', error)
|
||||||
|
failed++
|
||||||
|
failures.push({ test: testName, error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('\n' + '='.repeat(50))
|
||||||
|
console.log('📊 Test Results')
|
||||||
|
console.log('='.repeat(50))
|
||||||
|
console.log(` ✅ Passed: ${passed}`)
|
||||||
|
console.log(` ❌ Failed: ${failed}`)
|
||||||
|
console.log(` 📝 Total: ${passed + failed}`)
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log('\n❌ Failed tests:')
|
||||||
|
for (const { test, error } of failures) {
|
||||||
|
console.log(` - ${test}`)
|
||||||
|
if (error) {
|
||||||
|
console.log(` ${error.split('\n')[0]}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
process.exit(failed > 0 ? 1 : 0)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Test setup utilities for Paseo CLI E2E tests
|
||||||
|
*
|
||||||
|
* Critical rules from design doc:
|
||||||
|
* 1. Port: Random port via 10000 + Math.floor(Math.random() * 50000) - NEVER 6767
|
||||||
|
* 2. Protocol: WebSocket ONLY - daemon has no HTTP endpoints
|
||||||
|
* 3. Temp dirs: Create temp directories for PASEO_HOME and agent --cwd
|
||||||
|
* 4. Model: Always --provider claude with haiku model for agent tests
|
||||||
|
* 5. Cleanup: Kill daemon and remove temp dirs after each test
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { $, ProcessPromise, sleep } from 'zx'
|
||||||
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export interface TestContext {
|
||||||
|
/** Random port for test daemon (never 6767) */
|
||||||
|
port: number
|
||||||
|
/** Temp directory for PASEO_HOME */
|
||||||
|
paseoHome: string
|
||||||
|
/** Temp directory for agent working directory */
|
||||||
|
workDir: string
|
||||||
|
/** Running daemon process */
|
||||||
|
daemon: ProcessPromise | null
|
||||||
|
/** Run a paseo CLI command against the test daemon */
|
||||||
|
paseo: (args: string[]) => ProcessPromise
|
||||||
|
/** Clean up all resources */
|
||||||
|
cleanup: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random port for test daemon
|
||||||
|
* NEVER uses 6767 (user's running daemon)
|
||||||
|
*/
|
||||||
|
export function getRandomPort(): number {
|
||||||
|
return 10000 + Math.floor(Math.random() * 50000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create isolated temp directories for testing
|
||||||
|
*/
|
||||||
|
export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> {
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
|
||||||
|
const workDir = await mkdtemp(join(tmpdir(), 'paseo-test-work-'))
|
||||||
|
return { paseoHome, workDir }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for daemon to be ready by testing WebSocket connection
|
||||||
|
* Uses `paseo agent ls` which connects via WebSocket
|
||||||
|
*/
|
||||||
|
export async function waitForDaemon(port: number, timeout = 30000): Promise<void> {
|
||||||
|
const start = Date.now()
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
try {
|
||||||
|
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow()
|
||||||
|
if (result.exitCode === 0) return
|
||||||
|
} catch {
|
||||||
|
// Connection failed, keep trying
|
||||||
|
}
|
||||||
|
await sleep(100)
|
||||||
|
}
|
||||||
|
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start an isolated test daemon
|
||||||
|
*/
|
||||||
|
export async function startDaemon(
|
||||||
|
port: number,
|
||||||
|
paseoHome: string
|
||||||
|
): Promise<ProcessPromise> {
|
||||||
|
$.verbose = false
|
||||||
|
const daemon = $`PASEO_HOME=${paseoHome} PASEO_PORT=${port} paseo daemon start --foreground`.nothrow()
|
||||||
|
return daemon
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a full test context with daemon, temp dirs, and helpers
|
||||||
|
*/
|
||||||
|
export async function createTestContext(): Promise<TestContext> {
|
||||||
|
const port = getRandomPort()
|
||||||
|
const { paseoHome, workDir } = await createTempDirs()
|
||||||
|
|
||||||
|
// Helper to run CLI commands against test daemon
|
||||||
|
const paseo = (args: string[]): ProcessPromise => {
|
||||||
|
$.verbose = false
|
||||||
|
return $`PASEO_HOST=localhost:${port} paseo ${args}`.nothrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
const cleanup = async (): Promise<void> => {
|
||||||
|
if (ctx.daemon) {
|
||||||
|
ctx.daemon.kill()
|
||||||
|
}
|
||||||
|
await rm(paseoHome, { recursive: true, force: true })
|
||||||
|
await rm(workDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx: TestContext = {
|
||||||
|
port,
|
||||||
|
paseoHome,
|
||||||
|
workDir,
|
||||||
|
daemon: null,
|
||||||
|
paseo,
|
||||||
|
cleanup,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test context and start the daemon
|
||||||
|
* Use this for tests that need a running daemon
|
||||||
|
*/
|
||||||
|
export async function createTestContextWithDaemon(): Promise<TestContext> {
|
||||||
|
const ctx = await createTestContext()
|
||||||
|
ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome)
|
||||||
|
await waitForDaemon(ctx.port)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register cleanup handlers for process exit
|
||||||
|
*/
|
||||||
|
export function registerCleanupHandlers(cleanup: () => Promise<void>): void {
|
||||||
|
const handler = async () => {
|
||||||
|
await cleanup()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('exit', () => {
|
||||||
|
// Can't await in exit handler, but at least try to kill daemon
|
||||||
|
})
|
||||||
|
process.on('SIGINT', handler)
|
||||||
|
process.on('SIGTERM', handler)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitReturns": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
".": "./src/server/exports.ts",
|
||||||
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
|
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// The bearer token is the base64-encoded credentials
|
|
||||||
const bearerToken = Buffer.from("mo:bo").toString("base64");
|
|
||||||
|
|
||||||
const transport = new StdioClientTransport({
|
|
||||||
command: "codex",
|
|
||||||
args: ["mcp-server"],
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
// Set the bearer token env var
|
|
||||||
PASEO_AGENT_CONTROL_TOKEN: bearerToken
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new Client(
|
|
||||||
{ name: "test-client", version: "1.0.0" },
|
|
||||||
{ capabilities: { elicitation: {} } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Listen for events
|
|
||||||
client.setNotificationHandler(
|
|
||||||
z.object({
|
|
||||||
method: z.literal("codex/event"),
|
|
||||||
params: z.object({ msg: z.any() }),
|
|
||||||
}).passthrough(),
|
|
||||||
(data) => {
|
|
||||||
const event = (data.params as { msg: unknown }).msg as { type?: string };
|
|
||||||
if (event.type === "mcp_startup_update" || event.type === "mcp_startup_complete") {
|
|
||||||
process.stdout.write("MCP Event: " + JSON.stringify(event, null, 2) + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.connect(transport);
|
|
||||||
|
|
||||||
// Use correct route (/mcp/agents) and bearer token env var
|
|
||||||
process.stdout.write("\n=== Testing HTTP MCP server with correct route and bearer token ===\n\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await client.callTool({
|
|
||||||
name: "codex",
|
|
||||||
arguments: {
|
|
||||||
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
|
|
||||||
sandbox: "danger-full-access",
|
|
||||||
"approval-policy": "never",
|
|
||||||
config: {
|
|
||||||
mcp_servers: {
|
|
||||||
"agent-control": {
|
|
||||||
url: "http://localhost:6767/mcp/agents",
|
|
||||||
bearer_token_env_var: "PASEO_AGENT_CONTROL_TOKEN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, { timeout: 60000 });
|
|
||||||
|
|
||||||
process.stdout.write("\n=== RESULT ===\n");
|
|
||||||
const content = (result as { content: { text?: string }[] }).content;
|
|
||||||
for (const item of content) {
|
|
||||||
if (item.text) {
|
|
||||||
process.stdout.write(item.text + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write("Error: " + String(error) + "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
process.stderr.write(String(error) + "\n");
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const transport = new StdioClientTransport({
|
|
||||||
command: "codex",
|
|
||||||
args: ["mcp-server"],
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
// Try passing credentials via env var that Codex might read
|
|
||||||
AGENT_CONTROL_AUTH: "mo:bo"
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new Client(
|
|
||||||
{ name: "test-client", version: "1.0.0" },
|
|
||||||
{ capabilities: { elicitation: {} } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Listen for events
|
|
||||||
client.setNotificationHandler(
|
|
||||||
z.object({
|
|
||||||
method: z.literal("codex/event"),
|
|
||||||
params: z.object({ msg: z.any() }),
|
|
||||||
}).passthrough(),
|
|
||||||
(data) => {
|
|
||||||
const event = (data.params as { msg: unknown }).msg as { type?: string };
|
|
||||||
if (event.type === "mcp_startup_update" || event.type === "mcp_startup_complete") {
|
|
||||||
process.stdout.write("MCP Event: " + JSON.stringify(event, null, 2) + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.connect(transport);
|
|
||||||
|
|
||||||
// Try passing MCP server config via the config parameter with headers
|
|
||||||
process.stdout.write("\n=== Testing HTTP MCP server with basic auth in config ===\n\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create base64 encoded credentials
|
|
||||||
const credentials = Buffer.from("mo:bo").toString("base64");
|
|
||||||
|
|
||||||
const result = await client.callTool({
|
|
||||||
name: "codex",
|
|
||||||
arguments: {
|
|
||||||
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
|
|
||||||
sandbox: "danger-full-access",
|
|
||||||
"approval-policy": "never",
|
|
||||||
config: {
|
|
||||||
mcp_servers: {
|
|
||||||
"agent-control": {
|
|
||||||
url: "http://localhost:6767/mcp/agent-control",
|
|
||||||
// Try various ways to pass auth
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Basic ${credentials}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, { timeout: 60000 });
|
|
||||||
|
|
||||||
process.stdout.write("\n=== RESULT ===\n");
|
|
||||||
const content = (result as { content: { text?: string }[] }).content;
|
|
||||||
for (const item of content) {
|
|
||||||
if (item.text) {
|
|
||||||
process.stdout.write(item.text + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write("Error: " + String(error) + "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
process.stderr.write(String(error) + "\n");
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// Create base64 encoded credentials
|
|
||||||
const credentials = Buffer.from("mo:bo").toString("base64");
|
|
||||||
|
|
||||||
const transport = new StdioClientTransport({
|
|
||||||
command: "codex",
|
|
||||||
args: ["mcp-server"],
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
// Set the bearer token env var that we will reference
|
|
||||||
AGENT_CONTROL_TOKEN: `Basic ${credentials}`
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new Client(
|
|
||||||
{ name: "test-client", version: "1.0.0" },
|
|
||||||
{ capabilities: { elicitation: {} } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Listen for events
|
|
||||||
client.setNotificationHandler(
|
|
||||||
z.object({
|
|
||||||
method: z.literal("codex/event"),
|
|
||||||
params: z.object({ msg: z.any() }),
|
|
||||||
}).passthrough(),
|
|
||||||
(data) => {
|
|
||||||
const event = (data.params as { msg: unknown }).msg as { type?: string };
|
|
||||||
if (event.type === "mcp_startup_update" || event.type === "mcp_startup_complete") {
|
|
||||||
process.stdout.write("MCP Event: " + JSON.stringify(event, null, 2) + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.connect(transport);
|
|
||||||
|
|
||||||
// Try passing MCP server config with bearer_token_env_var
|
|
||||||
process.stdout.write("\n=== Testing HTTP MCP server with bearer_token_env_var ===\n\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await client.callTool({
|
|
||||||
name: "codex",
|
|
||||||
arguments: {
|
|
||||||
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
|
|
||||||
sandbox: "danger-full-access",
|
|
||||||
"approval-policy": "never",
|
|
||||||
config: {
|
|
||||||
mcp_servers: {
|
|
||||||
"agent-control": {
|
|
||||||
url: "http://localhost:6767/mcp/agent-control",
|
|
||||||
bearer_token_env_var: "AGENT_CONTROL_TOKEN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, { timeout: 60000 });
|
|
||||||
|
|
||||||
process.stdout.write("\n=== RESULT ===\n");
|
|
||||||
const content = (result as { content: { text?: string }[] }).content;
|
|
||||||
for (const item of content) {
|
|
||||||
if (item.text) {
|
|
||||||
process.stdout.write(item.text + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write("Error: " + String(error) + "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
process.stderr.write(String(error) + "\n");
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// The basic auth header
|
|
||||||
const basicAuth = `Basic ${Buffer.from("mo:bo").toString("base64")}`;
|
|
||||||
|
|
||||||
const transport = new StdioClientTransport({
|
|
||||||
command: "codex",
|
|
||||||
args: ["mcp-server"],
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new Client(
|
|
||||||
{ name: "test-client", version: "1.0.0" },
|
|
||||||
{ capabilities: { elicitation: {} } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Listen for events
|
|
||||||
client.setNotificationHandler(
|
|
||||||
z.object({
|
|
||||||
method: z.literal("codex/event"),
|
|
||||||
params: z.object({ msg: z.any() }),
|
|
||||||
}).passthrough(),
|
|
||||||
(data) => {
|
|
||||||
const event = (data.params as { msg: unknown }).msg as { type?: string };
|
|
||||||
if (event.type === "mcp_startup_update" || event.type === "mcp_startup_complete") {
|
|
||||||
process.stdout.write("MCP Event: " + JSON.stringify(event, null, 2) + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.connect(transport);
|
|
||||||
|
|
||||||
// Test using http_headers with Authorization
|
|
||||||
process.stdout.write("\n=== Testing HTTP MCP server with http_headers and Authorization ===\n\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await client.callTool({
|
|
||||||
name: "codex",
|
|
||||||
arguments: {
|
|
||||||
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
|
|
||||||
sandbox: "danger-full-access",
|
|
||||||
"approval-policy": "never",
|
|
||||||
config: {
|
|
||||||
mcp_servers: {
|
|
||||||
"agent-control": {
|
|
||||||
url: "http://localhost:6767/mcp/agents",
|
|
||||||
http_headers: {
|
|
||||||
Authorization: basicAuth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, { timeout: 60000 });
|
|
||||||
|
|
||||||
process.stdout.write("\n=== RESULT ===\n");
|
|
||||||
const content = (result as { content: { text?: string }[] }).content;
|
|
||||||
for (const item of content) {
|
|
||||||
if (item.text) {
|
|
||||||
process.stdout.write(item.text + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write("Error: " + String(error) + "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
process.stderr.write(String(error) + "\n");
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const transport = new StdioClientTransport({
|
|
||||||
command: "codex",
|
|
||||||
args: ["mcp-server"],
|
|
||||||
env: { ...process.env },
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new Client(
|
|
||||||
{ name: "test-client", version: "1.0.0" },
|
|
||||||
{ capabilities: { elicitation: {} } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Listen for events
|
|
||||||
client.setNotificationHandler(
|
|
||||||
z.object({
|
|
||||||
method: z.literal("codex/event"),
|
|
||||||
params: z.object({ msg: z.any() }),
|
|
||||||
}).passthrough(),
|
|
||||||
(data) => {
|
|
||||||
const event = (data.params as { msg: unknown }).msg as { type?: string };
|
|
||||||
if (event.type === "mcp_startup_update" || event.type === "mcp_startup_complete") {
|
|
||||||
process.stdout.write("MCP Event: " + JSON.stringify(event, null, 2) + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.connect(transport);
|
|
||||||
|
|
||||||
// Try passing MCP server config via the config parameter with HTTP URL
|
|
||||||
process.stdout.write("\n=== Testing HTTP MCP server config (agent-control style) ===\n\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await client.callTool({
|
|
||||||
name: "codex",
|
|
||||||
arguments: {
|
|
||||||
prompt: "List all the MCP tools you have available. Just list them, don't use any.",
|
|
||||||
sandbox: "danger-full-access",
|
|
||||||
"approval-policy": "never",
|
|
||||||
config: {
|
|
||||||
mcp_servers: {
|
|
||||||
"agent-control": {
|
|
||||||
url: "http://localhost:6767/mcp/agent-control",
|
|
||||||
type: "http"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, { timeout: 60000 });
|
|
||||||
|
|
||||||
process.stdout.write("\n=== RESULT ===\n");
|
|
||||||
const content = (result as { content: { text?: string }[] }).content;
|
|
||||||
for (const item of content) {
|
|
||||||
if (item.text) {
|
|
||||||
process.stdout.write(item.text + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write("Error: " + String(error) + "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
process.stderr.write(String(error) + "\n");
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Quick script to verify buildCodexMcpConfig includes MCP servers
|
|
||||||
|
|
||||||
import { buildPaseoDaemonConfigFromEnv } from "../src/server/config.js";
|
|
||||||
|
|
||||||
const config = buildPaseoDaemonConfigFromEnv();
|
|
||||||
|
|
||||||
process.stdout.write("=== agentControlMcp config ===\n");
|
|
||||||
process.stdout.write(JSON.stringify(config.agentControlMcp, null, 2) + "\n");
|
|
||||||
|
|
||||||
// Simulate what buildCodexMcpConfig does
|
|
||||||
const mcpServers: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
if (config.agentControlMcp) {
|
|
||||||
const agentControlUrl = config.agentControlMcp.url;
|
|
||||||
mcpServers["agent-control"] = {
|
|
||||||
url: agentControlUrl,
|
|
||||||
...(config.agentControlMcp.headers ? { http_headers: config.agentControlMcp.headers } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
mcpServers["playwright"] = {
|
|
||||||
command: "npx",
|
|
||||||
args: ["@playwright/mcp", "--headless", "--isolated"],
|
|
||||||
};
|
|
||||||
|
|
||||||
process.stdout.write("\n=== Built MCP servers config ===\n");
|
|
||||||
process.stdout.write(JSON.stringify(mcpServers, null, 2) + "\n");
|
|
||||||
@@ -163,6 +163,7 @@ export type CreateAgentRequestOptions = {
|
|||||||
git?: GitSetupOptions;
|
git?: GitSetupOptions;
|
||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
|
labels?: Record<string, string>;
|
||||||
} & AgentConfigOverrides;
|
} & AgentConfigOverrides;
|
||||||
|
|
||||||
type VoiceConversationLoadedPayload = VoiceConversationLoadedMessage["payload"];
|
type VoiceConversationLoadedPayload = VoiceConversationLoadedMessage["payload"];
|
||||||
@@ -656,6 +657,29 @@ export class DaemonClientV2 {
|
|||||||
this.sendSessionMessage(message);
|
this.sendSessionMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async waitForSessionState(timeout = 5000, requestId?: string): Promise<void> {
|
||||||
|
const resolvedRequestId = this.createRequestId(requestId);
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "request_session_state",
|
||||||
|
requestId: resolvedRequestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// First check the existing message queue in case session_state was already received
|
||||||
|
for (const msg of this.messageQueue) {
|
||||||
|
if (msg.type === "session_state") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in queue, wait for the session_state message
|
||||||
|
await this.sendSessionMessageOrThrow(message);
|
||||||
|
return this.waitFor(
|
||||||
|
(msg) => msg.type === "session_state" ? undefined : null,
|
||||||
|
timeout,
|
||||||
|
{ skipQueue: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async loadVoiceConversation(
|
async loadVoiceConversation(
|
||||||
voiceConversationId: string,
|
voiceConversationId: string,
|
||||||
requestId?: string
|
requestId?: string
|
||||||
@@ -751,6 +775,9 @@ export class DaemonClientV2 {
|
|||||||
: {}),
|
: {}),
|
||||||
...(options.git ? { git: options.git } : {}),
|
...(options.git ? { git: options.git } : {}),
|
||||||
...(options.worktreeName ? { worktreeName: options.worktreeName } : {}),
|
...(options.worktreeName ? { worktreeName: options.worktreeName } : {}),
|
||||||
|
...(options.labels && Object.keys(options.labels).length > 0
|
||||||
|
? { labels: options.labels }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusPromise = this.waitFor(
|
const statusPromise = this.waitFor(
|
||||||
@@ -1875,6 +1902,10 @@ export class DaemonClientV2 {
|
|||||||
sawRunningInQueue = true;
|
sawRunningInQueue = true;
|
||||||
queuedIdle = null; // Reset: any previous idle was before this run
|
queuedIdle = null; // Reset: any previous idle was before this run
|
||||||
}
|
}
|
||||||
|
// Return immediately if we have pending permissions (even if still running)
|
||||||
|
if (sawRunningInQueue && hasPendingPermissions) {
|
||||||
|
return msg.payload;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
sawRunningInQueue &&
|
sawRunningInQueue &&
|
||||||
(status === "idle" || status === "error") &&
|
(status === "idle" || status === "error") &&
|
||||||
@@ -1907,6 +1938,11 @@ export class DaemonClientV2 {
|
|||||||
if (status === "running" || hasPendingPermissions) {
|
if (status === "running" || hasPendingPermissions) {
|
||||||
sawRunning = true;
|
sawRunning = true;
|
||||||
}
|
}
|
||||||
|
// Return if we have pending permissions (even if still running)
|
||||||
|
// OR if agent is idle/error with no pending permissions (after having run)
|
||||||
|
if (sawRunning && hasPendingPermissions) {
|
||||||
|
return msg.payload;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
sawRunning &&
|
sawRunning &&
|
||||||
(status === "idle" || status === "error") &&
|
(status === "idle" || status === "error") &&
|
||||||
@@ -2564,6 +2600,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
|||||||
git: _git,
|
git: _git,
|
||||||
worktreeName: _worktreeName,
|
worktreeName: _worktreeName,
|
||||||
requestId: _requestId,
|
requestId: _requestId,
|
||||||
|
labels: _labels,
|
||||||
...overrides
|
...overrides
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import type {
|
|||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
AgentUsage,
|
AgentUsage,
|
||||||
AgentRuntimeInfo,
|
AgentRuntimeInfo,
|
||||||
AgentControlMcpConfig,
|
|
||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
@@ -58,7 +57,6 @@ export type AgentManagerOptions = {
|
|||||||
maxTimelineItems?: number;
|
maxTimelineItems?: number;
|
||||||
idFactory?: () => string;
|
idFactory?: () => string;
|
||||||
registry?: AgentStorage;
|
registry?: AgentStorage;
|
||||||
agentControlMcp?: AgentControlMcpConfig;
|
|
||||||
onAgentAttention?: AgentAttentionCallback;
|
onAgentAttention?: AgentAttentionCallback;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
};
|
};
|
||||||
@@ -101,11 +99,14 @@ type ManagedAgentBase = {
|
|||||||
lastUsage?: AgentUsage;
|
lastUsage?: AgentUsage;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
attention: AttentionState;
|
attention: AttentionState;
|
||||||
parentAgentId?: string;
|
|
||||||
/**
|
/**
|
||||||
* Internal agents are hidden from listings and don't trigger notifications.
|
* Internal agents are hidden from listings and don't trigger notifications.
|
||||||
*/
|
*/
|
||||||
internal?: boolean;
|
internal?: boolean;
|
||||||
|
/**
|
||||||
|
* User-defined labels for categorizing agents (e.g., { ui: "true" }).
|
||||||
|
*/
|
||||||
|
labels?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ManagedAgentWithSession = ManagedAgentBase & {
|
type ManagedAgentWithSession = ManagedAgentBase & {
|
||||||
@@ -189,7 +190,6 @@ export class AgentManager {
|
|||||||
private readonly idFactory: () => string;
|
private readonly idFactory: () => string;
|
||||||
private readonly registry?: AgentStorage;
|
private readonly registry?: AgentStorage;
|
||||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||||
private readonly agentControlMcp?: AgentControlMcpConfig;
|
|
||||||
private onAgentAttention?: AgentAttentionCallback;
|
private onAgentAttention?: AgentAttentionCallback;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
@@ -198,7 +198,6 @@ export class AgentManager {
|
|||||||
options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS;
|
options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS;
|
||||||
this.idFactory = options?.idFactory ?? (() => randomUUID());
|
this.idFactory = options?.idFactory ?? (() => randomUUID());
|
||||||
this.registry = options?.registry;
|
this.registry = options?.registry;
|
||||||
this.agentControlMcp = options?.agentControlMcp;
|
|
||||||
this.onAgentAttention = options?.onAgentAttention;
|
this.onAgentAttention = options?.onAgentAttention;
|
||||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||||
if (options?.clients) {
|
if (options?.clients) {
|
||||||
@@ -311,15 +310,17 @@ export class AgentManager {
|
|||||||
|
|
||||||
async createAgent(
|
async createAgent(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId?: string
|
agentId?: string,
|
||||||
|
options?: { labels?: Record<string, string> }
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
const normalizedConfig = await this.normalizeConfig(config);
|
const normalizedConfig = await this.normalizeConfig(config, { labels: options?.labels });
|
||||||
const client = this.requireClient(normalizedConfig.provider);
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
const session = await client.createSession(normalizedConfig);
|
const session = await client.createSession(normalizedConfig);
|
||||||
return this.registerSession(
|
return this.registerSession(
|
||||||
session,
|
session,
|
||||||
normalizedConfig,
|
normalizedConfig,
|
||||||
agentId ?? this.idFactory()
|
agentId ?? this.idFactory(),
|
||||||
|
{ labels: options?.labels }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +328,12 @@ export class AgentManager {
|
|||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null }
|
options?: {
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
lastUserMessageAt?: Date | null;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
}
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||||
const mergedConfig = {
|
const mergedConfig = {
|
||||||
@@ -346,7 +352,7 @@ export class AgentManager {
|
|||||||
session,
|
session,
|
||||||
normalizedConfig,
|
normalizedConfig,
|
||||||
agentId ?? this.idFactory(),
|
agentId ?? this.idFactory(),
|
||||||
timestamps
|
options
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +384,8 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.registerSession(session, overrides, agentId);
|
// Preserve existing labels during refresh
|
||||||
|
return this.registerSession(session, overrides, agentId, { labels: existing.labels });
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeAgent(agentId: string): Promise<void> {
|
async closeAgent(agentId: string): Promise<void> {
|
||||||
@@ -795,15 +802,17 @@ export class AgentManager {
|
|||||||
session: AgentSession,
|
session: AgentSession,
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null }
|
options?: {
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
lastUserMessageAt?: Date | null;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
}
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
if (this.agents.has(agentId)) {
|
if (this.agents.has(agentId)) {
|
||||||
throw new Error(`Agent with id ${agentId} already exists`);
|
throw new Error(`Agent with id ${agentId} already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inform the session of its managed agent ID for MCP parent-child relationships
|
|
||||||
session.setManagedAgentId?.(agentId);
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const managed = {
|
const managed = {
|
||||||
id: agentId,
|
id: agentId,
|
||||||
@@ -814,8 +823,8 @@ export class AgentManager {
|
|||||||
config,
|
config,
|
||||||
runtimeInfo: undefined,
|
runtimeInfo: undefined,
|
||||||
lifecycle: "initializing",
|
lifecycle: "initializing",
|
||||||
createdAt: timestamps?.createdAt ?? now,
|
createdAt: options?.createdAt ?? now,
|
||||||
updatedAt: timestamps?.updatedAt ?? now,
|
updatedAt: options?.updatedAt ?? now,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
@@ -823,10 +832,10 @@ export class AgentManager {
|
|||||||
timeline: [],
|
timeline: [],
|
||||||
persistence: session.describePersistence(),
|
persistence: session.describePersistence(),
|
||||||
historyPrimed: false,
|
historyPrimed: false,
|
||||||
lastUserMessageAt: timestamps?.lastUserMessageAt ?? null,
|
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
||||||
attention: { requiresAttention: false },
|
attention: { requiresAttention: false },
|
||||||
parentAgentId: config.parentAgentId,
|
|
||||||
internal: config.internal ?? false,
|
internal: config.internal ?? false,
|
||||||
|
labels: options?.labels,
|
||||||
} as ActiveManagedAgent;
|
} as ActiveManagedAgent;
|
||||||
|
|
||||||
this.agents.set(agentId, managed);
|
this.agents.set(agentId, managed);
|
||||||
@@ -1093,7 +1102,8 @@ export class AgentManager {
|
|||||||
|
|
||||||
|
|
||||||
private async normalizeConfig(
|
private async normalizeConfig(
|
||||||
config: AgentSessionConfig
|
config: AgentSessionConfig,
|
||||||
|
options?: { labels?: Record<string, string> }
|
||||||
): Promise<AgentSessionConfig> {
|
): Promise<AgentSessionConfig> {
|
||||||
const normalized: AgentSessionConfig = { ...config };
|
const normalized: AgentSessionConfig = { ...config };
|
||||||
|
|
||||||
@@ -1107,14 +1117,9 @@ export class AgentManager {
|
|||||||
normalized.model = trimmed.length > 0 ? trimmed : undefined;
|
normalized.model = trimmed.length > 0 ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalized.agentControlMcp && this.agentControlMcp) {
|
// Inject paseoPromptInstructions for UI agents (with ui=true label)
|
||||||
normalized.agentControlMcp = this.agentControlMcp;
|
const isUiAgent = options?.labels?.ui === "true";
|
||||||
}
|
if (isUiAgent) {
|
||||||
|
|
||||||
if (
|
|
||||||
normalized.paseoPromptInstructions === undefined &&
|
|
||||||
normalized.agentControlMcp
|
|
||||||
) {
|
|
||||||
normalized.paseoPromptInstructions = getSelfIdentificationInstructions({
|
normalized.paseoPromptInstructions = getSelfIdentificationInstructions({
|
||||||
cwd: normalized.cwd,
|
cwd: normalized.cwd,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,9 +102,6 @@ describe("agent MCP end-to-end", () => {
|
|||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
agentClients: {},
|
agentClients: {},
|
||||||
agentStoragePath: path.join(paseoHome, "agents"),
|
agentStoragePath: path.join(paseoHome, "agents"),
|
||||||
agentControlMcp: {
|
|
||||||
url: `http://127.0.0.1:${port}/mcp/agents`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
|
const previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
|
||||||
@@ -235,9 +232,6 @@ describe("agent MCP end-to-end", () => {
|
|||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
agentClients: {},
|
agentClients: {},
|
||||||
agentStoragePath: path.join(paseoHome, "agents"),
|
agentStoragePath: path.join(paseoHome, "agents"),
|
||||||
agentControlMcp: {
|
|
||||||
url: `http://127.0.0.1:${port}/mcp/agents`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
|
const previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function toStoredAgentRecord(
|
|||||||
? agent.lastUserMessageAt.toISOString()
|
? agent.lastUserMessageAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
title: options?.title ?? null,
|
title: options?.title ?? null,
|
||||||
|
labels: agent.labels && Object.keys(agent.labels).length > 0 ? agent.labels : undefined,
|
||||||
lastStatus: agent.lifecycle,
|
lastStatus: agent.lifecycle,
|
||||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||||
config: config ?? null,
|
config: config ?? null,
|
||||||
@@ -56,7 +57,6 @@ export function toStoredAgentRecord(
|
|||||||
attentionTimestamp: agent.attention.requiresAttention
|
attentionTimestamp: agent.attention.requiresAttention
|
||||||
? agent.attention.attentionTimestamp.toISOString()
|
? agent.attention.attentionTimestamp.toISOString()
|
||||||
: null,
|
: null,
|
||||||
parentAgentId: agent.parentAgentId ?? null,
|
|
||||||
internal: options?.internal,
|
internal: options?.internal,
|
||||||
} satisfies StoredAgentRecord;
|
} satisfies StoredAgentRecord;
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ export function toAgentPayload(
|
|||||||
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||||
persistence: sanitizePersistenceHandle(agent.persistence),
|
persistence: sanitizePersistenceHandle(agent.persistence),
|
||||||
title: options?.title ?? null,
|
title: options?.title ?? null,
|
||||||
parentAgentId: agent.parentAgentId ?? null,
|
labels: agent.labels && Object.keys(agent.labels).length > 0 ? agent.labels : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const usage = sanitizeUsage(agent.lastUsage);
|
const usage = sanitizeUsage(agent.lastUsage);
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ describe("getStructuredAgentResponse (e2e)", () => {
|
|||||||
cwd = mkdtempSync(path.join(tmpdir(), "agent-response-loop-"));
|
cwd = mkdtempSync(path.join(tmpdir(), "agent-response-loop-"));
|
||||||
manager = new AgentManager({
|
manager = new AgentManager({
|
||||||
clients: createAllClients(logger),
|
clients: createAllClients(logger),
|
||||||
agentControlMcp: { url: agentMcpServer.url },
|
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -171,11 +171,6 @@ export type AgentCommandResult = {
|
|||||||
usage?: AgentUsage;
|
usage?: AgentUsage;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentControlMcpConfig = {
|
|
||||||
url: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListPersistedAgentsOptions = {
|
export type ListPersistedAgentsOptions = {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
@@ -201,7 +196,6 @@ export type AgentSessionConfig = {
|
|||||||
networkAccess?: boolean;
|
networkAccess?: boolean;
|
||||||
webSearch?: boolean;
|
webSearch?: boolean;
|
||||||
reasoningEffort?: string;
|
reasoningEffort?: string;
|
||||||
agentControlMcp?: AgentControlMcpConfig;
|
|
||||||
/**
|
/**
|
||||||
* Paseo-owned instructions injected into the first user prompt via
|
* Paseo-owned instructions injected into the first user prompt via
|
||||||
* <paseo-instructions>...</paseo-instructions>.
|
* <paseo-instructions>...</paseo-instructions>.
|
||||||
@@ -215,7 +209,6 @@ export type AgentSessionConfig = {
|
|||||||
claude?: Partial<ClaudeAgentOptions>;
|
claude?: Partial<ClaudeAgentOptions>;
|
||||||
};
|
};
|
||||||
mcpServers?: AgentMetadata;
|
mcpServers?: AgentMetadata;
|
||||||
parentAgentId?: string;
|
|
||||||
/**
|
/**
|
||||||
* Internal agents are hidden from listings and don't trigger notifications.
|
* Internal agents are hidden from listings and don't trigger notifications.
|
||||||
* They are used for ephemeral system tasks like commit/PR generation.
|
* They are used for ephemeral system tasks like commit/PR generation.
|
||||||
@@ -239,12 +232,6 @@ export interface AgentSession {
|
|||||||
describePersistence(): AgentPersistenceHandle | null;
|
describePersistence(): AgentPersistenceHandle | null;
|
||||||
interrupt(): Promise<void>;
|
interrupt(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
/**
|
|
||||||
* Set the managed agent ID for this session. This is called by AgentManager
|
|
||||||
* after registration to allow the session to include its ID in MCP requests
|
|
||||||
* (for parent-child agent relationships).
|
|
||||||
*/
|
|
||||||
setManagedAgentId?(agentId: string): void;
|
|
||||||
/**
|
/**
|
||||||
* List available slash commands for this session.
|
* List available slash commands for this session.
|
||||||
* Commands are provider-specific - Claude supports skills and built-in commands.
|
* Commands are provider-specific - Claude supports skills and built-in commands.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
|||||||
lastActivityAt: z.string().optional(),
|
lastActivityAt: z.string().optional(),
|
||||||
lastUserMessageAt: z.string().nullable().optional(),
|
lastUserMessageAt: z.string().nullable().optional(),
|
||||||
title: z.string().nullable().optional(),
|
title: z.string().nullable().optional(),
|
||||||
|
labels: z.record(z.string()).optional(),
|
||||||
lastStatus: AgentStatusSchema.default("closed"),
|
lastStatus: AgentStatusSchema.default("closed"),
|
||||||
lastModeId: z.string().nullable().optional(),
|
lastModeId: z.string().nullable().optional(),
|
||||||
config: SERIALIZABLE_CONFIG_SCHEMA,
|
config: SERIALIZABLE_CONFIG_SCHEMA,
|
||||||
@@ -53,7 +54,6 @@ const STORED_AGENT_SCHEMA = z.object({
|
|||||||
requiresAttention: z.boolean().optional(),
|
requiresAttention: z.boolean().optional(),
|
||||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||||
attentionTimestamp: z.string().nullable().optional(),
|
attentionTimestamp: z.string().nullable().optional(),
|
||||||
parentAgentId: z.string().nullable().optional(),
|
|
||||||
internal: z.boolean().optional(),
|
internal: z.boolean().optional(),
|
||||||
archivedAt: z.string().nullable().optional(),
|
archivedAt: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface AgentMcpServerOptions {
|
|||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
/**
|
/**
|
||||||
* ID of the agent that is connecting to this MCP server.
|
* ID of the agent that is connecting to this MCP server.
|
||||||
* When set, create_agent will auto-inject this as parentAgentId.
|
* Used for cwd/mode inheritance when agents spawn child agents.
|
||||||
*/
|
*/
|
||||||
callerAgentId?: string;
|
callerAgentId?: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@@ -350,12 +350,6 @@ export async function createAgentMcpServer(
|
|||||||
.describe(
|
.describe(
|
||||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||||
),
|
),
|
||||||
parentAgentId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Optional parent agent ID. When set, this agent is a child of the specified parent agent."
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createAgentInputSchema = callerAgentId
|
const createAgentInputSchema = callerAgentId
|
||||||
@@ -400,12 +394,10 @@ export async function createAgentMcpServer(
|
|||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
background?: boolean;
|
background?: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
parentAgentId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let resolvedCwd: string;
|
let resolvedCwd: string;
|
||||||
let resolvedMode: string | undefined;
|
let resolvedMode: string | undefined;
|
||||||
let resolvedParentAgentId: string | undefined;
|
|
||||||
|
|
||||||
if (callerAgentId) {
|
if (callerAgentId) {
|
||||||
const parentAgent = agentManager.getAgent(callerAgentId);
|
const parentAgent = agentManager.getAgent(callerAgentId);
|
||||||
@@ -413,7 +405,6 @@ export async function createAgentMcpServer(
|
|||||||
throw new Error(`Parent agent ${callerAgentId} not found`);
|
throw new Error(`Parent agent ${callerAgentId} not found`);
|
||||||
}
|
}
|
||||||
resolvedCwd = parentAgent.cwd;
|
resolvedCwd = parentAgent.cwd;
|
||||||
resolvedParentAgentId = callerAgentId;
|
|
||||||
|
|
||||||
const provider: AgentProvider = agentType ?? "claude";
|
const provider: AgentProvider = agentType ?? "claude";
|
||||||
const parentMode = parentAgent.currentModeId;
|
const parentMode = parentAgent.currentModeId;
|
||||||
@@ -430,14 +421,12 @@ export async function createAgentMcpServer(
|
|||||||
initialMode: string;
|
initialMode: string;
|
||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
parentAgentId?: string;
|
|
||||||
};
|
};
|
||||||
const {
|
const {
|
||||||
cwd,
|
cwd,
|
||||||
initialMode,
|
initialMode,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
parentAgentId,
|
|
||||||
} = topLevelArgs;
|
} = topLevelArgs;
|
||||||
|
|
||||||
resolvedCwd = expandPath(cwd);
|
resolvedCwd = expandPath(cwd);
|
||||||
@@ -457,7 +446,6 @@ export async function createAgentMcpServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolvedMode = initialMode;
|
resolvedMode = initialMode;
|
||||||
resolvedParentAgentId = parentAgentId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider: AgentProvider = agentType ?? "claude";
|
const provider: AgentProvider = agentType ?? "claude";
|
||||||
@@ -467,7 +455,6 @@ export async function createAgentMcpServer(
|
|||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
modeId: resolvedMode,
|
modeId: resolvedMode,
|
||||||
title: normalizedTitle ?? undefined,
|
title: normalizedTitle ?? undefined,
|
||||||
parentAgentId: resolvedParentAgentId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (initialPrompt) {
|
if (initialPrompt) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Core rules:
|
|||||||
- In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself.
|
- In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself.
|
||||||
- Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
|
- Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
|
||||||
- Set cwd to the repository root and choose the most permissive mode available.
|
- Set cwd to the repository root and choose the most permissive mode available.
|
||||||
- If an agent control call fails, list agents before launching another; it may just be a wait timeout.
|
- If an agent control tool call fails, list agents before launching another; it may just be a wait timeout.
|
||||||
|
|
||||||
Context management:
|
Context management:
|
||||||
- Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area).
|
- Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area).
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ const hasClaudeCredentials =
|
|||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
agentControlMcp: {
|
|
||||||
url: "http://localhost:6767/mcp", // Placeholder - not actually used in plan mode
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|||||||
@@ -240,7 +240,6 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
|||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd,
|
cwd,
|
||||||
modeId: options?.modeId,
|
modeId: options?.modeId,
|
||||||
agentControlMcp: { url: agentMcpServer.url },
|
|
||||||
extra: {
|
extra: {
|
||||||
claude: {
|
claude: {
|
||||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
|
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
|
||||||
|
|||||||
@@ -116,16 +116,6 @@ type ClaudeAgentSessionOptions = {
|
|||||||
logger: Logger;
|
logger: Logger;
|
||||||
};
|
};
|
||||||
|
|
||||||
function appendCallerAgentId(url: string, agentId: string): string {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
parsed.searchParams.set("callerAgentId", agentId);
|
|
||||||
return parsed.toString();
|
|
||||||
} catch {
|
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
|
||||||
return `${url}${separator}callerAgentId=${encodeURIComponent(agentId)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractUserMessageText(content: unknown): string | null {
|
export function extractUserMessageText(content: unknown): string | null {
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
@@ -228,25 +218,6 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<Age
|
|||||||
if (typeof metadata.reasoningEffort === "string") {
|
if (typeof metadata.reasoningEffort === "string") {
|
||||||
result.reasoningEffort = metadata.reasoningEffort;
|
result.reasoningEffort = metadata.reasoningEffort;
|
||||||
}
|
}
|
||||||
if (isMetadata(metadata.agentControlMcp)) {
|
|
||||||
const url = metadata.agentControlMcp.url;
|
|
||||||
const headers = metadata.agentControlMcp.headers;
|
|
||||||
if (typeof url === "string") {
|
|
||||||
const agentControlMcp: AgentSessionConfig["agentControlMcp"] = { url };
|
|
||||||
if (isMetadata(headers)) {
|
|
||||||
const normalizedHeaders: { [key: string]: string } = {};
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
normalizedHeaders[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(normalizedHeaders).length > 0) {
|
|
||||||
agentControlMcp.headers = normalizedHeaders;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.agentControlMcp = agentControlMcp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isMetadata(metadata.extra)) {
|
if (isMetadata(metadata.extra)) {
|
||||||
const extra: AgentSessionConfig["extra"] = {};
|
const extra: AgentSessionConfig["extra"] = {};
|
||||||
if (isMetadata(metadata.extra.codex)) {
|
if (isMetadata(metadata.extra.codex)) {
|
||||||
@@ -262,9 +233,6 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<Age
|
|||||||
if (isMetadata(metadata.mcpServers)) {
|
if (isMetadata(metadata.mcpServers)) {
|
||||||
result.mcpServers = metadata.mcpServers;
|
result.mcpServers = metadata.mcpServers;
|
||||||
}
|
}
|
||||||
if (typeof metadata.parentAgentId === "string") {
|
|
||||||
result.parentAgentId = metadata.parentAgentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -432,7 +400,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private activeTurnPromise: Promise<void> | null = null;
|
private activeTurnPromise: Promise<void> | null = null;
|
||||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||||
private lastOptionsModel: string | null = null;
|
private lastOptionsModel: string | null = null;
|
||||||
private managedAgentId: string | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: ClaudeAgentConfig,
|
config: ClaudeAgentConfig,
|
||||||
@@ -722,10 +689,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.input = null;
|
this.input = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setManagedAgentId(agentId: string): void {
|
|
||||||
this.managedAgentId = agentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||||
const q = await this.ensureQuery();
|
const q = await this.ensureQuery();
|
||||||
const commands = await q.supportedCommands();
|
const commands = await q.supportedCommands();
|
||||||
@@ -819,28 +782,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
...this.config.extra?.claude,
|
...this.config.extra?.claude,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always include the agent-control MCP server so agents can launch other agents
|
|
||||||
if (!this.config.agentControlMcp) {
|
|
||||||
throw new Error("agentControlMcp is required for ClaudeAgentSession");
|
|
||||||
}
|
|
||||||
const agentControlConfig = this.config.agentControlMcp;
|
|
||||||
const agentControlUrl = this.managedAgentId
|
|
||||||
? appendCallerAgentId(agentControlConfig.url, this.managedAgentId)
|
|
||||||
: agentControlConfig.url;
|
|
||||||
const defaultMcpServers: Record<string, ClaudeMcpServerConfig> = {
|
|
||||||
"agent-control": {
|
|
||||||
type: "http",
|
|
||||||
url: agentControlUrl,
|
|
||||||
...(agentControlConfig.headers ? { headers: agentControlConfig.headers } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.config.mcpServers) {
|
if (this.config.mcpServers) {
|
||||||
const normalizedUserServers = this.normalizeMcpServers(this.config.mcpServers);
|
const normalizedUserServers = this.normalizeMcpServers(this.config.mcpServers);
|
||||||
// Merge user-provided MCP servers with defaults, user servers take precedence
|
if (normalizedUserServers) {
|
||||||
base.mcpServers = { ...defaultMcpServers, ...normalizedUserServers };
|
base.mcpServers = normalizedUserServers;
|
||||||
} else {
|
}
|
||||||
base.mcpServers = defaultMcpServers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config.model) {
|
if (this.config.model) {
|
||||||
|
|||||||
@@ -1859,11 +1859,6 @@ const PermissionParamsSchema = z
|
|||||||
|
|
||||||
type PermissionParams = z.infer<typeof PermissionParamsSchema>;
|
type PermissionParams = z.infer<typeof PermissionParamsSchema>;
|
||||||
|
|
||||||
const AgentControlMcpConfigSchema = z.object({
|
|
||||||
url: z.string(),
|
|
||||||
headers: z.record(z.string()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type AgentSessionExtra = NonNullable<AgentSessionConfig["extra"]>;
|
type AgentSessionExtra = NonNullable<AgentSessionConfig["extra"]>;
|
||||||
|
|
||||||
const AgentSessionExtraSchema = z.object({
|
const AgentSessionExtraSchema = z.object({
|
||||||
@@ -1883,10 +1878,8 @@ const AgentSessionConfigSchema = z
|
|||||||
networkAccess: z.boolean().optional(),
|
networkAccess: z.boolean().optional(),
|
||||||
webSearch: z.boolean().optional(),
|
webSearch: z.boolean().optional(),
|
||||||
reasoningEffort: z.string().optional(),
|
reasoningEffort: z.string().optional(),
|
||||||
agentControlMcp: AgentControlMcpConfigSchema.optional(),
|
|
||||||
extra: AgentSessionExtraSchema.optional(),
|
extra: AgentSessionExtraSchema.optional(),
|
||||||
mcpServers: z.record(z.unknown()).optional(),
|
mcpServers: z.record(z.unknown()).optional(),
|
||||||
parentAgentId: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
@@ -2718,7 +2711,6 @@ function buildCodexMcpConfig(
|
|||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
modeId: string,
|
modeId: string,
|
||||||
managedAgentId?: string,
|
|
||||||
experimentalResume?: string | null
|
experimentalResume?: string | null
|
||||||
): {
|
): {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -2762,21 +2754,6 @@ function buildCodexMcpConfig(
|
|||||||
// Build MCP servers configuration
|
// Build MCP servers configuration
|
||||||
const mcpServers: Record<string, CodexMcpServerConfig> = {};
|
const mcpServers: Record<string, CodexMcpServerConfig> = {};
|
||||||
|
|
||||||
// Add agent-control MCP server (HTTP-based) if configured
|
|
||||||
if (config.agentControlMcp) {
|
|
||||||
let agentControlUrl = config.agentControlMcp.url;
|
|
||||||
// Append caller agent ID to URL if this is a managed agent
|
|
||||||
if (managedAgentId) {
|
|
||||||
const separator = agentControlUrl.includes("?") ? "&" : "?";
|
|
||||||
agentControlUrl = `${agentControlUrl}${separator}callerAgentId=${encodeURIComponent(managedAgentId)}`;
|
|
||||||
}
|
|
||||||
mcpServers["agent-control"] = {
|
|
||||||
url: agentControlUrl,
|
|
||||||
tool_timeout_sec: 600, // 10 min timeout for child agents
|
|
||||||
...(config.agentControlMcp.headers ? { http_headers: config.agentControlMcp.headers } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge MCP servers from extra.codex.mcp_servers (legacy location)
|
// Merge MCP servers from extra.codex.mcp_servers (legacy location)
|
||||||
const extraCodex = config.extra?.codex as Record<string, unknown> | undefined;
|
const extraCodex = config.extra?.codex as Record<string, unknown> | undefined;
|
||||||
if (extraCodex?.mcp_servers && typeof extraCodex.mcp_servers === "object") {
|
if (extraCodex?.mcp_servers && typeof extraCodex.mcp_servers === "object") {
|
||||||
@@ -3037,7 +3014,6 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
private turnState: TurnState | null = null;
|
private turnState: TurnState | null = null;
|
||||||
private pendingPatchChanges = new Map<string, PatchFileChange[]>();
|
private pendingPatchChanges = new Map<string, PatchFileChange[]>();
|
||||||
private patchChangesByCallId = new Map<string, PatchFileChange[]>();
|
private patchChangesByCallId = new Map<string, PatchFileChange[]>();
|
||||||
private managedAgentId: string | null = null;
|
|
||||||
private resumeHandle: AgentPersistenceHandle | null = null;
|
private resumeHandle: AgentPersistenceHandle | null = null;
|
||||||
private pendingResumeFile: string | null = null;
|
private pendingResumeFile: string | null = null;
|
||||||
|
|
||||||
@@ -3541,10 +3517,6 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
this.conversationId = null;
|
this.conversationId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setManagedAgentId(agentId: string): void {
|
|
||||||
this.managedAgentId = agentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||||
const [skills, prompts] = await Promise.all([
|
const [skills, prompts] = await Promise.all([
|
||||||
listCodexSkills(this.config.cwd),
|
listCodexSkills(this.config.cwd),
|
||||||
@@ -3591,7 +3563,6 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
this.config,
|
this.config,
|
||||||
prompt,
|
prompt,
|
||||||
this.currentMode,
|
this.currentMode,
|
||||||
this.managedAgentId ?? undefined,
|
|
||||||
resumeFile
|
resumeFile
|
||||||
);
|
);
|
||||||
const attempt = async (arguments_: CodexToolArguments) =>
|
const attempt = async (arguments_: CodexToolArguments) =>
|
||||||
@@ -3631,7 +3602,7 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
);
|
);
|
||||||
if (isMissingConversationIdResponse(response)) {
|
if (isMissingConversationIdResponse(response)) {
|
||||||
const replayPrompt = this.buildResumePrompt(prompt);
|
const replayPrompt = this.buildResumePrompt(prompt);
|
||||||
const config = buildCodexMcpConfig(this.config, replayPrompt, this.currentMode, this.managedAgentId ?? undefined);
|
const config = buildCodexMcpConfig(this.config, replayPrompt, this.currentMode);
|
||||||
const attempt = async (arguments_: CodexToolArguments) =>
|
const attempt = async (arguments_: CodexToolArguments) =>
|
||||||
this.client.callTool(
|
this.client.callTool(
|
||||||
{ name: "codex", arguments: arguments_ },
|
{ name: "codex", arguments: arguments_ },
|
||||||
@@ -3654,7 +3625,7 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingConversationIdError(error)) {
|
if (isMissingConversationIdError(error)) {
|
||||||
const replayPrompt = this.buildResumePrompt(prompt);
|
const replayPrompt = this.buildResumePrompt(prompt);
|
||||||
const config = buildCodexMcpConfig(this.config, replayPrompt, this.currentMode, this.managedAgentId ?? undefined);
|
const config = buildCodexMcpConfig(this.config, replayPrompt, this.currentMode);
|
||||||
const attempt = async (arguments_: CodexToolArguments) =>
|
const attempt = async (arguments_: CodexToolArguments) =>
|
||||||
this.client.callTool(
|
this.client.callTool(
|
||||||
{ name: "codex", arguments: arguments_ },
|
{ name: "codex", arguments: arguments_ },
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ import { printPairingQrIfEnabled } from "./pairing-qr.js";
|
|||||||
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentControlMcpConfig,
|
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
|
|
||||||
@@ -78,7 +77,6 @@ export type PaseoDaemonConfig = {
|
|||||||
mcpDebug: boolean;
|
mcpDebug: boolean;
|
||||||
agentClients: Partial<Record<AgentProvider, AgentClient>>;
|
agentClients: Partial<Record<AgentProvider, AgentClient>>;
|
||||||
agentStoragePath: string;
|
agentStoragePath: string;
|
||||||
agentControlMcp: AgentControlMcpConfig;
|
|
||||||
relayEnabled?: boolean;
|
relayEnabled?: boolean;
|
||||||
relayEndpoint?: string;
|
relayEndpoint?: string;
|
||||||
appBaseUrl?: string;
|
appBaseUrl?: string;
|
||||||
@@ -211,7 +209,6 @@ export async function createPaseoDaemon(
|
|||||||
...config.agentClients,
|
...config.agentClients,
|
||||||
},
|
},
|
||||||
registry: agentStorage,
|
registry: agentStorage,
|
||||||
agentControlMcp: config.agentControlMcp,
|
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -378,10 +375,7 @@ export async function createPaseoDaemon(
|
|||||||
agentStorage,
|
agentStorage,
|
||||||
downloadTokenStore,
|
downloadTokenStore,
|
||||||
config.paseoHome,
|
config.paseoHome,
|
||||||
{
|
agentMcpRoute,
|
||||||
agentMcpUrl: config.agentControlMcp.url,
|
|
||||||
agentMcpHeaders: config.agentControlMcp.headers,
|
|
||||||
},
|
|
||||||
{ allowedOrigins },
|
{ allowedOrigins },
|
||||||
{ stt: sttService, tts: ttsService },
|
{ stt: sttService, tts: ttsService },
|
||||||
terminalManager
|
terminalManager
|
||||||
|
|||||||
@@ -80,9 +80,6 @@ export function loadConfig(
|
|||||||
agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE,
|
agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE,
|
||||||
agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`],
|
agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`],
|
||||||
mcpDebug: env.MCP_DEBUG === "1",
|
mcpDebug: env.MCP_DEBUG === "1",
|
||||||
agentControlMcp: {
|
|
||||||
url: `http://${mcpListen}${DEFAULT_AGENT_MCP_ROUTE}`,
|
|
||||||
},
|
|
||||||
agentStoragePath: path.join(paseoHome, "agents"),
|
agentStoragePath: path.join(paseoHome, "agents"),
|
||||||
staticDir: "public",
|
staticDir: "public",
|
||||||
agentClients: {},
|
agentClients: {},
|
||||||
|
|||||||
@@ -57,9 +57,6 @@ async function startDaemon(options: {
|
|||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
agentClients: {},
|
agentClients: {},
|
||||||
agentStoragePath: path.join(options.paseoHome, "agents"),
|
agentStoragePath: path.join(options.paseoHome, "agents"),
|
||||||
agentControlMcp: {
|
|
||||||
url: `http://127.0.0.1:${port}/mcp/agents`,
|
|
||||||
},
|
|
||||||
openai: process.env.OPENAI_API_KEY ? { apiKey: process.env.OPENAI_API_KEY } : undefined,
|
openai: process.env.OPENAI_API_KEY ? { apiKey: process.env.OPENAI_API_KEY } : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs";
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import {
|
import {
|
||||||
createDaemonTestContext,
|
createDaemonTestContext,
|
||||||
type DaemonTestContext,
|
type DaemonTestContext,
|
||||||
} from "../test-utils/index.js";
|
} from "../test-utils/index.js";
|
||||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
|
||||||
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
|
|
||||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
|
||||||
const CODEX_TEST_REASONING_EFFORT = "low";
|
|
||||||
|
|
||||||
describe("daemon E2E", () => {
|
describe("daemon E2E", () => {
|
||||||
let ctx: DaemonTestContext;
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
@@ -29,140 +23,10 @@ describe("daemon E2E", () => {
|
|||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
describe("multi-agent orchestration", () => {
|
describe("multi-agent orchestration", () => {
|
||||||
test(
|
// TODO: Re-implement orchestration tests with new Paseo MCP
|
||||||
"parent agent creates child agent via agent-control MCP",
|
// The old agent-control MCP has been removed
|
||||||
async () => {
|
test("placeholder for future orchestration tests", async () => {
|
||||||
const cwd = tmpCwd();
|
expect(true).toBe(true);
|
||||||
const childCwd = tmpCwd();
|
});
|
||||||
|
|
||||||
// Create parent Codex agent
|
|
||||||
const parent = await ctx.client.createAgent({
|
|
||||||
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
|
||||||
cwd,
|
|
||||||
title: "Parent Agent",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parent.id).toBeTruthy();
|
|
||||||
expect(parent.status).toBe("idle");
|
|
||||||
|
|
||||||
// Clear message queue before sending prompt
|
|
||||||
ctx.client.clearMessageQueue();
|
|
||||||
|
|
||||||
// Prompt the parent to create a child agent using agent-control MCP
|
|
||||||
const prompt = [
|
|
||||||
`Use the create_agent tool from the agent-control MCP server to create a new codex agent.`,
|
|
||||||
`Set the cwd to: ${childCwd}`,
|
|
||||||
`Set the title to: Child Agent`,
|
|
||||||
`Set agentType to: codex`,
|
|
||||||
`Do NOT set an initialPrompt - just create the agent.`,
|
|
||||||
`After creating the agent, reply with "CREATED" followed by the child's agentId.`,
|
|
||||||
].join(" ");
|
|
||||||
|
|
||||||
await ctx.client.sendMessage(parent.id, prompt);
|
|
||||||
|
|
||||||
// Wait for parent to complete
|
|
||||||
const afterCreate = await ctx.client.waitForAgentIdle(
|
|
||||||
parent.id,
|
|
||||||
120000
|
|
||||||
);
|
|
||||||
expect(afterCreate.status).toBe("idle");
|
|
||||||
|
|
||||||
// Verify timeline contains a tool call to create_agent
|
|
||||||
const queue = ctx.client.getMessageQueue();
|
|
||||||
const timelineItems: AgentTimelineItem[] = [];
|
|
||||||
for (const m of queue) {
|
|
||||||
if (
|
|
||||||
m.type === "agent_stream" &&
|
|
||||||
m.payload.agentId === parent.id &&
|
|
||||||
m.payload.event.type === "timeline"
|
|
||||||
) {
|
|
||||||
timelineItems.push(m.payload.event.item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have a tool call to create_agent from agent-control
|
|
||||||
const hasCreateAgentCall = timelineItems.some(
|
|
||||||
(item) =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "agent-control.create_agent"
|
|
||||||
);
|
|
||||||
expect(hasCreateAgentCall).toBe(true);
|
|
||||||
|
|
||||||
// Now verify we can see both agents via session_state
|
|
||||||
// Send a list_persisted_agents_request to trigger session_state refresh
|
|
||||||
// Or we can check the queue for agent_state messages
|
|
||||||
const agentStateMessages = queue.filter(
|
|
||||||
(m) => m.type === "agent_state"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract unique agent IDs from state messages
|
|
||||||
const agentIds = new Set<string>();
|
|
||||||
for (const m of agentStateMessages) {
|
|
||||||
if (m.type === "agent_state") {
|
|
||||||
agentIds.add(m.payload.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have at least 2 agents (parent + child)
|
|
||||||
expect(agentIds.size).toBeGreaterThanOrEqual(2);
|
|
||||||
expect(agentIds.has(parent.id)).toBe(true);
|
|
||||||
|
|
||||||
// Get the child agent ID from the tool call output
|
|
||||||
const createAgentCall = timelineItems.find(
|
|
||||||
(item) =>
|
|
||||||
item.type === "tool_call" &&
|
|
||||||
item.name === "agent-control.create_agent"
|
|
||||||
);
|
|
||||||
|
|
||||||
let childAgentId: string | null = null;
|
|
||||||
if (
|
|
||||||
createAgentCall &&
|
|
||||||
createAgentCall.type === "tool_call" &&
|
|
||||||
createAgentCall.output
|
|
||||||
) {
|
|
||||||
const output = createAgentCall.output as unknown;
|
|
||||||
const tryExtract = (value: unknown): string | null => {
|
|
||||||
if (!value) return null;
|
|
||||||
if (typeof value === "string") {
|
|
||||||
try {
|
|
||||||
return tryExtract(JSON.parse(value));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof value !== "object") return null;
|
|
||||||
const asObj = value as Record<string, unknown>;
|
|
||||||
const direct = asObj.agentId;
|
|
||||||
if (typeof direct === "string") return direct;
|
|
||||||
const structured = asObj.structuredContent;
|
|
||||||
if (structured && typeof structured === "object") {
|
|
||||||
const nested = (structured as Record<string, unknown>).agentId;
|
|
||||||
if (typeof nested === "string") return nested;
|
|
||||||
}
|
|
||||||
if (typeof structured === "string") {
|
|
||||||
try {
|
|
||||||
return tryExtract(JSON.parse(structured));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
childAgentId = tryExtract(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify we found the child agent ID
|
|
||||||
expect(childAgentId).toBeTruthy();
|
|
||||||
expect(agentIds.has(childAgentId!)).toBe(true);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
rmSync(childCwd, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
300000 // 5 minute timeout for multi-agent E2E
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "wait-perm-e2e-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for wait returning on permission request.
|
||||||
|
*
|
||||||
|
* The `paseo wait` command should return when:
|
||||||
|
* 1. Agent completes (goes idle)
|
||||||
|
* 2. Agent requests permission
|
||||||
|
*
|
||||||
|
* This test verifies that waitForAgentIdle correctly returns when
|
||||||
|
* an agent requests permission, not just when it goes idle.
|
||||||
|
*/
|
||||||
|
describe("wait returns on permission request", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
describe("Claude provider", () => {
|
||||||
|
test(
|
||||||
|
"waitForAgentIdle returns when permission is requested (not just when idle)",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const testFilePath = path.join(cwd, "test-wait-perm.txt");
|
||||||
|
|
||||||
|
// Create Claude agent with default mode (always ask for permissions)
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "claude",
|
||||||
|
model: "haiku",
|
||||||
|
cwd,
|
||||||
|
title: "Wait Permission Test",
|
||||||
|
modeId: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
// Clear message queue before sending prompt
|
||||||
|
ctx.client.clearMessageQueue();
|
||||||
|
|
||||||
|
// Send a prompt that requires file write permission
|
||||||
|
const prompt = [
|
||||||
|
`You must use the Write tool to create a file at "${testFilePath}" with content "hello".`,
|
||||||
|
"Do not respond before attempting to write the file.",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
await ctx.client.sendMessage(agent.id, prompt);
|
||||||
|
|
||||||
|
// CRITICAL: This is the behavior we're testing
|
||||||
|
// waitForAgentIdle should return when permission is requested,
|
||||||
|
// NOT wait until the agent is fully idle (which would timeout or
|
||||||
|
// require us to approve the permission first)
|
||||||
|
const startTime = Date.now();
|
||||||
|
const state = await ctx.client.waitForAgentIdle(agent.id, 60000);
|
||||||
|
const waitDuration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// If wait returns because of permission request, we should have:
|
||||||
|
// 1. Agent status still "running" (not idle yet - waiting for permission)
|
||||||
|
// 2. Pending permissions in the state
|
||||||
|
// 3. Wait should return quickly (under 30 seconds, not timeout)
|
||||||
|
|
||||||
|
// Check that we have a pending permission
|
||||||
|
const hasPendingPermission =
|
||||||
|
(state.pendingPermissions && state.pendingPermissions.length > 0);
|
||||||
|
|
||||||
|
// Log for debugging
|
||||||
|
console.log("Wait returned after", waitDuration, "ms");
|
||||||
|
console.log("Agent status:", state.status);
|
||||||
|
console.log("Pending permissions:", state.pendingPermissions?.length ?? 0);
|
||||||
|
|
||||||
|
// THE ASSERTION:
|
||||||
|
// If waitForAgentIdle correctly yields on permission request,
|
||||||
|
// we should either:
|
||||||
|
// - Get a state with pending permissions (status might be "running")
|
||||||
|
// - Or the state should be from right when permission was requested
|
||||||
|
//
|
||||||
|
// If waitForAgentIdle does NOT yield on permission request,
|
||||||
|
// this test will either:
|
||||||
|
// - Timeout (60s)
|
||||||
|
// - Return only after we never approve permission and agent errors/gives up
|
||||||
|
|
||||||
|
// This test will FAIL if waitForAgentIdle waits for full idle
|
||||||
|
// instead of returning on permission request
|
||||||
|
expect(hasPendingPermission).toBe(true);
|
||||||
|
|
||||||
|
// Also verify we can get the permission via waitForPermission
|
||||||
|
// (This should return immediately since permission is already pending)
|
||||||
|
const permission = await ctx.client.waitForPermission(agent.id, 5000);
|
||||||
|
expect(permission).toBeTruthy();
|
||||||
|
expect(permission.kind).toBe("tool");
|
||||||
|
|
||||||
|
// Clean up: deny the permission so agent can finish
|
||||||
|
await ctx.client.respondToPermission(agent.id, permission.id, {
|
||||||
|
behavior: "deny",
|
||||||
|
message: "Test complete",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for agent to finish processing the denial
|
||||||
|
await ctx.client.waitForAgentIdle(agent.id, 30000);
|
||||||
|
|
||||||
|
await ctx.client.deleteAgent(agent.id);
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
120000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// CLI exports for @paseo/server
|
||||||
|
export { createPaseoDaemon, type PaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||||
|
export { loadConfig } from "./config.js";
|
||||||
|
export { resolvePaseoHome } from "./paseo-home.js";
|
||||||
|
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
||||||
|
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
||||||
|
export { DaemonClientV2, type DaemonClientV2Config, type ConnectionState, type DaemonEvent } from "../client/daemon-client-v2.js";
|
||||||
|
|
||||||
|
// Agent SDK types for CLI commands
|
||||||
|
export type {
|
||||||
|
AgentMode,
|
||||||
|
AgentUsage,
|
||||||
|
AgentCapabilityFlags,
|
||||||
|
AgentPermissionRequest,
|
||||||
|
AgentTimelineItem,
|
||||||
|
} from "./agent/agent-sdk-types.js";
|
||||||
|
|
||||||
|
// Agent activity curator for CLI logs
|
||||||
|
export { curateAgentActivity } from "./agent/activity-curator.js";
|
||||||
|
|
||||||
|
// WebSocket message types for CLI streaming
|
||||||
|
export type {
|
||||||
|
AgentSnapshotPayload,
|
||||||
|
AgentStreamEventPayload,
|
||||||
|
AgentStreamMessage,
|
||||||
|
AgentStreamSnapshotMessage,
|
||||||
|
} from "../shared/messages.js";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user