@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 +221 -0
- package/package.json +39 -0
- package/src/adapters/claude.ts +112 -0
- package/src/adapters/codex.ts +118 -0
- package/src/adapters/extensions.ts +1235 -0
- package/src/adapters/index.ts +9 -0
- package/src/adapters/kiro.ts +57 -0
- package/src/adapters/pi.ts +65 -0
- package/src/adapters/shared.ts +338 -0
- package/src/errors.ts +43 -0
- package/src/index.ts +34 -0
- package/src/output.ts +183 -0
- package/src/process.ts +183 -0
- package/src/registry.ts +34 -0
- package/src/schema.ts +18 -0
- package/src/types.ts +166 -0
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
|
+
}
|