diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfe674f..f3d5731 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,22 +68,45 @@ jobs: security: runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Use Node.js 20.x uses: actions/setup-node@v4 with: node-version: 20.x cache: 'npm' - + - name: Install dependencies run: npm ci - + - name: Run security audit run: npm audit --audit-level=moderate - + - name: Check for vulnerabilities - run: npm audit --audit-level=high --production \ No newline at end of file + run: npm audit --audit-level=high --production + + validate-acp-providers: + # Fail the build if src/models/acp-providers.json drifts from the + # Python source of truth in openhands-sdk. Not gated by + # continue-on-error — drift is a bug and must block merging. + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install openhands-sdk + run: pip install -r scripts/requirements-acp-check.txt + + - name: Check ACP_PROVIDERS matches openhands-sdk + env: + OPENHANDS_SUPPRESS_BANNER: '1' + run: python scripts/check-acp-drift.py \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9144ec9..f29bb68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,20 @@ npm run test:integration - Keep the root SDK surface ergonomic; lower-level endpoint clients belong in `@openhands/typescript-client/clients`. +## ACP provider registry + +The data in `src/models/acp-providers.json` mirrors +`openhands.sdk.settings.acp_providers.ACP_PROVIDERS` in +[software-agent-sdk](https://github.com/OpenHands/software-agent-sdk). The +Python module is the canonical source — edit `acp-providers.json` here when +it changes upstream. The `validate-acp-providers` CI job diffs the two on +every PR; to run it locally: + +```bash +pip install -r scripts/requirements-acp-check.txt +python scripts/check-acp-drift.py +``` + ## Pull requests - Open focused PRs with a clear description of what changed and why. diff --git a/package.json b/package.json index 99c5c9a..17cbb54 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ } }, "scripts": { - "build": "tsc && node ./scripts/rewrite-relative-imports.mjs", + "build": "tsc && node ./scripts/copy-json-assets.mjs && node ./scripts/rewrite-relative-imports.mjs", "lint": "eslint src/**/*.ts", "lint:fix": "eslint src/**/*.ts --fix", "format": "prettier --write \"src/**/*.ts\" \"examples/**/*.ts\" \"scripts/**/*.mjs\"", diff --git a/scripts/check-acp-drift.py b/scripts/check-acp-drift.py new file mode 100644 index 0000000..9d7ff7d --- /dev/null +++ b/scripts/check-acp-drift.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""ACP-registry drift check vs openhands-sdk. + +Reads `src/models/acp-providers.json` (the TS-side source of truth) and +compares it field-for-field against `ACP_PROVIDERS` in the installed +openhands-sdk. Exits non-zero with a unified diff on mismatch. + +Run locally: + pip install -r scripts/requirements-acp-check.txt + python scripts/check-acp-drift.py + +CI runs this as the `validate-acp-providers` job on every PR + push to main. +""" + +from __future__ import annotations + +import dataclasses +import difflib +import json +import pathlib +import sys + + +def _normalize(value): + """Coerce tuples to lists and dataclasses to dicts; recurse.""" + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return _normalize(dataclasses.asdict(value)) + if isinstance(value, dict): + return {k: _normalize(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize(v) for v in value] + return value + + +def _dump_json(obj) -> str: + return json.dumps(obj, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def main() -> int: + try: + from openhands.sdk.settings.acp_providers import ACP_PROVIDERS # type: ignore[import-not-found] + except ImportError as exc: + print( + "ERROR: cannot import openhands-sdk. " + "Run `pip install -r scripts/requirements-acp-check.txt` first.", + file=sys.stderr, + ) + print(f" ({exc})", file=sys.stderr) + return 2 + + repo_root = pathlib.Path(__file__).resolve().parent.parent + ts_json_path = repo_root / "src" / "models" / "acp-providers.json" + ts_data = _normalize(json.loads(ts_json_path.read_text())) + py_data = _normalize(dict(ACP_PROVIDERS)) + + ts_text = _dump_json(ts_data) + py_text = _dump_json(py_data) + + if ts_text == py_text: + n = len(ts_data) if isinstance(ts_data, dict) else 0 + print(f"OK: src/models/acp-providers.json matches openhands-sdk ({n} providers).") + return 0 + + print( + "ERROR: src/models/acp-providers.json has drifted from openhands-sdk.\n" + "Update src/models/acp-providers.json to match the Python source at\n" + "openhands-sdk/openhands/sdk/settings/acp_providers.py.\n", + file=sys.stderr, + ) + diff = difflib.unified_diff( + ts_text.splitlines(keepends=True), + py_text.splitlines(keepends=True), + fromfile="src/models/acp-providers.json", + tofile="openhands-sdk ACP_PROVIDERS", + ) + sys.stderr.writelines(diff) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/copy-json-assets.mjs b/scripts/copy-json-assets.mjs new file mode 100644 index 0000000..1f537fe --- /dev/null +++ b/scripts/copy-json-assets.mjs @@ -0,0 +1,39 @@ +// Copy non-TS assets (.json) from src/ to dist/ after tsc runs. +// tsc does not emit imported JSON files even with resolveJsonModule; +// without this step, `import x from './acp-providers.json'` resolves at +// compile time but fails at runtime in the published package. + +import { copyFile, mkdir, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SRC = path.join(ROOT, 'src'); +const DIST = path.join(ROOT, 'dist'); +const EXTENSIONS = new Set(['.json']); + +async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walk(full))); + } else if (EXTENSIONS.has(path.extname(entry.name))) { + files.push(full); + } + } + return files; +} + +const sources = await walk(SRC); +for (const src of sources) { + const rel = path.relative(SRC, src); + const dest = path.join(DIST, rel); + await mkdir(path.dirname(dest), { recursive: true }); + await copyFile(src, dest); +} + +if (sources.length > 0) { + console.log(`copy-json-assets: copied ${sources.length} file(s) to dist/`); +} diff --git a/scripts/requirements-acp-check.txt b/scripts/requirements-acp-check.txt new file mode 100644 index 0000000..2fe7bb1 --- /dev/null +++ b/scripts/requirements-acp-check.txt @@ -0,0 +1,5 @@ +# Pinned dependency for scripts/check-acp-drift.py. +# Tracks `main` of OpenHands/software-agent-sdk intentionally: the drift +# check exists to fail CI when ACP_PROVIDERS moves upstream so this repo's +# JSON mirror gets updated. Do NOT pin to a tag or sha. +openhands-sdk @ git+https://github.com/OpenHands/software-agent-sdk@main#subdirectory=openhands-sdk diff --git a/src/index.ts b/src/index.ts index 72417d3..cbb896c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -201,6 +201,10 @@ export type { ConversationType, } from './conversation/base'; +// ACP provider registry (mirrors openhands-sdk; see scripts/validate-acp-providers.mjs) +export { ACP_PROVIDERS, ACP_SETTINGS_KEYS, getAcpProvider } from './models/acp'; +export type { ACPProviderInfo, ACPProviderKey } from './models/acp'; + // Conversation models export type { ConversationInfo, diff --git a/src/models/acp-providers.json b/src/models/acp-providers.json new file mode 100644 index 0000000..9c89c00 --- /dev/null +++ b/src/models/acp-providers.json @@ -0,0 +1,35 @@ +{ + "claude-code": { + "key": "claude-code", + "display_name": "Claude Code", + "default_command": ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], + "api_key_env_var": "ANTHROPIC_API_KEY", + "base_url_env_var": "ANTHROPIC_BASE_URL", + "default_session_mode": "bypassPermissions", + "agent_name_patterns": ["claude-agent"], + "supports_set_session_model": false, + "session_meta_key": "claudeCode" + }, + "codex": { + "key": "codex", + "display_name": "Codex", + "default_command": ["npx", "-y", "@zed-industries/codex-acp"], + "api_key_env_var": "OPENAI_API_KEY", + "base_url_env_var": "OPENAI_BASE_URL", + "default_session_mode": "full-access", + "agent_name_patterns": ["codex-acp"], + "supports_set_session_model": true, + "session_meta_key": null + }, + "gemini-cli": { + "key": "gemini-cli", + "display_name": "Gemini CLI", + "default_command": ["npx", "-y", "@google/gemini-cli", "--acp"], + "api_key_env_var": "GEMINI_API_KEY", + "base_url_env_var": "GEMINI_BASE_URL", + "default_session_mode": "yolo", + "agent_name_patterns": ["gemini-cli"], + "supports_set_session_model": true, + "session_meta_key": null + } +} diff --git a/src/models/acp.ts b/src/models/acp.ts new file mode 100644 index 0000000..1a14718 --- /dev/null +++ b/src/models/acp.ts @@ -0,0 +1,75 @@ +/** + * ACP (Agent Client Protocol) provider registry — TypeScript mirror of the + * Python source of truth at + * `openhands.sdk.settings.acp_providers.ACP_PROVIDERS` in + * https://github.com/OpenHands/software-agent-sdk. + * + * The data lives in `./acp-providers.json` so the Python drift check in + * `scripts/check-acp-drift.py` can read it without executing TypeScript. + * To add or modify a provider, edit `acp_providers.py` in software-agent-sdk + * first, then mirror the change in `acp-providers.json` here. CI will fail + * until the two match. + */ + +import providersData from './acp-providers.json'; + +/** + * Stable registry key for a built-in ACP provider. + * + * Does **not** include `'custom'` — `custom` is a UI-side discriminator + * meaning "user typed their own command" and intentionally has no registry + * entry. + */ +export type ACPProviderKey = 'claude-code' | 'codex' | 'gemini-cli'; + +/** + * Immutable metadata record for one built-in ACP provider. Mirrors + * `openhands.sdk.settings.acp_providers.ACPProviderInfo` field-for-field. + */ +export interface ACPProviderInfo { + readonly key: ACPProviderKey; + readonly display_name: string; + readonly default_command: readonly string[]; + /** `null` for providers that authenticate via browser login. */ + readonly api_key_env_var: string | null; + /** `null` if the provider does not support env-based base-URL override. */ + readonly base_url_env_var: string | null; + /** ACP session-mode ID that suppresses all permission prompts. */ + readonly default_session_mode: string; + /** Lowercase substring fragments matched against the runtime agent name. */ + readonly agent_name_patterns: readonly string[]; + /** `true` if this provider uses the `set_session_model` protocol call. */ + readonly supports_set_session_model: boolean; + /** Top-level `_meta` key for model selection, or `null`. */ + readonly session_meta_key: string | null; +} + +export const ACP_PROVIDERS: Readonly> = + providersData as Readonly>; + +/** + * Return the {@link ACPProviderInfo} for `key`, or `null` if unknown. + * Mirrors the Python `get_acp_provider` semantics — returns `null` for the + * UI-side `'custom'` discriminator and for any unknown / falsy value. + */ +export function getAcpProvider(key: string | null | undefined): ACPProviderInfo | null { + if (!key) { + return null; + } + return (ACP_PROVIDERS as Record)[key] ?? null; +} + +/** + * Allow-list of fields that travel on an `ACPAgent` settings payload. + * Mirrors `openhands.sdk.settings.model.ACPAgentSettings`'s field set. + * Clients filter ACP-only fields out when switching to a non-ACP variant. + */ +export const ACP_SETTINGS_KEYS: readonly string[] = [ + 'acp_command', + 'acp_args', + 'acp_env', + 'acp_model', + 'acp_session_mode', + 'acp_prompt_timeout', + 'acp_server', +];