chore: add npm-first docs, lint/format tooling, and CI workflows
This commit is contained in:
parent
9fd2a621bd
commit
38e8e5f330
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
36
.github/workflows/ci.yml
vendored
Normal file
36
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
39
.github/workflows/release.yml
vendored
Normal file
39
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
*.tgz
|
||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 88,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"semi": true
|
||||
}
|
||||
21
AGENTS.md
21
AGENTS.md
@ -63,13 +63,14 @@ Built-in friendly names map to commands:
|
||||
|
||||
```ts
|
||||
const AGENT_REGISTRY: Record<string, string> = {
|
||||
codex: 'npx @zed-industries/codex-acp',
|
||||
claude: 'npx @zed-industries/claude-agent-acp',
|
||||
gemini: 'gemini',
|
||||
codex: "npx @zed-industries/codex-acp",
|
||||
claude: "npx @zed-industries/claude-agent-acp",
|
||||
gemini: "gemini",
|
||||
};
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Known names resolve automatically.
|
||||
- Unknown names are treated as raw commands.
|
||||
- Escape hatch: `--agent <command>` sets a raw command explicitly.
|
||||
@ -140,14 +141,14 @@ Refactored the auth module to use async/await. All 42 tests passing.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Agent/protocol error |
|
||||
| 2 | CLI usage error |
|
||||
| 3 | Timeout |
|
||||
| Code | Meaning |
|
||||
| ---- | ---------------------------------------- |
|
||||
| 0 | Success |
|
||||
| 1 | Agent/protocol error |
|
||||
| 2 | CLI usage error |
|
||||
| 3 | Timeout |
|
||||
| 4 | Permission denied (all options rejected) |
|
||||
| 130 | Interrupted (Ctrl+C) |
|
||||
| 130 | Interrupted (Ctrl+C) |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
15
CONTRIBUTING.md
Normal file
15
CONTRIBUTING.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Contributing
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a branch for your change.
|
||||
3. Make your changes with focused commits.
|
||||
4. Run checks locally:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run build
|
||||
```
|
||||
|
||||
5. Open a pull request to `main` with a clear summary.
|
||||
135
README.md
135
README.md
@ -1,139 +1,60 @@
|
||||
# acpx
|
||||
|
||||
Headless CLI client for the [Agent Client Protocol (ACP)](https://agentclientprotocol.com) — talk to coding agents from the command line.
|
||||
Headless CLI client for the [Agent Client Protocol (ACP)](https://agentclientprotocol.com).
|
||||
|
||||
```bash
|
||||
# Conversational prompt (persistent session, auto-resume by agent+cwd)
|
||||
acpx codex "fix the tests"
|
||||
|
||||
# Explicit prompt verb (same behavior)
|
||||
acpx codex prompt "fix the tests"
|
||||
|
||||
# One-shot execution (no saved session)
|
||||
acpx codex exec "what does this repo do"
|
||||
|
||||
# Named session
|
||||
acpx codex -s backend "fix the API"
|
||||
|
||||
# Session management
|
||||
acpx codex sessions
|
||||
acpx codex sessions close
|
||||
acpx codex sessions close backend
|
||||
```
|
||||
|
||||
## Why?
|
||||
|
||||
ACP adapters exist for every major coding agent ([Codex](https://github.com/zed-industries/codex-acp), [Claude Code](https://github.com/zed-industries/claude-code-acp), [Gemini CLI](https://github.com/google-gemini/gemini-cli), etc.) but every ACP client is a GUI app or editor plugin.
|
||||
|
||||
`acpx` is the missing piece: a simple CLI that lets **agents talk to agents** (or humans script agents) over structured ACP instead of scraping terminal output.
|
||||
`acpx` is built for scriptable, session-aware agent usage from the terminal.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g acpx
|
||||
# or
|
||||
npx acpx codex "hello"
|
||||
npm i -g acpx
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
`acpx` manages persistent sessions, so prefer a global install. Avoid `npx acpx ...` for normal use.
|
||||
|
||||
You need an ACP-compatible agent installed:
|
||||
## Agent prerequisites
|
||||
|
||||
Install at least one ACP-compatible agent adapter:
|
||||
|
||||
```bash
|
||||
# Codex ACP adapter
|
||||
npm install -g @zed-industries/codex-acp
|
||||
|
||||
# Claude ACP adapter
|
||||
npm install -g @zed-industries/claude-agent-acp
|
||||
|
||||
# Gemini CLI (native ACP support)
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Command grammar
|
||||
## Core usage
|
||||
|
||||
```bash
|
||||
acpx <agent> [prompt] <text>
|
||||
acpx <agent> exec <text>
|
||||
acpx <agent> sessions [list|close]
|
||||
acpx codex 'fix the tests' # implicit prompt, auto-resume session
|
||||
acpx codex prompt 'fix the tests' # same, explicit
|
||||
acpx codex exec 'what does this repo do' # one-shot, no session
|
||||
acpx codex -s backend 'fix the API' # named session
|
||||
acpx codex sessions # list sessions
|
||||
acpx claude 'refactor auth' # different agent
|
||||
```
|
||||
|
||||
`prompt` is the default verb, so `acpx codex "..."` and `acpx codex prompt "..."` are equivalent.
|
||||
## Session behavior
|
||||
|
||||
### Built-in agent registry
|
||||
- Default mode is conversational: prompts use a saved session scoped to `(agent command, cwd)`.
|
||||
- `-s <name>` switches to a named session scoped to `(agent command, cwd, name)`.
|
||||
- `exec` is fire-and-forget: temporary session, prompt once, then discard.
|
||||
|
||||
Friendly names are resolved automatically:
|
||||
Session files are stored in `~/.acpx/sessions/`.
|
||||
|
||||
- `codex` -> `npx @zed-industries/codex-acp`
|
||||
- `claude` -> `npx @zed-industries/claude-agent-acp`
|
||||
- `gemini` -> `gemini`
|
||||
## Built-in agents and custom servers
|
||||
|
||||
Unknown agent names are treated as raw commands. You can also use the explicit escape hatch:
|
||||
Built-ins:
|
||||
|
||||
- `codex`
|
||||
- `claude`
|
||||
- `gemini`
|
||||
|
||||
Use `--agent` as an escape hatch for custom ACP servers:
|
||||
|
||||
```bash
|
||||
acpx --agent ./my-custom-server "do something"
|
||||
acpx --agent ./my-custom-acp-server 'do something'
|
||||
```
|
||||
|
||||
### Session behavior
|
||||
|
||||
- `prompt` always uses a saved session.
|
||||
- Sessions auto-resume by `(agent command, cwd)`.
|
||||
- `-s, --session <name>` uses a named session for that `(agent command, cwd)`.
|
||||
- `exec` is fire-and-forget (temporary session, not saved).
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
acpx codex "fix the tests"
|
||||
acpx codex -s backend "fix the API"
|
||||
acpx claude "refactor auth"
|
||||
acpx gemini "add logging"
|
||||
```
|
||||
|
||||
### Default agent shortcuts
|
||||
|
||||
If agent is omitted, default agent is `codex`:
|
||||
|
||||
```bash
|
||||
acpx prompt "fix tests"
|
||||
acpx exec "summarize this repo"
|
||||
acpx sessions
|
||||
```
|
||||
|
||||
### Global options (before agent name)
|
||||
|
||||
```text
|
||||
--agent <command> Raw ACP agent command (escape hatch)
|
||||
--cwd <dir> Working directory (default: .)
|
||||
--approve-all Auto-approve all permission requests
|
||||
--approve-reads Auto-approve reads/searches, prompt for writes
|
||||
--deny-all Deny all permission requests
|
||||
--format <fmt> Output format: text (default), json, quiet
|
||||
--timeout <seconds> Maximum time to wait for agent response
|
||||
--verbose Enable debug output on stderr
|
||||
```
|
||||
|
||||
### Output formats
|
||||
|
||||
| Format | Flag | Description |
|
||||
|--------|------|-------------|
|
||||
| text | `--format text` | Human-readable streaming (default) |
|
||||
| json | `--format json` | Structured ndjson for machines |
|
||||
| quiet | `--format quiet` | Final text output only |
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
┌─────────┐ stdio/ndjson ┌──────────────┐ wraps ┌─────────┐
|
||||
│ acpx │ ◄──────────────────► │ ACP adapter │ ◄───────────► │ Agent │
|
||||
│ (client)│ ACP protocol │ (codex-acp) │ │ (Codex) │
|
||||
└─────────┘ └──────────────┘ └─────────┘
|
||||
```
|
||||
|
||||
acpx spawns the ACP adapter as a child process, communicates over JSON-RPC/ndjson over stdio, and streams structured events (tool calls, text, permissions).
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
51
docs/2026-02-17-agent-registry.md
Normal file
51
docs/2026-02-17-agent-registry.md
Normal file
@ -0,0 +1,51 @@
|
||||
---
|
||||
title: acpx Agent Registry
|
||||
description: Built-in agent mappings, name resolution rules, and custom adapter usage with --agent.
|
||||
author: Bob <bob@dutifulbob.com>
|
||||
date: 2026-02-17
|
||||
---
|
||||
|
||||
## Built-in registry
|
||||
|
||||
`src/agent-registry.ts` defines friendly names:
|
||||
|
||||
- `codex -> npx @zed-industries/codex-acp`
|
||||
- `claude -> npx @zed-industries/claude-agent-acp`
|
||||
- `gemini -> gemini`
|
||||
|
||||
Default agent is `codex`.
|
||||
|
||||
## Resolution behavior
|
||||
|
||||
When you run `acpx <agent> ...`:
|
||||
|
||||
1. agent token is normalized (trim + lowercase)
|
||||
2. if it matches a built-in key, `acpx` uses the mapped command
|
||||
3. if it does not match, `acpx` treats it as a raw command
|
||||
|
||||
This means custom names work without any registry file edits.
|
||||
|
||||
## `--agent` escape hatch
|
||||
|
||||
`--agent <command>` forces a raw adapter command and bypasses positional agent resolution.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
acpx --agent ./my-custom-acp-server 'summarize this repo'
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- do not combine a positional agent with `--agent`
|
||||
- the command string is parsed into executable + args before spawn
|
||||
- the chosen command is what session scoping uses
|
||||
|
||||
## Practical guidance
|
||||
|
||||
Use built-ins for common adapters (`codex`, `claude`, `gemini`).
|
||||
Use `--agent` when you need:
|
||||
|
||||
- local development adapters
|
||||
- pinned binaries/scripts
|
||||
- non-standard ACP servers
|
||||
69
docs/2026-02-17-architecture.md
Normal file
69
docs/2026-02-17-architecture.md
Normal file
@ -0,0 +1,69 @@
|
||||
---
|
||||
title: acpx Architecture
|
||||
description: Internal architecture and runtime flow from CLI command to ACP session updates.
|
||||
author: Bob <bob@dutifulbob.com>
|
||||
date: 2026-02-17
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`acpx` is a CLI client that speaks ACP over stdio.
|
||||
|
||||
Data path:
|
||||
|
||||
`CLI command -> AcpClient -> ndjson/stdio -> ACP adapter -> coding agent`
|
||||
|
||||
The CLI never scrapes terminal text from an interactive UI. It talks structured ACP JSON-RPC messages directly.
|
||||
|
||||
## Core components
|
||||
|
||||
- `src/cli.ts`: command grammar, flags, output mode selection, and top-level command handling.
|
||||
- `src/client.ts`: ACP transport and protocol methods. Spawns the adapter process and connects with `ClientSideConnection` + `ndJsonStream`.
|
||||
- `src/session.ts`: session persistence, resume/create logic, timeout/interrupt handling, and lifecycle cleanup.
|
||||
- `src/permissions.ts`: permission policy (`approve-all`, `approve-reads`, `deny-all`) and interactive fallback.
|
||||
- `src/output.ts`: streaming text/json/quiet output formatters.
|
||||
|
||||
## Protocol flow
|
||||
|
||||
Typical prompt flow:
|
||||
|
||||
1. `initialize`
|
||||
2. `newSession` or `loadSession`
|
||||
3. `prompt`
|
||||
4. stream `sessionUpdate` notifications until done
|
||||
|
||||
Details:
|
||||
|
||||
- `initialize` advertises client capabilities (`fs.readTextFile`, `fs.writeTextFile`, `terminal`).
|
||||
- If a saved session exists and agent supports it, `loadSession` is attempted.
|
||||
- If load fails with not-found style errors, `acpx` falls back to `newSession`.
|
||||
- Prompt responses and notifications are streamed through the active formatter.
|
||||
|
||||
## Session persistence
|
||||
|
||||
Session metadata is stored in `~/.acpx/sessions/*.json`.
|
||||
|
||||
Each record includes:
|
||||
|
||||
- stable file/session id
|
||||
- ACP session id
|
||||
- agent command
|
||||
- cwd
|
||||
- optional named session
|
||||
- timestamps (`createdAt`, `lastUsedAt`)
|
||||
- adapter process pid (when known)
|
||||
- protocol/version capabilities snapshot
|
||||
|
||||
This lets `acpx` resume conversational context by default.
|
||||
|
||||
## Permission handling
|
||||
|
||||
Permission requests come in through ACP `requestPermission` callbacks.
|
||||
|
||||
Modes:
|
||||
|
||||
- `approve-all`: auto-approve first allow option
|
||||
- `approve-reads`: auto-approve read/search; prompt for others
|
||||
- `deny-all`: reject when possible
|
||||
|
||||
`acpx` tracks permission stats (requested/approved/denied/cancelled) and uses them for exit-code behavior.
|
||||
83
docs/2026-02-17-session-management.md
Normal file
83
docs/2026-02-17-session-management.md
Normal file
@ -0,0 +1,83 @@
|
||||
---
|
||||
title: acpx Session Management
|
||||
description: How acpx resumes, names, stores, and closes sessions including pid tracking and subprocess lifecycle.
|
||||
author: Bob <bob@dutifulbob.com>
|
||||
date: 2026-02-17
|
||||
---
|
||||
|
||||
## Session model
|
||||
|
||||
`acpx` is conversational by default.
|
||||
|
||||
Session lookup is scoped by:
|
||||
|
||||
- agent command
|
||||
- cwd
|
||||
- optional session name (`-s <name>`)
|
||||
|
||||
No `-s` means the default cwd session for that agent command.
|
||||
|
||||
## Auto-resume behavior
|
||||
|
||||
For prompt commands:
|
||||
|
||||
1. `findSession` searches stored records by `(agentCommand, cwd, name?)`.
|
||||
2. If no record exists, `createSession` creates ACP session + record.
|
||||
3. `sendSession` starts a fresh adapter process and tries `loadSession`.
|
||||
4. If load is unsupported or fails with known not-found/invalid errors, it falls back to `newSession`.
|
||||
5. After prompt completes, record metadata is updated and re-written.
|
||||
|
||||
## Named sessions
|
||||
|
||||
`-s backend` creates a parallel conversation stream for the same agent and cwd.
|
||||
|
||||
Example:
|
||||
|
||||
- default session: `acpx codex 'fix tests'`
|
||||
- named session: `acpx codex -s backend 'fix API'`
|
||||
|
||||
Both can coexist because names are part of the scope key.
|
||||
|
||||
## Session files
|
||||
|
||||
Stored under `~/.acpx/sessions/` as JSON files.
|
||||
|
||||
Record fields include:
|
||||
|
||||
- `id`
|
||||
- `sessionId`
|
||||
- `agentCommand`
|
||||
- `cwd`
|
||||
- `name` (optional)
|
||||
- `createdAt`, `lastUsedAt`
|
||||
- `pid` (adapter process pid, optional)
|
||||
- `protocolVersion`, `agentCapabilities` (optional)
|
||||
|
||||
Writes are done via temp file + rename for safer updates.
|
||||
|
||||
## loadSession protocol flow
|
||||
|
||||
Resume path in `sendSession`:
|
||||
|
||||
1. start ACP client process
|
||||
2. initialize protocol
|
||||
3. `loadSession(sessionId, cwd, mcpServers: [])`
|
||||
4. suppress replayed updates during load
|
||||
5. wait for session-update drain
|
||||
6. send new prompt
|
||||
|
||||
If resume fails with a fallback-eligible error, `newSession` is used and stored `sessionId` is replaced.
|
||||
|
||||
## PID tracking and process lifecycle
|
||||
|
||||
`acpx` stores the adapter pid in each session record to help with cleanup and diagnostics.
|
||||
|
||||
Lifecycle behavior:
|
||||
|
||||
- each command launches a new adapter subprocess
|
||||
- records track pid of the latest process used
|
||||
- `closeSession` tries to terminate the stored pid if still alive and likely matches expected command
|
||||
- process termination uses `SIGTERM` then `SIGKILL` fallback
|
||||
- signal handling (`SIGINT`, `SIGTERM`) closes client resources before exit
|
||||
|
||||
This keeps session files and local processes in sync while remaining robust to stale pids.
|
||||
36
eslint.config.js
Normal file
36
eslint.config.js
Normal file
@ -0,0 +1,36 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "*.tgz"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "inline-type-imports",
|
||||
},
|
||||
],
|
||||
"prefer-promise-reject-errors": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
1203
package-lock.json
generated
1203
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -15,7 +15,10 @@
|
||||
"build": "tsup src/cli.ts --format esm --dts --clean",
|
||||
"prepack": "npm run build",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"keywords": [
|
||||
"acp",
|
||||
@ -40,9 +43,14 @@
|
||||
"commander": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^22.0.0",
|
||||
"eslint": "^10.0.0",
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"tsup": "^8.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"@types/node": "^22.0.0"
|
||||
"typescript-eslint": "^8.56.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,9 +110,7 @@ async function main() {
|
||||
|
||||
// Create streams to communicate with the agent
|
||||
const input = Writable.toWeb(agentProcess.stdin!);
|
||||
const output = Readable.toWeb(
|
||||
agentProcess.stdout!,
|
||||
) as ReadableStream<Uint8Array>;
|
||||
const output = Readable.toWeb(agentProcess.stdout!) as ReadableStream<Uint8Array>;
|
||||
|
||||
// Create the client connection
|
||||
const client = new ExampleClient();
|
||||
@ -131,9 +129,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ Connected to agent (protocol v${initResult.protocolVersion})`,
|
||||
);
|
||||
console.log(`✅ Connected to agent (protocol v${initResult.protocolVersion})`);
|
||||
|
||||
// Create a new session
|
||||
const sessionResult = await connection.newSession({
|
||||
|
||||
@ -69,14 +69,19 @@ function resolveToolKindForPermission(
|
||||
params: RequestPermissionRequest,
|
||||
toolName: string | undefined,
|
||||
): string | undefined {
|
||||
const toolCall = params.toolCall as unknown as { kind?: unknown; title?: unknown } | undefined;
|
||||
const kindRaw = typeof toolCall?.kind === "string" ? toolCall.kind.trim().toLowerCase() : "";
|
||||
const toolCall = params.toolCall as unknown as
|
||||
| { kind?: unknown; title?: unknown }
|
||||
| undefined;
|
||||
const kindRaw =
|
||||
typeof toolCall?.kind === "string" ? toolCall.kind.trim().toLowerCase() : "";
|
||||
if (kindRaw) {
|
||||
return kindRaw;
|
||||
}
|
||||
const name =
|
||||
toolName ??
|
||||
parseToolNameFromTitle(typeof toolCall?.title === "string" ? toolCall.title : undefined);
|
||||
parseToolNameFromTitle(
|
||||
typeof toolCall?.title === "string" ? toolCall.title : undefined,
|
||||
);
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
@ -98,7 +103,11 @@ function resolveToolKindForPermission(
|
||||
if (normalized.includes("fetch") || normalized.includes("http")) {
|
||||
return "fetch";
|
||||
}
|
||||
if (normalized.includes("write") || normalized.includes("edit") || normalized.includes("patch")) {
|
||||
if (
|
||||
normalized.includes("write") ||
|
||||
normalized.includes("edit") ||
|
||||
normalized.includes("patch")
|
||||
) {
|
||||
return "edit";
|
||||
}
|
||||
if (normalized.includes("delete") || normalized.includes("remove")) {
|
||||
@ -107,19 +116,30 @@ function resolveToolKindForPermission(
|
||||
if (normalized.includes("move") || normalized.includes("rename")) {
|
||||
return "move";
|
||||
}
|
||||
if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) {
|
||||
if (
|
||||
normalized.includes("exec") ||
|
||||
normalized.includes("run") ||
|
||||
normalized.includes("bash")
|
||||
) {
|
||||
return "execute";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function resolveToolNameForPermission(params: RequestPermissionRequest): string | undefined {
|
||||
function resolveToolNameForPermission(
|
||||
params: RequestPermissionRequest,
|
||||
): string | undefined {
|
||||
const toolCall = params.toolCall;
|
||||
const toolMeta = asRecord(toolCall?._meta);
|
||||
const rawInput = asRecord(toolCall?.rawInput);
|
||||
|
||||
const fromMeta = readFirstStringValue(toolMeta, ["toolName", "tool_name", "name"]);
|
||||
const fromRawInput = readFirstStringValue(rawInput, ["tool", "toolName", "tool_name", "name"]);
|
||||
const fromRawInput = readFirstStringValue(rawInput, [
|
||||
"tool",
|
||||
"toolName",
|
||||
"tool_name",
|
||||
"name",
|
||||
]);
|
||||
const fromTitle = parseToolNameFromTitle(toolCall?.title);
|
||||
return normalizeToolName(fromMeta ?? fromRawInput ?? fromTitle ?? "");
|
||||
}
|
||||
@ -145,9 +165,14 @@ function cancelledPermission(): RequestPermissionResponse {
|
||||
return { outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
|
||||
function promptUserPermission(toolName: string | undefined, toolTitle?: string): Promise<boolean> {
|
||||
function promptUserPermission(
|
||||
toolName: string | undefined,
|
||||
toolTitle?: string,
|
||||
): Promise<boolean> {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
console.error(`[permission denied] ${toolName ?? "unknown"}: non-interactive terminal`);
|
||||
console.error(
|
||||
`[permission denied] ${toolName ?? "unknown"}: non-interactive terminal`,
|
||||
);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
@ -179,7 +204,9 @@ function promptUserPermission(toolName: string | undefined, toolTitle?: string):
|
||||
: (toolName ?? "unknown tool");
|
||||
rl.question(`\n[permission] Allow "${label}"? (y/N) `, (answer) => {
|
||||
const approved = answer.trim().toLowerCase() === "y";
|
||||
console.error(`[permission ${approved ? "approved" : "denied"}] ${toolName ?? "unknown"}`);
|
||||
console.error(
|
||||
`[permission ${approved ? "approved" : "denied"}] ${toolName ?? "unknown"}`,
|
||||
);
|
||||
finish(approved);
|
||||
});
|
||||
});
|
||||
@ -317,17 +344,23 @@ function printSessionUpdate(notification: SessionNotification): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAcpClient(opts: AcpClientOptions = {}): Promise<AcpClientHandle> {
|
||||
export async function createAcpClient(
|
||||
opts: AcpClientOptions = {},
|
||||
): Promise<AcpClientHandle> {
|
||||
const cwd = opts.cwd ?? process.cwd();
|
||||
const verbose = Boolean(opts.verbose);
|
||||
const log = verbose ? (msg: string) => console.error(`[acp-client] ${msg}`) : () => {};
|
||||
const log = verbose
|
||||
? (msg: string) => console.error(`[acp-client] ${msg}`)
|
||||
: () => {};
|
||||
|
||||
ensureOpenClawCliOnPath();
|
||||
const serverArgs = buildServerArgs(opts);
|
||||
|
||||
const entryPath = resolveSelfEntryPath();
|
||||
const serverCommand = opts.serverCommand ?? (entryPath ? process.execPath : "openclaw");
|
||||
const effectiveArgs = opts.serverCommand || !entryPath ? serverArgs : [entryPath, ...serverArgs];
|
||||
const serverCommand =
|
||||
opts.serverCommand ?? (entryPath ? process.execPath : "openclaw");
|
||||
const effectiveArgs =
|
||||
opts.serverCommand || !entryPath ? serverArgs : [entryPath, ...serverArgs];
|
||||
|
||||
log(`spawning: ${serverCommand} ${effectiveArgs.join(" ")}`);
|
||||
|
||||
@ -379,7 +412,9 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise<AcpC
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAcpClientInteractive(opts: AcpClientOptions = {}): Promise<void> {
|
||||
export async function runAcpClientInteractive(
|
||||
opts: AcpClientOptions = {},
|
||||
): Promise<void> {
|
||||
const { client, agent, sessionId } = await createAcpClient(opts);
|
||||
|
||||
const rl = readline.createInterface({
|
||||
|
||||
53
src/cli.ts
53
src/cli.ts
@ -49,7 +49,7 @@ const TOP_LEVEL_VERBS = new Set(["prompt", "exec", "sessions", "help"]);
|
||||
function parseOutputFormat(value: string): OutputFormat {
|
||||
if (!OUTPUT_FORMATS.includes(value as OutputFormat)) {
|
||||
throw new InvalidArgumentError(
|
||||
`Invalid format \"${value}\". Expected one of: ${OUTPUT_FORMATS.join(", ")}`,
|
||||
`Invalid format "${value}". Expected one of: ${OUTPUT_FORMATS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return value as OutputFormat;
|
||||
@ -207,10 +207,7 @@ function resolveAgentInvocation(
|
||||
};
|
||||
}
|
||||
|
||||
function printSessionsByFormat(
|
||||
sessions: SessionRecord[],
|
||||
format: OutputFormat,
|
||||
): void {
|
||||
function printSessionsByFormat(sessions: SessionRecord[], format: OutputFormat): void {
|
||||
if (format === "json") {
|
||||
process.stdout.write(`${JSON.stringify(sessions)}\n`);
|
||||
return;
|
||||
@ -235,10 +232,7 @@ function printSessionsByFormat(
|
||||
}
|
||||
}
|
||||
|
||||
function printClosedSessionByFormat(
|
||||
record: SessionRecord,
|
||||
format: OutputFormat,
|
||||
): void {
|
||||
function printClosedSessionByFormat(record: SessionRecord, format: OutputFormat): void {
|
||||
if (format === "json") {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@ -287,7 +281,7 @@ async function handlePrompt(
|
||||
});
|
||||
|
||||
if (globalFlags.verbose) {
|
||||
const scope = flags.session ? `named session \"${flags.session}\"` : "cwd session";
|
||||
const scope = flags.session ? `named session "${flags.session}"` : "cwd session";
|
||||
process.stderr.write(`[acpx] created ${scope}: ${record.id}\n`);
|
||||
}
|
||||
}
|
||||
@ -361,13 +355,11 @@ async function handleSessionsClose(
|
||||
if (!record) {
|
||||
if (sessionName) {
|
||||
throw new Error(
|
||||
`No named session \"${sessionName}\" for cwd ${agent.cwd} and agent ${agent.agentName}`,
|
||||
`No named session "${sessionName}" for cwd ${agent.cwd} and agent ${agent.agentName}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No cwd session for ${agent.cwd} and agent ${agent.agentName}`,
|
||||
);
|
||||
throw new Error(`No cwd session for ${agent.cwd} and agent ${agent.agentName}`);
|
||||
}
|
||||
|
||||
const closed = await closeSession(record.id);
|
||||
@ -559,30 +551,31 @@ async function main(): Promise<void> {
|
||||
registerAgentCommand(program, scan.token);
|
||||
}
|
||||
|
||||
program
|
||||
.argument("[prompt...]", "Prompt text")
|
||||
.action(async function (this: Command, promptParts: string[]) {
|
||||
if (promptParts.length === 0 && process.stdin.isTTY) {
|
||||
this.outputHelp();
|
||||
return;
|
||||
}
|
||||
program.argument("[prompt...]", "Prompt text").action(async function (
|
||||
this: Command,
|
||||
promptParts: string[],
|
||||
) {
|
||||
if (promptParts.length === 0 && process.stdin.isTTY) {
|
||||
this.outputHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
await handlePrompt(undefined, promptParts, {}, this);
|
||||
});
|
||||
await handlePrompt(undefined, promptParts, {}, this);
|
||||
});
|
||||
|
||||
program.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
acpx codex \"fix the tests\"
|
||||
acpx codex prompt \"fix the tests\"
|
||||
acpx codex exec \"what does this repo do\"
|
||||
acpx codex -s backend \"fix the API\"
|
||||
acpx codex "fix the tests"
|
||||
acpx codex prompt "fix the tests"
|
||||
acpx codex exec "what does this repo do"
|
||||
acpx codex -s backend "fix the API"
|
||||
acpx codex sessions
|
||||
acpx codex sessions close backend
|
||||
acpx claude \"refactor auth\"
|
||||
acpx gemini \"add logging\"
|
||||
acpx --agent ./my-custom-server \"do something\"`,
|
||||
acpx claude "refactor auth"
|
||||
acpx gemini "add logging"
|
||||
acpx --agent ./my-custom-server "do something"`,
|
||||
);
|
||||
|
||||
program.exitOverride((error) => {
|
||||
|
||||
@ -22,11 +22,7 @@ import {
|
||||
type WriteTextFileRequest,
|
||||
type WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
spawn,
|
||||
type ChildProcess,
|
||||
type ChildProcessByStdio,
|
||||
} from "node:child_process";
|
||||
import { spawn, type ChildProcess, type ChildProcessByStdio } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@ -145,9 +141,7 @@ function asAbsoluteCwd(cwd: string): string {
|
||||
return path.resolve(cwd);
|
||||
}
|
||||
|
||||
function toEnvObject(
|
||||
env: CreateTerminalRequest["env"],
|
||||
): NodeJS.ProcessEnv | undefined {
|
||||
function toEnvObject(env: CreateTerminalRequest["env"]): NodeJS.ProcessEnv | undefined {
|
||||
if (!env || env.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@ -488,7 +482,8 @@ export class AcpClient {
|
||||
throw new Error(`Unknown terminal: ${params.terminalId}`);
|
||||
}
|
||||
|
||||
const hasExitStatus = terminal.exitCode !== undefined || terminal.signal !== undefined;
|
||||
const hasExitStatus =
|
||||
terminal.exitCode !== undefined || terminal.signal !== undefined;
|
||||
|
||||
return {
|
||||
output: terminal.output.toString("utf8"),
|
||||
|
||||
@ -77,9 +77,7 @@ function isAutoApprovedReadKind(kind: ToolKind | undefined): boolean {
|
||||
return kind === "read" || kind === "search";
|
||||
}
|
||||
|
||||
async function promptForPermission(
|
||||
params: RequestPermissionRequest,
|
||||
): Promise<boolean> {
|
||||
async function promptForPermission(params: RequestPermissionRequest): Promise<boolean> {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
return false;
|
||||
}
|
||||
@ -160,10 +158,7 @@ export function classifyPermissionDecision(
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
if (
|
||||
selectedOption.kind === "allow_once" ||
|
||||
selectedOption.kind === "allow_always"
|
||||
) {
|
||||
if (selectedOption.kind === "allow_once" || selectedOption.kind === "allow_always") {
|
||||
return "approved";
|
||||
}
|
||||
|
||||
|
||||
@ -66,10 +66,7 @@ async function ensureSessionDir(): Promise<void> {
|
||||
await fs.mkdir(SESSION_BASE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs?: number): Promise<T> {
|
||||
if (!timeoutMs || timeoutMs <= 0) {
|
||||
return promise;
|
||||
}
|
||||
@ -309,10 +306,7 @@ function isProcessAlive(pid: number | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(
|
||||
pid: number,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + Math.max(0, timeoutMs);
|
||||
while (Date.now() <= deadline) {
|
||||
if (!isProcessAlive(pid)) {
|
||||
|
||||
@ -18,11 +18,7 @@ export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES];
|
||||
export const OUTPUT_FORMATS = ["text", "json", "quiet"] as const;
|
||||
export type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
||||
|
||||
export const PERMISSION_MODES = [
|
||||
"approve-all",
|
||||
"approve-reads",
|
||||
"deny-all",
|
||||
] as const;
|
||||
export const PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
|
||||
export type PermissionMode = (typeof PERMISSION_MODES)[number];
|
||||
|
||||
export type PermissionStats = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user