@plimeor/harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # @plimeor/harness
2
+
3
+ Drive CLI coding agents from one TypeScript API.
4
+
5
+ `@plimeor/harness` lets your app detect installed agents, check whether they
6
+ can answer a prompt, run tasks, decode common output modes, and install
7
+ user-scope integrations such as skills, MCP servers, and hooks.
8
+
9
+ It is an SDK only. It does not expose a CLI.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ bun add @plimeor/harness
15
+ ```
16
+
17
+ ## Pick an Agent
18
+
19
+ Importing the package registers the built-in adapters.
20
+
21
+ ```ts
22
+ import { harness } from '@plimeor/harness'
23
+
24
+ const available = await harness.detectAll()
25
+
26
+ for (const agent of available) {
27
+ if (agent.detected) {
28
+ console.log(agent.id, agent.binary?.identity)
29
+ }
30
+ }
31
+ ```
32
+
33
+ Open the adapter you want to use:
34
+
35
+ ```ts
36
+ const handle = await harness.open('codex', {
37
+ cwd: process.cwd()
38
+ })
39
+ ```
40
+
41
+ ## Check Health
42
+
43
+ Health checks answer one product question: can this CLI run right now?
44
+
45
+ ```ts
46
+ const health = await handle.health.check()
47
+
48
+ if (!health.success) {
49
+ throw new Error(health.message)
50
+ }
51
+ ```
52
+
53
+ A successful report means the CLI is installed and produced output for a smoke
54
+ prompt. Codex and Claude health checks also verify that `google.com` is
55
+ reachable before running the smoke prompt. A failed report includes a message
56
+ suitable for showing to a user.
57
+
58
+ ## Run a Task
59
+
60
+ For the common path, pass a request directly to `process.run()`.
61
+
62
+ ```ts
63
+ const run = await handle.process.run({
64
+ prompt: 'Summarize this repository in three bullets.',
65
+ timeoutMs: 60_000
66
+ })
67
+
68
+ const result = await run.result
69
+
70
+ console.log(result.finalText)
71
+ ```
72
+
73
+ `run.stdout` and `run.stderr` are async iterables, so you can stream raw process
74
+ output while still awaiting `run.result`.
75
+
76
+ Use `plan()` first when your app needs to show or approve the exact command
77
+ before spawning it:
78
+
79
+ ```ts
80
+ const plan = await handle.process.plan({
81
+ prompt: 'Summarize this repository in three bullets.'
82
+ })
83
+
84
+ console.log(plan.command, plan.args)
85
+
86
+ const run = await handle.process.run(plan)
87
+ ```
88
+
89
+ ## Output Modes
90
+
91
+ Text output is the default:
92
+
93
+ ```ts
94
+ const run = await handle.process.run({
95
+ prompt: 'Reply with OK.'
96
+ })
97
+ ```
98
+
99
+ Use JSONL when the adapter supports native JSON events:
100
+
101
+ ```ts
102
+ const run = await handle.process.run({
103
+ output: { mode: 'jsonl' },
104
+ prompt: 'Reply with OK.'
105
+ })
106
+
107
+ for await (const event of run.events) {
108
+ if (event.type === 'json') {
109
+ console.log(event.value)
110
+ }
111
+ }
112
+ ```
113
+
114
+ Use structured output when you need a validated object. Schemas use
115
+ `StandardSchemaV1`, so libraries such as Valibot can provide the schema.
116
+
117
+ ```ts
118
+ import * as v from 'valibot'
119
+
120
+ const Answer = v.object({
121
+ answer: v.string()
122
+ })
123
+
124
+ const run = await handle.process.run({
125
+ output: { mode: 'structured', schema: Answer },
126
+ prompt: 'Return JSON with an answer field.'
127
+ })
128
+
129
+ const result = await run.result
130
+
131
+ console.log(result.structured.answer)
132
+ ```
133
+
134
+ Unsupported output modes fail during `process.plan()` with `HarnessPlanError`.
135
+ Invalid JSON or failed structured validation fails from `run.result` with
136
+ `HarnessRunOutputError`.
137
+
138
+ ## Install Extensions
139
+
140
+ Extensions describe user-scope resources your app wants the selected agent to
141
+ know about.
142
+
143
+ ```ts
144
+ const extension = {
145
+ id: 'acme-tools',
146
+ resources: {
147
+ skills: ['./skills/review'],
148
+ mcpServers: {
149
+ 'acme-tools__docs': {
150
+ command: 'bun',
151
+ args: ['run', 'docs-mcp.ts'],
152
+ env: { DOCS_ROOT: '/workspace/docs' }
153
+ }
154
+ },
155
+ hooks: [
156
+ {
157
+ name: 'acme-tools__pre-tool',
158
+ event: 'PreToolUse',
159
+ command: 'bun run hooks/pre-tool.ts'
160
+ }
161
+ ]
162
+ }
163
+ }
164
+ ```
165
+
166
+ Check compatibility before installing:
167
+
168
+ ```ts
169
+ const check = await handle.extensions.check(extension)
170
+
171
+ if (!check.compatible) {
172
+ console.error(check.issues)
173
+ return
174
+ }
175
+ ```
176
+
177
+ Install and uninstall through the adapter:
178
+
179
+ ```ts
180
+ const installed = await handle.extensions.install(extension)
181
+
182
+ if (!installed.success) {
183
+ console.error(installed.issues)
184
+ }
185
+
186
+ await handle.extensions.uninstall('acme-tools')
187
+ ```
188
+
189
+ Skills are filesystem paths. Relative paths resolve from `HarnessContext.cwd`.
190
+ MCP servers are stdio process configs. Hooks use the target agent's native event
191
+ names.
192
+
193
+ Install is all-or-nothing: unsupported resources or conflicts prevent native
194
+ writes. Uninstall only removes resources that the adapter can still prove belong
195
+ to the extension.
196
+
197
+ ## Built-In Adapters
198
+
199
+ | Adapter | CLI command | Output modes | Extensions |
200
+ | --- | --- | --- | --- |
201
+ | `codex` | `codex` | `text`, `jsonl`, `structured` | skills, MCP servers, hooks |
202
+ | `claude` | `claude` | `text`, `jsonl`, `structured` | skills, MCP servers, hooks |
203
+ | `kiro` | `kiro-cli` | `text` | skills, MCP servers, hooks |
204
+ | `pi` | `pi` | `text`, `jsonl` | skills |
205
+
206
+ ## Context
207
+
208
+ Pass `HarnessContext` when your app needs deterministic paths or environment:
209
+
210
+ ```ts
211
+ const handle = await harness.open('kiro', {
212
+ cwd: '/workspace/project',
213
+ env: { KIRO_HOME: '/tmp/kiro-home' },
214
+ home: '/tmp/user-home'
215
+ })
216
+ ```
217
+
218
+ - `cwd` is the default working directory for runs and relative extension paths.
219
+ - `env` patches the process environment used by detection, planning, and native
220
+ adapter commands.
221
+ - `home` controls where user-scope config is resolved.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "description": "Unified infrastructure contract for CLI coding-agent harnesses",
3
+ "homepage": "https://github.com/plimeor/labs/tree/main/packages/harness",
4
+ "name": "@plimeor/harness",
5
+ "type": "module",
6
+ "types": "./src/index.ts",
7
+ "version": "0.1.0",
8
+ "bugs": {
9
+ "url": "https://github.com/plimeor/labs/issues"
10
+ },
11
+ "dependencies": {
12
+ "@standard-schema/spec": "^1.1.0"
13
+ },
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./adapters": "./src/adapters/index.ts",
17
+ "./adapters/claude": "./src/adapters/claude.ts",
18
+ "./adapters/codex": "./src/adapters/codex.ts",
19
+ "./adapters/kiro": "./src/adapters/kiro.ts",
20
+ "./adapters/pi": "./src/adapters/pi.ts"
21
+ },
22
+ "files": [
23
+ "src"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "directory": "packages/harness",
30
+ "type": "git",
31
+ "url": "git+https://github.com/plimeor/labs.git"
32
+ },
33
+ "scripts": {
34
+ "check": "tsc --noEmit -p tsconfig.json",
35
+ "lint": "biome check src test",
36
+ "prepack": "bun src/index.ts",
37
+ "test": "bun test test"
38
+ }
39
+ }
@@ -0,0 +1,112 @@
1
+ import { harness } from '../registry'
2
+ import { resolveOutputJsonSchema } from '../schema'
3
+ import type {
4
+ HarnessContext,
5
+ JsonlOutputRequest,
6
+ RunOutputRequest,
7
+ RunRequest,
8
+ StructuredOutputRequest,
9
+ TextOutputRequest
10
+ } from '../types'
11
+ import { configDirectory, createExtensionFacet } from './extensions'
12
+ import { createBuiltInAdapter, planCommand, planTextCommand, unsupportedOutputMode } from './shared'
13
+
14
+ const HARNESS_ID = 'claude'
15
+
16
+ export const claudeAdapter = createBuiltInAdapter({
17
+ commands: ['claude'],
18
+ id: HARNESS_ID,
19
+ identity: /claude/i,
20
+ installHint: 'Install Claude Code and ensure `claude --version` is available on PATH.',
21
+ requiresGoogleAccessBeforeSmoke: true,
22
+ extensions(context: HarnessContext | undefined) {
23
+ const directory = configDirectory(context?.home, '.claude')
24
+ return createExtensionFacet({
25
+ configDirectory: directory,
26
+ context,
27
+ harnessId: HARNESS_ID,
28
+ mcp: { configFile: `${directory}/mcp.json`, kind: 'claude-json' },
29
+ skillsDirectory: `${directory}/skills`,
30
+ hooks: {
31
+ kind: 'json-hooks',
32
+ settingsFile: `${directory}/settings.json`,
33
+ events: [
34
+ 'SessionStart',
35
+ 'Setup',
36
+ 'UserPromptSubmit',
37
+ 'UserPromptExpansion',
38
+ 'PreToolUse',
39
+ 'PermissionRequest',
40
+ 'PermissionDenied',
41
+ 'PostToolUse',
42
+ 'PostToolUseFailure',
43
+ 'PostToolBatch',
44
+ 'Notification',
45
+ 'MessageDisplay',
46
+ 'SubagentStart',
47
+ 'SubagentStop',
48
+ 'TaskCreated',
49
+ 'TaskCompleted',
50
+ 'Stop',
51
+ 'StopFailure',
52
+ 'TeammateIdle',
53
+ 'InstructionsLoaded',
54
+ 'ConfigChange',
55
+ 'CwdChanged',
56
+ 'FileChanged',
57
+ 'WorktreeCreate',
58
+ 'WorktreeRemove',
59
+ 'PreCompact',
60
+ 'PostCompact',
61
+ 'Elicitation',
62
+ 'ElicitationResult',
63
+ 'SessionEnd'
64
+ ]
65
+ }
66
+ })
67
+ },
68
+ plan(request: RunRequest<RunOutputRequest>, command: string, cwd: string) {
69
+ const output = request.output ?? ({ mode: 'text' } satisfies TextOutputRequest)
70
+ if (output.mode === 'jsonl') {
71
+ return planTextCommand({
72
+ args: ['-p', '--output-format', 'json', request.prompt],
73
+ command,
74
+ cwd,
75
+ harnessId: HARNESS_ID,
76
+ output: output satisfies JsonlOutputRequest,
77
+ request
78
+ })
79
+ }
80
+
81
+ if (output.mode === 'structured') {
82
+ const jsonSchema = resolveOutputJsonSchema(output.schema)
83
+ if (!jsonSchema) {
84
+ return unsupportedOutputMode(HARNESS_ID, output)
85
+ }
86
+
87
+ return planCommand({
88
+ args: ['-p', '--output-format', 'json', '--json-schema', JSON.stringify(jsonSchema), request.prompt],
89
+ command,
90
+ cwd,
91
+ harnessId: HARNESS_ID,
92
+ output: output satisfies StructuredOutputRequest,
93
+ request
94
+ })
95
+ }
96
+
97
+ if (!output.mode || output.mode === 'text') {
98
+ return planTextCommand({
99
+ args: ['-p', '--output-format', 'text', request.prompt],
100
+ command,
101
+ cwd,
102
+ harnessId: HARNESS_ID,
103
+ output: { mode: 'text' },
104
+ request
105
+ })
106
+ }
107
+
108
+ return unsupportedOutputMode(HARNESS_ID, output)
109
+ }
110
+ })
111
+
112
+ harness.use(claudeAdapter)
@@ -0,0 +1,118 @@
1
+ import { harness } from '../registry'
2
+ import { resolveOutputJsonSchema } from '../schema'
3
+ import type {
4
+ HarnessContext,
5
+ JsonlOutputRequest,
6
+ RunOutputRequest,
7
+ RunRequest,
8
+ StructuredOutputRequest,
9
+ TextOutputRequest
10
+ } from '../types'
11
+ import { configDirectory, createExtensionFacet } from './extensions'
12
+ import { createBuiltInAdapter, planCommand, planTextCommand, shellQuote, unsupportedOutputMode } from './shared'
13
+
14
+ const HARNESS_ID = 'codex'
15
+
16
+ export const codexAdapter = createBuiltInAdapter({
17
+ commands: ['codex'],
18
+ id: HARNESS_ID,
19
+ identity: /codex/i,
20
+ installHint: 'Install OpenAI Codex CLI and ensure `codex --version` is available on PATH.',
21
+ requiresGoogleAccessBeforeSmoke: true,
22
+ extensions(context: HarnessContext | undefined) {
23
+ const directory = configDirectory(context?.home, '.codex')
24
+ return createExtensionFacet({
25
+ configDirectory: directory,
26
+ context,
27
+ harnessId: HARNESS_ID,
28
+ mcp: { configFile: `${directory}/config.toml`, kind: 'codex-toml' },
29
+ skillsDirectory: `${directory}/skills`,
30
+ hooks: {
31
+ kind: 'json-hooks',
32
+ settingsFile: `${directory}/hooks.json`,
33
+ events: [
34
+ 'PermissionRequest',
35
+ 'PostCompact',
36
+ 'PostToolUse',
37
+ 'PreCompact',
38
+ 'PreToolUse',
39
+ 'SessionStart',
40
+ 'Stop',
41
+ 'SubagentStart',
42
+ 'SubagentStop',
43
+ 'UserPromptSubmit'
44
+ ]
45
+ }
46
+ })
47
+ },
48
+ plan(request: RunRequest<RunOutputRequest>, command: string, cwd: string) {
49
+ const output = request.output ?? ({ mode: 'text' } satisfies TextOutputRequest)
50
+ if (output.mode === 'jsonl') {
51
+ return planTextCommand({
52
+ args: ['exec', '--skip-git-repo-check', '--json', request.prompt],
53
+ command,
54
+ cwd,
55
+ harnessId: HARNESS_ID,
56
+ output: output satisfies JsonlOutputRequest,
57
+ request
58
+ })
59
+ }
60
+
61
+ if (output.mode === 'structured') {
62
+ const jsonSchema = resolveOutputJsonSchema(output.schema)
63
+ if (!jsonSchema) {
64
+ return unsupportedOutputMode(HARNESS_ID, output)
65
+ }
66
+
67
+ return planCommand({
68
+ args: ['-lc', codexStructuredCommand(command, request.prompt, JSON.stringify(jsonSchema))],
69
+ command: 'sh',
70
+ cwd,
71
+ harnessId: HARNESS_ID,
72
+ output: output satisfies StructuredOutputRequest,
73
+ request
74
+ })
75
+ }
76
+
77
+ if (!output.mode || output.mode === 'text') {
78
+ return planTextCommand({
79
+ args: ['exec', '--skip-git-repo-check', '--color', 'never', request.prompt],
80
+ command,
81
+ cwd,
82
+ harnessId: HARNESS_ID,
83
+ output: { mode: 'text' },
84
+ request
85
+ })
86
+ }
87
+
88
+ return unsupportedOutputMode(HARNESS_ID, output)
89
+ }
90
+ })
91
+
92
+ harness.use(codexAdapter)
93
+
94
+ function codexStructuredCommand(command: string, prompt: string, jsonSchema: string): string {
95
+ return [
96
+ 'schema=$(mktemp /tmp/codex-schema.XXXXXX)',
97
+ 'out=$(mktemp /tmp/codex-output.XXXXXX)',
98
+ 'cleanup() { rm -f "$schema" "$out"; }',
99
+ 'trap cleanup EXIT',
100
+ `printf %s ${shellQuote(jsonSchema)} > "$schema"`,
101
+ [
102
+ shellQuote(command),
103
+ 'exec',
104
+ '--skip-git-repo-check',
105
+ '--color',
106
+ 'never',
107
+ '--output-schema',
108
+ '"$schema"',
109
+ '--output-last-message',
110
+ '"$out"',
111
+ shellQuote(prompt),
112
+ '>/dev/null'
113
+ ].join(' '),
114
+ 'code=$?',
115
+ '[ -f "$out" ] && cat "$out"',
116
+ 'exit "$code"'
117
+ ].join('; ')
118
+ }