martty 0.2.33 → 0.2.35
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 +108 -11
- package/bin/martty.js +52 -5
- package/lib/acp-client.js +296 -20
- package/lib/acp-registry.snapshot.json +1450 -0
- package/lib/acp-session-config.js +23 -0
- package/lib/acp-session-plan.js +14 -2
- package/lib/acp-session-stats.js +22 -5
- package/lib/acp-session-status.js +70 -25
- package/lib/agent.js +24 -4
- package/lib/boot.js +29 -5
- package/lib/command-args.js +28 -0
- package/lib/download.js +165 -0
- package/lib/harness-discovery.js +41 -0
- package/lib/harness-package.js +200 -0
- package/lib/harness-registry.js +468 -0
- package/lib/harness-removal.js +88 -0
- package/lib/harness-view.js +639 -46
- package/lib/harnesses.js +1053 -41
- package/lib/index.js +4 -0
- package/lib/mux.js +23 -0
- package/lib/plan-view.js +18 -3
- package/lib/status-view.js +2 -0
- package/lib/tui-commands.js +7 -1
- package/lib/tui-overlay.js +27 -5
- package/package.json +7 -4
- package/vendor/darwin-arm64/martty +0 -0
- package/vendor/darwin-x64/martty +0 -0
- package/vendor/linux-arm64/martty +0 -0
- package/vendor/linux-x64/martty +0 -0
- package/vendor/win32-x64/martty.exe +0 -0
package/lib/harnesses.js
CHANGED
|
@@ -3,24 +3,309 @@ import {
|
|
|
3
3
|
mkdirSync,
|
|
4
4
|
readFileSync,
|
|
5
5
|
readdirSync,
|
|
6
|
+
realpathSync,
|
|
6
7
|
renameSync,
|
|
7
8
|
statSync,
|
|
8
9
|
writeFileSync,
|
|
9
10
|
} from 'node:fs'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
10
12
|
import path from 'node:path'
|
|
13
|
+
export { tokenizeCommandArgs as tokenizeHarnessArgs } from './command-args.js'
|
|
14
|
+
import {
|
|
15
|
+
fetchAcpRegistry,
|
|
16
|
+
installRegistryBinary,
|
|
17
|
+
managedBinaryPath,
|
|
18
|
+
normalizeAcpRegistry,
|
|
19
|
+
readAcpRegistrySnapshot,
|
|
20
|
+
} from './harness-registry.js'
|
|
21
|
+
|
|
22
|
+
export { fetchAcpRegistry } from './harness-registry.js'
|
|
11
23
|
|
|
12
24
|
const HARNESS_ID = /^[a-z0-9][a-z0-9-]*$/
|
|
13
|
-
const
|
|
25
|
+
const ANSI = Object.freeze({ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', cyan: '\x1b[36m', green: '\x1b[32m' })
|
|
26
|
+
|
|
27
|
+
// Kept as a source-compatible empty export. The source of truth is the
|
|
28
|
+
// official ACP Registry loaded by fetchAcpRegistry(), never a Martty table.
|
|
29
|
+
export const HARNESS_REGISTRY = Object.freeze([])
|
|
30
|
+
|
|
31
|
+
function paint(value, tone, color) {
|
|
32
|
+
return color ? `${ANSI[tone]}${value}${ANSI.reset}` : value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cellWidth(char) {
|
|
36
|
+
const code = char.codePointAt(0)
|
|
37
|
+
if (code === undefined || code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0
|
|
38
|
+
if ((code >= 0x300 && code <= 0x36f) || (code >= 0xfe00 && code <= 0xfe0f)) return 0
|
|
39
|
+
return code >= 0x1100 && (
|
|
40
|
+
code <= 0x115f || code === 0x2329 || code === 0x232a
|
|
41
|
+
|| (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f)
|
|
42
|
+
|| (code >= 0xac00 && code <= 0xd7a3)
|
|
43
|
+
|| (code >= 0xf900 && code <= 0xfaff)
|
|
44
|
+
|| (code >= 0xfe10 && code <= 0xfe19)
|
|
45
|
+
|| (code >= 0xfe30 && code <= 0xfe6f)
|
|
46
|
+
|| (code >= 0xff00 && code <= 0xff60)
|
|
47
|
+
|| (code >= 0xffe0 && code <= 0xffe6)
|
|
48
|
+
|| (code >= 0x1f300 && code <= 0x1faff)
|
|
49
|
+
) ? 2 : 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function displayWidth(value) {
|
|
53
|
+
return [...value].reduce((width, char) => width + cellWidth(char), 0)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clip(value, width) {
|
|
57
|
+
if (width <= 0) return ''
|
|
58
|
+
if (displayWidth(value) <= width) return value
|
|
59
|
+
if (width === 1) return '…'
|
|
60
|
+
let result = ''
|
|
61
|
+
let used = 0
|
|
62
|
+
for (const char of value) {
|
|
63
|
+
const next = cellWidth(char)
|
|
64
|
+
if (used + next > width - 1) break
|
|
65
|
+
result += char
|
|
66
|
+
used += next
|
|
67
|
+
}
|
|
68
|
+
return `${result}…`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function compactPath(value, options) {
|
|
72
|
+
if (!path.isAbsolute(value)) return value
|
|
73
|
+
const roots = [
|
|
74
|
+
[options.cwd, '.'],
|
|
75
|
+
[options.home ?? homedir(), '~'],
|
|
76
|
+
]
|
|
77
|
+
for (const [root, prefix] of roots) {
|
|
78
|
+
if (typeof root !== 'string' || root.length === 0) continue
|
|
79
|
+
const relative = path.relative(root, value)
|
|
80
|
+
if (relative === '') return prefix
|
|
81
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
82
|
+
return `${prefix}/${relative}`
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function argumentText(args, options) {
|
|
89
|
+
return args.map((part) => {
|
|
90
|
+
const display = compactPath(part, options)
|
|
91
|
+
return /\s/.test(display) ? JSON.stringify(display) : display
|
|
92
|
+
}).join(' ')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sourceLabel(source) {
|
|
96
|
+
if (source === 'configured') return 'Settings'
|
|
97
|
+
if (source === 'managed') return 'Martty bin'
|
|
98
|
+
if (source === 'forced') return 'Forced'
|
|
99
|
+
if (source === 'builtin') return 'Bundled'
|
|
100
|
+
if (source === 'path') return 'PATH'
|
|
101
|
+
if (source === 'registry') return 'ACP Registry'
|
|
102
|
+
return source
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fieldLine(name, value, columns, color, indent = 4) {
|
|
106
|
+
const prefix = `${' '.repeat(indent)}${name.padEnd(Math.max(9, name.length + 1))}`
|
|
107
|
+
return `${paint(prefix, 'dim', color)}${clip(value, columns - displayWidth(prefix))}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function harnessHelp(color = false) {
|
|
111
|
+
const section = (value) => paint(value, 'bold', color)
|
|
112
|
+
const command = (value) => paint(value, 'cyan', color)
|
|
113
|
+
return `${section('Martty Harnesses')}
|
|
114
|
+
|
|
115
|
+
Manage the ACP Harness used by standalone Martty.
|
|
116
|
+
|
|
117
|
+
${section('Usage')}
|
|
118
|
+
${command('martty harness <command> [options]')}
|
|
119
|
+
|
|
120
|
+
${section('Commands')}
|
|
121
|
+
list Show saved, bundled, and discovered Harnesses
|
|
122
|
+
find [query] Find ACP Harnesses in ACP Registry and local PATH
|
|
123
|
+
add <id> Configure a registry Harness or save a command
|
|
124
|
+
use <id> Set the default Harness for the next standalone launch
|
|
125
|
+
remove <id> Remove saved configuration (asks for confirmation)
|
|
126
|
+
help Show this help
|
|
127
|
+
|
|
128
|
+
${section('Add options')}
|
|
129
|
+
--command <cmd> Manual ACP command (optional for registry IDs)
|
|
130
|
+
--label <label> Human-readable name
|
|
131
|
+
--arg <arg> Command argument; repeat as needed
|
|
132
|
+
|
|
133
|
+
${section('Examples')}
|
|
134
|
+
${command('martty harness list')}
|
|
135
|
+
${command('martty harness find')}
|
|
136
|
+
${command('martty harness add local --label "Local ACP" --command local-acp --arg --stdio')}
|
|
137
|
+
${command('martty harness use local')}
|
|
138
|
+
${command('martty harness find --refresh')}
|
|
139
|
+
${command('martty harness remove local --dry-run')}
|
|
140
|
+
${command('/harness add local --command local-acp --arg --stdio')}
|
|
141
|
+
|
|
142
|
+
Run find without a query to browse every official Registry entry. A query is
|
|
143
|
+
only an optional filter after you already know what you are looking for.
|
|
144
|
+
|
|
145
|
+
Find reads the cached or bundled official Registry immediately. Use --refresh
|
|
146
|
+
to fetch its latest catalog; offline refresh retains the local catalog.
|
|
147
|
+
Add reuses saved recipes/local installations and saves configuration only.
|
|
148
|
+
Use saves defaultHarness for the next standalone launch; it does not start an agent.
|
|
149
|
+
That launch starts a new ACP session.
|
|
150
|
+
|
|
151
|
+
Remove keeps installed files by default. --cleanup also deletes only an exclusive
|
|
152
|
+
Martty-owned binary installation; global programs, shared caches, history and
|
|
153
|
+
credentials are kept. --dry-run previews exact paths. --yes confirms without a
|
|
154
|
+
terminal prompt. Stop other Martty instances using the target before cleanup.
|
|
155
|
+
`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function addHarnessHelp(color = false) {
|
|
159
|
+
const section = (value) => paint(value, 'bold', color)
|
|
160
|
+
const command = (value) => paint(value, 'cyan', color)
|
|
161
|
+
return `${section('Add a Harness')}
|
|
162
|
+
|
|
163
|
+
Martty connects to ACP servers, not directly to agent CLIs.
|
|
164
|
+
|
|
165
|
+
${section('Find an ACP Harness')}
|
|
166
|
+
${command('martty harness find')}
|
|
167
|
+
Reads the cached/bundled ACP Registry, then adds executable *-acp and *_acp
|
|
168
|
+
commands found on PATH. Package entries run through npx/uvx. Binary entries
|
|
169
|
+
are installed into Martty's private bin directory after you choose them.
|
|
170
|
+
|
|
171
|
+
${section('Custom ACP command')}
|
|
172
|
+
${command('martty harness add <id> --command <cmd> [options]')}
|
|
173
|
+
--command is only needed for an id outside the ACP Registry or when its
|
|
174
|
+
declared distribution does not apply to this machine.
|
|
175
|
+
|
|
176
|
+
${section('Options')}
|
|
177
|
+
--command <cmd> ACP server command to launch (manual fallback)
|
|
178
|
+
--label <label> Human-readable name
|
|
179
|
+
--arg <arg> Command argument; repeat as needed
|
|
180
|
+
|
|
181
|
+
${section('Example')}
|
|
182
|
+
${command('martty harness add local --label "Local ACP" --command local-acp --arg --stdio')}
|
|
183
|
+
`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
class HarnessUsageError extends Error {
|
|
187
|
+
constructor(message) {
|
|
188
|
+
super(message)
|
|
189
|
+
this.name = 'HarnessUsageError'
|
|
190
|
+
this.exitCode = 2
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function savedHarnessOutput(harness, color = false) {
|
|
195
|
+
const title = paint(`Saved ${harness.label}`, 'bold', color)
|
|
196
|
+
const lines = [title, '']
|
|
197
|
+
lines.push(fieldLine('ID', harness.id, 100, color, 2))
|
|
198
|
+
lines.push(fieldLine('Command', harness.command, 100, color, 2))
|
|
199
|
+
if (harness.args.length > 0) {
|
|
200
|
+
lines.push(fieldLine('Args', argumentText(harness.args, {}), 100, color, 2))
|
|
201
|
+
}
|
|
202
|
+
lines.push('')
|
|
203
|
+
lines.push(fieldLine('Next', `martty harness use ${harness.id}`, 100, color, 2))
|
|
204
|
+
lines.push(fieldLine('Then', 'restart martty (starts a new ACP session)', 100, color, 2))
|
|
205
|
+
return `${lines.join('\n')}\n`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatHarnessList(entries, defaultId, options = {}) {
|
|
209
|
+
const columns = Number.isInteger(options.columns) && options.columns > 0
|
|
210
|
+
? options.columns
|
|
211
|
+
: 100
|
|
212
|
+
const color = options.color === true
|
|
213
|
+
if (entries.length === 0) {
|
|
214
|
+
return `${paint('Martty Harnesses', 'bold', color)}
|
|
215
|
+
|
|
216
|
+
No Harnesses found.
|
|
217
|
+
|
|
218
|
+
${paint('Add one', 'dim', color)} martty harness add <id> --command <cmd>
|
|
219
|
+
${paint('Discover', 'dim', color)} install an executable named *-acp or *_acp
|
|
220
|
+
`
|
|
221
|
+
}
|
|
14
222
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
223
|
+
const lines = [paint(`Harnesses (${entries.length})`, 'bold', color), '']
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
const selected = entry.id === defaultId
|
|
226
|
+
const marker = paint(selected ? '●' : '○', selected ? 'green' : 'dim', color)
|
|
227
|
+
const prefix = ' ○ '
|
|
228
|
+
const label = clip(entry.label, columns - displayWidth(prefix))
|
|
229
|
+
lines.push(` ${marker} ${selected ? paint(label, 'bold', color) : label}`)
|
|
230
|
+
lines.push(fieldLine('ID', entry.id, columns, color))
|
|
231
|
+
lines.push(fieldLine('Source', sourceLabel(entry.source), columns, color))
|
|
232
|
+
lines.push(fieldLine('Command', compactPath(entry.command, options), columns, color))
|
|
233
|
+
if (entry.args.length > 0) {
|
|
234
|
+
lines.push(fieldLine('Args', argumentText(entry.args, options), columns, color))
|
|
235
|
+
}
|
|
236
|
+
lines.push('')
|
|
237
|
+
}
|
|
238
|
+
const defaultLabel = defaultId ?? 'none (bundled fallback on next launch)'
|
|
239
|
+
lines.push(fieldLine('Default', defaultLabel, columns, color, 2))
|
|
240
|
+
lines.push(fieldLine('Set', 'martty harness use <id>', columns, color, 2))
|
|
241
|
+
lines.push(fieldLine('In TUI', '/harness', columns, color, 2))
|
|
242
|
+
return `${lines.join('\n')}\n`
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function formatHarnessFind(entries, query, options = {}) {
|
|
246
|
+
const columns = Number.isInteger(options.columns) && options.columns > 0
|
|
247
|
+
? options.columns
|
|
248
|
+
: 100
|
|
249
|
+
const color = options.color === true
|
|
250
|
+
if (entries.length === 0) {
|
|
251
|
+
return `${paint('ACP Harness candidates', 'bold', color)}
|
|
19
252
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
253
|
+
No ACP Harnesses found in the ACP Registry, local PATH, or settings.
|
|
254
|
+
|
|
255
|
+
${paint('Add one', 'dim', color)} martty harness add <id> --command <cmd>
|
|
23
256
|
`
|
|
257
|
+
}
|
|
258
|
+
const lines = [paint(`ACP Harness candidates (${entries.length})`, 'bold', color)]
|
|
259
|
+
if (query.length > 0) lines.push(`Query ${query}`)
|
|
260
|
+
lines.push('')
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
lines.push(` ○ ${clip(entry.label, columns - 6)}`)
|
|
263
|
+
lines.push(fieldLine('ID', entry.id, columns, color))
|
|
264
|
+
lines.push(fieldLine('Source', sourceLabel(entry.source), columns, color))
|
|
265
|
+
lines.push(fieldLine('Status', entry.status ?? 'Found locally', columns, color))
|
|
266
|
+
// Keep discovered executable paths copyable; a missing registry command
|
|
267
|
+
// remains the declared binary name so the user knows what installation
|
|
268
|
+
// will provide.
|
|
269
|
+
lines.push(`${' '.repeat(4)}Command ${entry.resolvedCommand ?? entry.command}`)
|
|
270
|
+
if (entry.args.length > 0) lines.push(`${' '.repeat(4)}Args ${argumentText(entry.args, options)}`)
|
|
271
|
+
if (entry.distribution?.type !== undefined) {
|
|
272
|
+
lines.push(fieldLine('Distribution', entry.distribution.type, columns, color))
|
|
273
|
+
}
|
|
274
|
+
if (entry.status === 'Available to install') {
|
|
275
|
+
lines.push(fieldLine('Install', `martty harness add ${entry.id}`, columns, color))
|
|
276
|
+
if (entry.installPath !== undefined) {
|
|
277
|
+
lines.push(fieldLine('Location', compactPath(entry.installPath, options), columns, color))
|
|
278
|
+
}
|
|
279
|
+
lines.push(fieldLine('After', 'choose it from /harness (starts a new session)', columns, color))
|
|
280
|
+
} else if (entry.status === 'Needs npx' || entry.status === 'Needs uvx') {
|
|
281
|
+
const runtime = entry.distribution.type === 'npx'
|
|
282
|
+
? 'Node.js/npm (provides npx)'
|
|
283
|
+
: 'uv (provides uvx)'
|
|
284
|
+
lines.push(fieldLine('Setup', `Install ${runtime}, then run martty harness add ${entry.id}`, columns, color))
|
|
285
|
+
} else if (entry.status === 'Not installed') {
|
|
286
|
+
lines.push(fieldLine('Install', formatCommand(entry.install, options), columns, color))
|
|
287
|
+
if (entry.fallback !== undefined) {
|
|
288
|
+
lines.push(fieldLine('Config', `martty harness add ${entry.id}`, columns, color))
|
|
289
|
+
} else {
|
|
290
|
+
lines.push(fieldLine('Verify', `command -v ${entry.command}`, columns, color))
|
|
291
|
+
}
|
|
292
|
+
lines.push(fieldLine('After', `martty harness find ${entry.id}`, columns, color))
|
|
293
|
+
lines.push(fieldLine('Manual', `martty harness add ${entry.id} --command <cmd>`, columns, color))
|
|
294
|
+
} else if (entry.registry === true && entry.source !== 'configured') {
|
|
295
|
+
lines.push(fieldLine('Add', `martty harness add ${entry.id}`, columns, color))
|
|
296
|
+
} else {
|
|
297
|
+
lines.push(fieldLine('Use', `martty harness use ${entry.id}`, columns, color))
|
|
298
|
+
}
|
|
299
|
+
lines.push('')
|
|
300
|
+
}
|
|
301
|
+
lines.push(fieldLine('Manual', 'martty harness add <id> --command <cmd>', columns, color, 2))
|
|
302
|
+
return `${lines.join('\n')}\n`
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatCommand(entry, options = {}) {
|
|
306
|
+
if (entry === undefined || entry === null) return ''
|
|
307
|
+
return argumentText([entry.command, ...(entry.args ?? [])], options)
|
|
308
|
+
}
|
|
24
309
|
|
|
25
310
|
function readSettings(settingsPath) {
|
|
26
311
|
if (!existsSync(settingsPath)) return {}
|
|
@@ -71,13 +356,22 @@ function validateHarness(value) {
|
|
|
71
356
|
if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
|
|
72
357
|
throw new Error('harness args must be an array of strings')
|
|
73
358
|
}
|
|
359
|
+
const env = value.env === undefined ? undefined : value.env
|
|
360
|
+
if (env !== undefined && (env === null || typeof env !== 'object' || Array.isArray(env)
|
|
361
|
+
|| Object.entries(env).some(([key, item]) => key.length === 0 || typeof item !== 'string'))) {
|
|
362
|
+
throw new Error('harness env must be an object of strings')
|
|
363
|
+
}
|
|
364
|
+
const normalizedArgs = /^npx(?:\.(?:cmd|bat|exe))?$/i.test(path.win32.basename(value.command))
|
|
365
|
+
? [...args].filter((arg) => arg !== '--yes' && arg !== '--prefer-offline')
|
|
366
|
+
: [...args]
|
|
74
367
|
return {
|
|
75
368
|
id: value.id,
|
|
76
369
|
label: typeof value.label === 'string' && value.label.trim().length > 0
|
|
77
370
|
? value.label
|
|
78
371
|
: value.id,
|
|
79
372
|
command: value.command,
|
|
80
|
-
args:
|
|
373
|
+
args: normalizedArgs,
|
|
374
|
+
...(env !== undefined ? { env: { ...env } } : {}),
|
|
81
375
|
}
|
|
82
376
|
}
|
|
83
377
|
|
|
@@ -86,6 +380,24 @@ function configuredHarnesses(settings) {
|
|
|
86
380
|
return settings.harnesses.map(validateHarness)
|
|
87
381
|
}
|
|
88
382
|
|
|
383
|
+
function withoutLegacyActive(settings) {
|
|
384
|
+
const { activeHarness: _legacyActive, ...current } = settings
|
|
385
|
+
if (current.defaultHarness === undefined && typeof _legacyActive === 'string') {
|
|
386
|
+
current.defaultHarness = _legacyActive
|
|
387
|
+
}
|
|
388
|
+
return current
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function persistedDefaultId(settings) {
|
|
392
|
+
if (typeof settings.defaultHarness === 'string') return settings.defaultHarness
|
|
393
|
+
// Read legacy settings during the migration window, but never write the
|
|
394
|
+
// old key back. An explicit null/empty default means the bundled fallback.
|
|
395
|
+
if (settings.defaultHarness === undefined && typeof settings.activeHarness === 'string') {
|
|
396
|
+
return settings.activeHarness
|
|
397
|
+
}
|
|
398
|
+
return undefined
|
|
399
|
+
}
|
|
400
|
+
|
|
89
401
|
export function upsertHarness(settingsPath, harness) {
|
|
90
402
|
const next = validateHarness(harness)
|
|
91
403
|
const settings = readSettings(settingsPath)
|
|
@@ -93,23 +405,554 @@ export function upsertHarness(settingsPath, harness) {
|
|
|
93
405
|
const index = harnesses.findIndex(({ id }) => id === next.id)
|
|
94
406
|
if (index === -1) harnesses.push(next)
|
|
95
407
|
else harnesses[index] = next
|
|
96
|
-
writeSettings(settingsPath, { ...settings, harnesses })
|
|
408
|
+
writeSettings(settingsPath, { ...withoutLegacyActive(settings), harnesses })
|
|
97
409
|
return next
|
|
98
410
|
}
|
|
99
411
|
|
|
100
|
-
export function
|
|
412
|
+
export function setDefaultHarness(settingsPath, id) {
|
|
101
413
|
const settings = readSettings(settingsPath)
|
|
102
414
|
const harnesses = configuredHarnesses(settings)
|
|
103
415
|
if (!harnesses.some((harness) => harness.id === id)) {
|
|
104
416
|
throw new Error(`unknown harness ${JSON.stringify(id)}`)
|
|
105
417
|
}
|
|
106
|
-
writeSettings(settingsPath, { ...settings, harnesses,
|
|
418
|
+
writeSettings(settingsPath, { ...withoutLegacyActive(settings), harnesses, defaultHarness: id })
|
|
107
419
|
}
|
|
108
420
|
|
|
421
|
+
export function savedHarnesses(settingsPath) {
|
|
422
|
+
return configuredHarnesses(readSettings(settingsPath))
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function removeHarnessConfiguration(settingsPath, expected) {
|
|
426
|
+
const settings = withoutLegacyActive(readSettings(settingsPath))
|
|
427
|
+
const harnesses = configuredHarnesses(settings)
|
|
428
|
+
const current = harnesses.find(({ id }) => id === expected.id)
|
|
429
|
+
if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error('Harness configuration changed; review removal again')
|
|
430
|
+
settings.harnesses = harnesses.filter(({ id }) => id !== expected.id)
|
|
431
|
+
if (settings.defaultHarness === expected.id) delete settings.defaultHarness
|
|
432
|
+
writeSettings(settingsPath, settings)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Kept as a source-compatible alias for integrations compiled against 0.2.30.
|
|
436
|
+
// Persisted settings use only `defaultHarness`.
|
|
437
|
+
export const activateHarness = setDefaultHarness
|
|
438
|
+
|
|
109
439
|
export function selectedHarness(settingsPath) {
|
|
110
440
|
const settings = readSettings(settingsPath)
|
|
111
|
-
|
|
112
|
-
|
|
441
|
+
const id = persistedDefaultId(settings)
|
|
442
|
+
if (typeof id !== 'string') return undefined
|
|
443
|
+
return configuredHarnesses(settings).find((harness) => harness.id === id)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function executableFile(command) {
|
|
447
|
+
if (typeof command !== 'string' || command.trim().length === 0) return false
|
|
448
|
+
try {
|
|
449
|
+
const stat = statSync(command)
|
|
450
|
+
return stat.isFile() && (process.platform === 'win32' || (stat.mode & 0o111) !== 0)
|
|
451
|
+
} catch {
|
|
452
|
+
return false
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function commandNames(command, options = {}) {
|
|
457
|
+
const platform = options.platform ?? process.platform
|
|
458
|
+
if (platform !== 'win32' || path.extname(command).length > 0) return [command]
|
|
459
|
+
const pathExt = options.pathExt ?? process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD'
|
|
460
|
+
const extensions = String(pathExt).split(';')
|
|
461
|
+
.map((extension) => extension.trim())
|
|
462
|
+
.filter(Boolean)
|
|
463
|
+
.map((extension) => extension.startsWith('.') ? extension : `.${extension}`)
|
|
464
|
+
return [command, ...extensions.map((extension) => `${command}${extension}`)]
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function resolvePathCommand(command, pathValue = process.env.PATH ?? '', options = {}) {
|
|
468
|
+
if (typeof command !== 'string' || command.trim().length === 0) return undefined
|
|
469
|
+
const names = commandNames(command, options)
|
|
470
|
+
if (path.isAbsolute(command) || command.includes(path.sep)) {
|
|
471
|
+
return names.map((name) => path.resolve(name)).find(executableFile)
|
|
472
|
+
}
|
|
473
|
+
for (const directory of String(pathValue ?? '').split(path.delimiter).filter(Boolean)) {
|
|
474
|
+
const resolved = names.map((name) => path.resolve(directory, name)).find(executableFile)
|
|
475
|
+
if (resolved !== undefined) return resolved
|
|
476
|
+
}
|
|
477
|
+
return undefined
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function normalizeRegistryCommand(value) {
|
|
481
|
+
if (typeof value === 'string') return { command: value, args: [] }
|
|
482
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
483
|
+
if (typeof value.command !== 'string' || value.command.trim().length === 0) return undefined
|
|
484
|
+
const args = value.args === undefined ? [] : value.args
|
|
485
|
+
if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) return undefined
|
|
486
|
+
const env = value.env === undefined ? {} : value.env
|
|
487
|
+
if (env === null || typeof env !== 'object' || Array.isArray(env)
|
|
488
|
+
|| Object.entries(env).some(([key, item]) => key.length === 0 || typeof item !== 'string')) return undefined
|
|
489
|
+
return { command: value.command, args: [...args], env: { ...env } }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function registryRecords(options = {}) {
|
|
493
|
+
const rawRegistry = options.registry === undefined ? HARNESS_REGISTRY : options.registry
|
|
494
|
+
const records = rawRegistry !== null && typeof rawRegistry === 'object' && !Array.isArray(rawRegistry)
|
|
495
|
+
? normalizeAcpRegistry(rawRegistry, options)
|
|
496
|
+
: rawRegistry
|
|
497
|
+
if (!Array.isArray(records)) return []
|
|
498
|
+
return records.flatMap((record) => {
|
|
499
|
+
if (record === null || typeof record !== 'object' || Array.isArray(record)) return []
|
|
500
|
+
if (typeof record.id !== 'string' || !HARNESS_ID.test(record.id)) return []
|
|
501
|
+
if (Array.isArray(record.distributions)) {
|
|
502
|
+
const distributions = record.distributions.filter((distribution) => (
|
|
503
|
+
distribution !== null
|
|
504
|
+
&& typeof distribution === 'object'
|
|
505
|
+
&& ['binary', 'npx', 'uvx'].includes(distribution.type)
|
|
506
|
+
&& typeof distribution.command === 'string'
|
|
507
|
+
&& Array.isArray(distribution.args)
|
|
508
|
+
)).map((distribution) => ({
|
|
509
|
+
...distribution,
|
|
510
|
+
args: distribution.args.filter((arg) => typeof arg === 'string'),
|
|
511
|
+
env: distribution.env && typeof distribution.env === 'object' && !Array.isArray(distribution.env)
|
|
512
|
+
? Object.fromEntries(Object.entries(distribution.env).filter(([, item]) => typeof item === 'string'))
|
|
513
|
+
: {},
|
|
514
|
+
}))
|
|
515
|
+
if (distributions.length === 0) return []
|
|
516
|
+
return [{
|
|
517
|
+
id: record.id,
|
|
518
|
+
label: typeof record.label === 'string' && record.label.trim().length > 0
|
|
519
|
+
? record.label
|
|
520
|
+
: record.id,
|
|
521
|
+
version: typeof record.version === 'string' ? record.version : 'unversioned',
|
|
522
|
+
description: typeof record.description === 'string' ? record.description : '',
|
|
523
|
+
distributions,
|
|
524
|
+
}]
|
|
525
|
+
}
|
|
526
|
+
const rawCommands = record.commands ?? (record.command === undefined ? [] : [
|
|
527
|
+
{ command: record.command, args: record.args },
|
|
528
|
+
])
|
|
529
|
+
if (!Array.isArray(rawCommands)) return []
|
|
530
|
+
const commands = rawCommands.map(normalizeRegistryCommand).filter(Boolean)
|
|
531
|
+
if (commands.length === 0) return []
|
|
532
|
+
const install = normalizeRegistryCommand(record.install ?? record.download)
|
|
533
|
+
const fallback = normalizeRegistryCommand(record.fallback)
|
|
534
|
+
?? (install?.command === 'npx' ? install : undefined)
|
|
535
|
+
return [{
|
|
536
|
+
id: record.id,
|
|
537
|
+
label: typeof record.label === 'string' && record.label.trim().length > 0
|
|
538
|
+
? record.label
|
|
539
|
+
: record.id,
|
|
540
|
+
commands,
|
|
541
|
+
install,
|
|
542
|
+
fallback,
|
|
543
|
+
}]
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function registryPackageCandidate(record, distribution, options, localPackages) {
|
|
548
|
+
const runner = resolvePathCommand(distribution.command, options.pathValue, options)
|
|
549
|
+
const local = distribution.type === 'npx' ? localNpmExecutable(distribution, options)
|
|
550
|
+
: localPythonExecutable(distribution, options, localPackages)
|
|
551
|
+
return {
|
|
552
|
+
id: record.id,
|
|
553
|
+
label: record.label,
|
|
554
|
+
version: record.version,
|
|
555
|
+
description: record.description,
|
|
556
|
+
command: local?.command ?? runner ?? distribution.command,
|
|
557
|
+
...(local !== undefined ? { resolvedCommand: local.command, installedVersion: local.version } : {}),
|
|
558
|
+
args: local === undefined ? [...distribution.args] : distribution.args.slice(1),
|
|
559
|
+
env: { ...distribution.env },
|
|
560
|
+
source: local === undefined ? 'registry' : 'path',
|
|
561
|
+
status: local !== undefined ? 'Found locally'
|
|
562
|
+
: runner === undefined ? `Needs ${distribution.type}` : `Available via ${distribution.type}`,
|
|
563
|
+
registry: true,
|
|
564
|
+
distribution,
|
|
565
|
+
registryRecord: record,
|
|
566
|
+
runner,
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Resolve an installed npm package's declared bin, never a guessed CLI name. */
|
|
571
|
+
function localNpmExecutable(distribution, options) {
|
|
572
|
+
const spec = distribution.args[0]
|
|
573
|
+
if (typeof spec !== 'string') return undefined
|
|
574
|
+
const packageName = /^(@[a-z0-9._-]+\/[a-z0-9._-]+|[a-z0-9._-]+)(?:@[^\s/]+)?$/i.exec(spec)?.[1]
|
|
575
|
+
if (packageName === undefined || packageName.split('/').some((part) => part === '.' || part === '..')) {
|
|
576
|
+
return undefined
|
|
577
|
+
}
|
|
578
|
+
const directories = String(options.pathValue ?? process.env.PATH ?? '').split(path.delimiter).filter(Boolean)
|
|
579
|
+
for (const directory of directories) {
|
|
580
|
+
// npm global prefixes use lib/node_modules on Unix and node_modules on
|
|
581
|
+
// Windows. A project may explicitly put node_modules/.bin on its PATH.
|
|
582
|
+
const roots = [
|
|
583
|
+
path.resolve(directory, '..', 'lib', 'node_modules', packageName),
|
|
584
|
+
path.resolve(directory, 'node_modules', packageName),
|
|
585
|
+
...(path.basename(directory) === '.bin' ? [path.resolve(directory, '..', packageName)] : []),
|
|
586
|
+
]
|
|
587
|
+
for (const root of roots) {
|
|
588
|
+
try {
|
|
589
|
+
const metadata = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
590
|
+
if (metadata.name !== packageName) continue
|
|
591
|
+
const binValues = typeof metadata.bin === 'string' ? [metadata.bin]
|
|
592
|
+
: metadata.bin !== null && typeof metadata.bin === 'object' && !Array.isArray(metadata.bin)
|
|
593
|
+
? Object.values(metadata.bin) : []
|
|
594
|
+
const targets = [...new Set(binValues)]
|
|
595
|
+
// Multiple different entrypoints require an explicit command. Picking
|
|
596
|
+
// one could turn a package's management CLI into an ACP launch recipe.
|
|
597
|
+
if (targets.length !== 1 || typeof targets[0] !== 'string') continue
|
|
598
|
+
const packageRoot = realpathSync(root)
|
|
599
|
+
const target = realpathSync(path.resolve(root, targets[0]))
|
|
600
|
+
const relative = path.relative(packageRoot, target)
|
|
601
|
+
if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) continue
|
|
602
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
603
|
+
if (!entry.isSymbolicLink()) continue
|
|
604
|
+
const command = path.resolve(directory, entry.name)
|
|
605
|
+
if (executableFile(command) && realpathSync(command) === target) {
|
|
606
|
+
return { command, version: typeof metadata.version === 'string' ? metadata.version : undefined }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
} catch {
|
|
610
|
+
// Missing, malformed, or stale package metadata is not proof that a
|
|
611
|
+
// same-named executable belongs to this Registry package.
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return undefined
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function readDirectory(directory) {
|
|
619
|
+
try { return readdirSync(directory, { withFileTypes: true }) } catch { return [] }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function pythonPackageName(value) {
|
|
623
|
+
return value.toLowerCase().replace(/[-_.]+/g, '-')
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Inspect installed tool environments reached from PATH; never run uv/pip. */
|
|
627
|
+
function localPythonExecutable(distribution, options, cache) {
|
|
628
|
+
const spec = distribution.args[0]
|
|
629
|
+
const name = typeof spec === 'string'
|
|
630
|
+
? /^([a-z0-9][a-z0-9._-]*)(?:@[^\s/]+|==[^\s/]+)?$/i.exec(spec)?.[1] : undefined
|
|
631
|
+
if (name === undefined) return undefined
|
|
632
|
+
if (!cache.has('python')) {
|
|
633
|
+
const packages = new Map()
|
|
634
|
+
const environments = new Map()
|
|
635
|
+
const directories = String(options.pathValue ?? process.env.PATH ?? '').split(path.delimiter).filter(Boolean)
|
|
636
|
+
for (const directory of directories) {
|
|
637
|
+
for (const entry of readDirectory(directory)) {
|
|
638
|
+
if (!entry.isSymbolicLink() && !entry.isFile()) continue
|
|
639
|
+
const command = path.resolve(directory, entry.name)
|
|
640
|
+
try {
|
|
641
|
+
const target = realpathSync(command)
|
|
642
|
+
const targetDirectory = path.dirname(target)
|
|
643
|
+
if (!['bin', 'scripts'].includes(path.basename(targetDirectory).toLowerCase())) continue
|
|
644
|
+
const root = path.dirname(targetDirectory)
|
|
645
|
+
if (!existsSync(path.join(root, 'pyvenv.cfg')) || !executableFile(command)) continue
|
|
646
|
+
const commands = environments.get(root) ?? []
|
|
647
|
+
commands.push({ command, target })
|
|
648
|
+
environments.set(root, commands)
|
|
649
|
+
} catch { /* Broken PATH links do not prove an installed tool. */ }
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
for (const [root, commands] of environments) {
|
|
653
|
+
const sites = [path.join(root, 'Lib', 'site-packages')]
|
|
654
|
+
for (const entry of readDirectory(path.join(root, 'lib'))) {
|
|
655
|
+
if (entry.isDirectory() && /^python\d+\.\d+$/.test(entry.name)) {
|
|
656
|
+
sites.push(path.join(root, 'lib', entry.name, 'site-packages'))
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
for (const site of sites) {
|
|
660
|
+
for (const entry of readDirectory(site)) {
|
|
661
|
+
if (!entry.isDirectory() || !entry.name.endsWith('.dist-info')) continue
|
|
662
|
+
try {
|
|
663
|
+
const info = path.join(site, entry.name)
|
|
664
|
+
const metadata = readFileSync(path.join(info, 'METADATA'), 'utf8')
|
|
665
|
+
const packageName = /^Name:[ \t]*(\S+)[ \t]*$/mi.exec(metadata)?.[1]
|
|
666
|
+
if (packageName === undefined) continue
|
|
667
|
+
const scripts = []
|
|
668
|
+
let section
|
|
669
|
+
for (const line of readFileSync(path.join(info, 'entry_points.txt'), 'utf8').split(/\r?\n/)) {
|
|
670
|
+
const heading = /^\s*\[([^\]]+)\]\s*$/.exec(line)
|
|
671
|
+
if (heading) section = heading[1]
|
|
672
|
+
else if (section === 'console_scripts') {
|
|
673
|
+
const script = /^\s*([^\s=]+)\s*=\s*\S+/.exec(line)?.[1]
|
|
674
|
+
if (script !== undefined) scripts.push(script)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (scripts.length !== 1) continue
|
|
678
|
+
const local = commands.find(({ target }) => (
|
|
679
|
+
path.basename(target).replace(/\.exe$/i, '') === scripts[0]
|
|
680
|
+
))
|
|
681
|
+
const key = pythonPackageName(packageName)
|
|
682
|
+
if (local !== undefined && !packages.has(key)) {
|
|
683
|
+
packages.set(key, {
|
|
684
|
+
command: local.command,
|
|
685
|
+
version: /^Version:[ \t]*(\S+)[ \t]*$/mi.exec(metadata)?.[1],
|
|
686
|
+
})
|
|
687
|
+
}
|
|
688
|
+
} catch { /* Incomplete or stale distribution metadata: use the recipe. */ }
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
cache.set('python', packages)
|
|
693
|
+
}
|
|
694
|
+
return cache.get('python').get(pythonPackageName(name))
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function registryCandidates(options = {}, records = registryRecords(options)) {
|
|
698
|
+
const localPackages = new Map()
|
|
699
|
+
return records.map((record) => {
|
|
700
|
+
if (Array.isArray(record.distributions)) {
|
|
701
|
+
const binary = record.distributions.find((distribution) => distribution.type === 'binary')
|
|
702
|
+
if (binary !== undefined) {
|
|
703
|
+
let managed
|
|
704
|
+
try {
|
|
705
|
+
const command = managedBinaryPath({ ...record, distribution: binary }, options)
|
|
706
|
+
if (command !== undefined && executableFile(command)) managed = command
|
|
707
|
+
} catch {
|
|
708
|
+
// Invalid registry paths are left as unavailable rather than escaping
|
|
709
|
+
// the managed install root.
|
|
710
|
+
}
|
|
711
|
+
if (managed !== undefined) {
|
|
712
|
+
return {
|
|
713
|
+
id: record.id,
|
|
714
|
+
label: record.label,
|
|
715
|
+
version: record.version,
|
|
716
|
+
description: record.description,
|
|
717
|
+
command: managed,
|
|
718
|
+
resolvedCommand: managed,
|
|
719
|
+
args: [...binary.args],
|
|
720
|
+
env: { ...binary.env },
|
|
721
|
+
source: 'managed',
|
|
722
|
+
status: 'Installed',
|
|
723
|
+
registry: true,
|
|
724
|
+
distribution: binary,
|
|
725
|
+
registryRecord: record,
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const binaryName = path.basename(binary.command.replaceAll('\\', '/'))
|
|
729
|
+
const local = resolvePathCommand(binaryName, options.pathValue, options)
|
|
730
|
+
if (local !== undefined) {
|
|
731
|
+
return {
|
|
732
|
+
id: record.id,
|
|
733
|
+
label: record.label,
|
|
734
|
+
version: record.version,
|
|
735
|
+
description: record.description,
|
|
736
|
+
command: binaryName,
|
|
737
|
+
resolvedCommand: local,
|
|
738
|
+
args: [...binary.args],
|
|
739
|
+
env: { ...binary.env },
|
|
740
|
+
source: 'path',
|
|
741
|
+
status: 'Found locally',
|
|
742
|
+
registry: true,
|
|
743
|
+
distribution: binary,
|
|
744
|
+
registryRecord: record,
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const packageDistributions = record.distributions.filter((distribution) => (
|
|
749
|
+
distribution.type === 'npx' || distribution.type === 'uvx'
|
|
750
|
+
))
|
|
751
|
+
const packageCandidates = packageDistributions
|
|
752
|
+
.map((distribution) => registryPackageCandidate(record, distribution, options, localPackages))
|
|
753
|
+
const localPackage = packageCandidates.find(({ status }) => status === 'Found locally')
|
|
754
|
+
if (localPackage !== undefined) return localPackage
|
|
755
|
+
const availablePackage = packageCandidates.find(({ status }) => status.startsWith('Available via '))
|
|
756
|
+
if (availablePackage !== undefined) {
|
|
757
|
+
return availablePackage
|
|
758
|
+
}
|
|
759
|
+
if (binary !== undefined) {
|
|
760
|
+
return {
|
|
761
|
+
id: record.id,
|
|
762
|
+
label: record.label,
|
|
763
|
+
version: record.version,
|
|
764
|
+
description: record.description,
|
|
765
|
+
command: binary.command,
|
|
766
|
+
args: [...binary.args],
|
|
767
|
+
env: { ...binary.env },
|
|
768
|
+
source: 'registry',
|
|
769
|
+
status: 'Available to install',
|
|
770
|
+
registry: true,
|
|
771
|
+
distribution: binary,
|
|
772
|
+
registryRecord: record,
|
|
773
|
+
installPath: (() => {
|
|
774
|
+
try {
|
|
775
|
+
const command = managedBinaryPath({ ...record, distribution: binary }, options)
|
|
776
|
+
return command === undefined ? undefined : path.dirname(command)
|
|
777
|
+
} catch { return undefined }
|
|
778
|
+
})(),
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (packageCandidates.length > 0) return packageCandidates[0]
|
|
782
|
+
return undefined
|
|
783
|
+
}
|
|
784
|
+
const local = record.commands
|
|
785
|
+
.map((spec) => ({
|
|
786
|
+
spec,
|
|
787
|
+
command: resolvePathCommand(spec.command, options.pathValue, options),
|
|
788
|
+
}))
|
|
789
|
+
.find(({ command }) => command !== undefined)
|
|
790
|
+
if (local !== undefined) {
|
|
791
|
+
return {
|
|
792
|
+
id: record.id,
|
|
793
|
+
label: record.label,
|
|
794
|
+
command: local.spec.command,
|
|
795
|
+
resolvedCommand: local.command,
|
|
796
|
+
args: [...local.spec.args],
|
|
797
|
+
source: 'path',
|
|
798
|
+
status: 'Found locally',
|
|
799
|
+
registry: true,
|
|
800
|
+
install: record.install,
|
|
801
|
+
fallback: record.fallback,
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const primary = record.commands[0]
|
|
805
|
+
return {
|
|
806
|
+
id: record.id,
|
|
807
|
+
label: record.label,
|
|
808
|
+
command: primary.command,
|
|
809
|
+
args: [...primary.args],
|
|
810
|
+
source: 'registry',
|
|
811
|
+
status: 'Not installed',
|
|
812
|
+
registry: true,
|
|
813
|
+
install: record.install,
|
|
814
|
+
fallback: record.fallback,
|
|
815
|
+
}
|
|
816
|
+
}).filter(Boolean)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function registryCandidate(id, options = {}) {
|
|
820
|
+
// Normalize the catalog, but probe local installations only for this id.
|
|
821
|
+
return registryCandidates(options, registryRecords(options).filter((record) => record.id === id))[0]
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function runnableRegistryCandidate(entry) {
|
|
825
|
+
return [
|
|
826
|
+
'Found locally', 'Installed', 'Available via npx', 'Available via uvx',
|
|
827
|
+
].includes(entry.status)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export function discoverRegistryHarnesses(options = {}) {
|
|
831
|
+
return registryCandidates(options).filter(runnableRegistryCandidate)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function finishHarnessConfiguration(settingsPath, harness, options) {
|
|
835
|
+
// Setup and runtime are independent. persist:false lets callers prepare a
|
|
836
|
+
// recipe before explicitly saving it; neither path initializes an ACP session.
|
|
837
|
+
return options.persist === false ? validateHarness(harness) : upsertHarness(settingsPath, harness)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export function addHarness(settingsPath, id, tokens = [], options = {}) {
|
|
841
|
+
return configureHarness(settingsPath, id, tokens, options, () => registryCandidate(id, options))
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function parseHarnessAdd(id, tokens) {
|
|
845
|
+
const color = false
|
|
846
|
+
if (id === undefined || ['help', '-h', '--help'].includes(id)) {
|
|
847
|
+
throw new HarnessUsageError(addHarnessHelp(color))
|
|
848
|
+
}
|
|
849
|
+
if (!HARNESS_ID.test(id)) {
|
|
850
|
+
throw new HarnessUsageError(
|
|
851
|
+
`Invalid Harness id ${JSON.stringify(id)}. Use lowercase letters, numbers, and hyphens.`,
|
|
852
|
+
)
|
|
853
|
+
}
|
|
854
|
+
let command
|
|
855
|
+
let label
|
|
856
|
+
const args = []
|
|
857
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
858
|
+
const token = tokens[index]
|
|
859
|
+
const value = tokens[index + 1]
|
|
860
|
+
if (value === undefined) {
|
|
861
|
+
throw new HarnessUsageError(`${token} needs a value.\n\n${addHarnessHelp(color)}`)
|
|
862
|
+
}
|
|
863
|
+
if (token === '--command' && !value.trim()) throw new HarnessUsageError('--command needs a non-empty value')
|
|
864
|
+
if (token === '--command') command = value
|
|
865
|
+
else if (token === '--label') label = value
|
|
866
|
+
else if (token === '--arg') args.push(value)
|
|
867
|
+
else {
|
|
868
|
+
throw new HarnessUsageError(
|
|
869
|
+
`Unknown add option ${JSON.stringify(token)}.\n\n${addHarnessHelp(color)}`,
|
|
870
|
+
)
|
|
871
|
+
}
|
|
872
|
+
index += 1
|
|
873
|
+
}
|
|
874
|
+
return { command, label, args }
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function configureHarness(settingsPath, id, tokens, options, lookupCandidate) {
|
|
878
|
+
const { command, label, args } = parseHarnessAdd(id, tokens)
|
|
879
|
+
if (typeof command !== 'string' || command.trim().length === 0) {
|
|
880
|
+
const candidate = lookupCandidate()
|
|
881
|
+
if (candidate !== undefined && [
|
|
882
|
+
'Found locally', 'Installed', 'Available via npx', 'Available via uvx',
|
|
883
|
+
].includes(candidate.status)) {
|
|
884
|
+
return finishHarnessConfiguration(settingsPath, {
|
|
885
|
+
id: candidate.id,
|
|
886
|
+
label: label ?? candidate.label,
|
|
887
|
+
command: candidate.resolvedCommand ?? candidate.command,
|
|
888
|
+
args: args.length > 0 ? args : candidate.args,
|
|
889
|
+
...(candidate.env !== undefined ? { env: candidate.env } : {}),
|
|
890
|
+
}, options)
|
|
891
|
+
}
|
|
892
|
+
if (candidate?.status === 'Available to install') {
|
|
893
|
+
throw new HarnessUsageError(
|
|
894
|
+
`Harness ${JSON.stringify(id)} is a binary distribution.\n\n`
|
|
895
|
+
+ ` Install into Martty with martty harness add ${id}\n`
|
|
896
|
+
+ ` Location ${candidate.installPath ?? "Martty's private bin directory"}\n`,
|
|
897
|
+
)
|
|
898
|
+
}
|
|
899
|
+
if (candidate?.status === 'Needs npx' || candidate?.status === 'Needs uvx') {
|
|
900
|
+
const runtime = candidate.distribution.type === 'npx' ? 'Node.js/npm' : 'uv'
|
|
901
|
+
throw new HarnessUsageError(
|
|
902
|
+
`Harness ${JSON.stringify(id)} needs ${candidate.distribution.type}.\n\n`
|
|
903
|
+
+ ` Install ${runtime} to provide ${candidate.distribution.type}, then run martty harness add ${id}\n`,
|
|
904
|
+
)
|
|
905
|
+
}
|
|
906
|
+
if (candidate?.status === 'Not installed' && candidate.fallback !== undefined) {
|
|
907
|
+
return finishHarnessConfiguration(settingsPath, {
|
|
908
|
+
id: candidate.id,
|
|
909
|
+
label: label ?? candidate.label,
|
|
910
|
+
command: candidate.fallback.command,
|
|
911
|
+
args: [
|
|
912
|
+
...candidate.fallback.args,
|
|
913
|
+
...args,
|
|
914
|
+
],
|
|
915
|
+
}, options)
|
|
916
|
+
}
|
|
917
|
+
if (candidate?.status === 'Not installed' && candidate.install !== undefined) {
|
|
918
|
+
throw new HarnessUsageError(
|
|
919
|
+
`Harness ${JSON.stringify(id)} is not installed locally.\n\n`
|
|
920
|
+
+ ` Install ${formatCommand(candidate.install)}\n`
|
|
921
|
+
+ ` After martty harness find ${id}\n\n`
|
|
922
|
+
+ ` Manual martty harness add ${id} --command <cmd>`,
|
|
923
|
+
)
|
|
924
|
+
}
|
|
925
|
+
throw new HarnessUsageError(
|
|
926
|
+
`Missing --command for custom Harness ${JSON.stringify(id)}.\n\n`
|
|
927
|
+
+ ` martty harness add ${id} --command <cmd>\n\n`
|
|
928
|
+
+ 'The command must start an ACP-compatible server on stdin/stdout.',
|
|
929
|
+
)
|
|
930
|
+
}
|
|
931
|
+
return finishHarnessConfiguration(settingsPath, { id, label, command, args }, options)
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** Configure a registry entry; persist:false prepares it without writing settings. */
|
|
935
|
+
export async function addHarnessAsync(settingsPath, id, tokens = [], options = {}) {
|
|
936
|
+
parseHarnessAdd(id, tokens)
|
|
937
|
+
if (tokens.includes('--command')) return addHarness(settingsPath, id, tokens, options)
|
|
938
|
+
const candidate = registryCandidate(id, options)
|
|
939
|
+
if (candidate?.status !== 'Available to install') {
|
|
940
|
+
return configureHarness(settingsPath, id, tokens, options, () => candidate)
|
|
941
|
+
}
|
|
942
|
+
// Validate every option and preserve overrides before any download can start.
|
|
943
|
+
const prepared = configureHarness(settingsPath, id, tokens, { ...options, persist: false },
|
|
944
|
+
() => ({ ...candidate, status: 'Installed' }))
|
|
945
|
+
const installed = await installRegistryBinary({
|
|
946
|
+
id: candidate.id,
|
|
947
|
+
label: candidate.label,
|
|
948
|
+
version: candidate.version,
|
|
949
|
+
distribution: candidate.distribution,
|
|
950
|
+
}, options)
|
|
951
|
+
return finishHarnessConfiguration(settingsPath, {
|
|
952
|
+
...installed,
|
|
953
|
+
label: prepared.label,
|
|
954
|
+
args: prepared.args,
|
|
955
|
+
}, options)
|
|
113
956
|
}
|
|
114
957
|
|
|
115
958
|
export function discoverPathHarnesses(pathValue = process.env.PATH ?? '') {
|
|
@@ -148,6 +991,10 @@ export function discoverPathHarnesses(pathValue = process.env.PATH ?? '') {
|
|
|
148
991
|
}
|
|
149
992
|
|
|
150
993
|
export function discoverHarnesses(settingsPath, options = {}) {
|
|
994
|
+
return discoverHarnessEntries(settingsPath, options, registryCandidates(options))
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function discoverHarnessEntries(settingsPath, options, registryEntries) {
|
|
151
998
|
const configured = configuredHarnesses(readSettings(settingsPath))
|
|
152
999
|
.map((harness) => ({ ...harness, source: 'configured' }))
|
|
153
1000
|
const defaults = (options.defaults ?? []).map((entry) => ({
|
|
@@ -155,59 +1002,224 @@ export function discoverHarnesses(settingsPath, options = {}) {
|
|
|
155
1002
|
source: typeof entry.source === 'string' ? entry.source : 'builtin',
|
|
156
1003
|
}))
|
|
157
1004
|
const pathEntries = discoverPathHarnesses(options.pathValue)
|
|
1005
|
+
// A product-forced recipe must be the visible and selectable entry even
|
|
1006
|
+
// when a user has a saved recipe with the same id.
|
|
1007
|
+
const forced = defaults.filter((entry) => entry.source === 'forced')
|
|
1008
|
+
const ordinaryDefaults = defaults.filter((entry) => entry.source !== 'forced')
|
|
1009
|
+
const registry = registryEntries.filter(runnableRegistryCandidate).map((entry) => ({
|
|
1010
|
+
...entry,
|
|
1011
|
+
command: entry.resolvedCommand ?? entry.command,
|
|
1012
|
+
}))
|
|
158
1013
|
const seen = new Set()
|
|
159
|
-
|
|
1014
|
+
const seenRecipes = new Set()
|
|
1015
|
+
const seenCommands = new Set()
|
|
1016
|
+
const inferredPathEntries = new Set(pathEntries)
|
|
1017
|
+
return [...forced, ...configured, ...ordinaryDefaults, ...registry, ...pathEntries].filter((entry) => {
|
|
160
1018
|
if (seen.has(entry.id)) return false
|
|
1019
|
+
const command = resolvePathCommand(entry.command, options.pathValue, options) ?? entry.command
|
|
1020
|
+
// A bare PATH guess is not a second recipe when settings/the Registry
|
|
1021
|
+
// already describe this command's required ACP arguments.
|
|
1022
|
+
if (inferredPathEntries.has(entry) && seenCommands.has(command)) return false
|
|
1023
|
+
const recipe = JSON.stringify([
|
|
1024
|
+
command,
|
|
1025
|
+
entry.args ?? [],
|
|
1026
|
+
Object.entries(entry.env ?? {}).sort(([left], [right]) => left.localeCompare(right)),
|
|
1027
|
+
])
|
|
1028
|
+
if (seenRecipes.has(recipe)) return false
|
|
161
1029
|
seen.add(entry.id)
|
|
1030
|
+
seenRecipes.add(recipe)
|
|
1031
|
+
seenCommands.add(command)
|
|
162
1032
|
return true
|
|
163
1033
|
})
|
|
164
1034
|
}
|
|
165
1035
|
|
|
1036
|
+
function searchFields(entry) {
|
|
1037
|
+
return [
|
|
1038
|
+
entry.id,
|
|
1039
|
+
entry.label,
|
|
1040
|
+
entry.command,
|
|
1041
|
+
entry.resolvedCommand,
|
|
1042
|
+
entry.install?.command,
|
|
1043
|
+
...(entry.install?.args ?? []),
|
|
1044
|
+
entry.description,
|
|
1045
|
+
entry.distribution?.type,
|
|
1046
|
+
...(entry.distribution?.args ?? []),
|
|
1047
|
+
].filter((value) => typeof value === 'string').map((value) => value.toLowerCase())
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function searchScore(entry, query = '') {
|
|
1051
|
+
const normalized = String(query).trim().toLowerCase()
|
|
1052
|
+
if (normalized.length === 0) return 0
|
|
1053
|
+
const fields = searchFields(entry)
|
|
1054
|
+
const haystack = fields.join(' ')
|
|
1055
|
+
const terms = normalized.split(/\s+/).filter(Boolean)
|
|
1056
|
+
if (!terms.every((term) => haystack.includes(term))) return Number.POSITIVE_INFINITY
|
|
1057
|
+
if (entry.id.toLowerCase() === normalized) return 0
|
|
1058
|
+
if (entry.id.toLowerCase().startsWith(normalized)) return 1
|
|
1059
|
+
if (entry.label.toLowerCase().startsWith(normalized)) return 2
|
|
1060
|
+
if (haystack.includes(normalized)) return 3
|
|
1061
|
+
return 4
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function findHarnessCandidates(settingsPath, options = {}, query = '') {
|
|
1065
|
+
const configured = configuredHarnesses(readSettings(settingsPath)).map((entry) => ({
|
|
1066
|
+
...entry,
|
|
1067
|
+
source: 'configured',
|
|
1068
|
+
status: 'Configured',
|
|
1069
|
+
}))
|
|
1070
|
+
const configuredIds = new Set(configured.map(({ id }) => id))
|
|
1071
|
+
const registry = registryCandidates(options)
|
|
1072
|
+
const entries = [
|
|
1073
|
+
// A configured Harness is still a useful find result: it explains why a
|
|
1074
|
+
// machine with only saved entries must not look empty, and lets the user
|
|
1075
|
+
// jump straight from discovery to switching.
|
|
1076
|
+
...configured,
|
|
1077
|
+
...registry.filter((entry) => !configuredIds.has(entry.id)),
|
|
1078
|
+
...discoverHarnessEntries(settingsPath, options, registry).filter((entry) => entry.source !== 'configured'),
|
|
1079
|
+
]
|
|
1080
|
+
const seen = new Set()
|
|
1081
|
+
return entries.map((entry, index) => ({ entry, index, score: searchScore(entry, query) }))
|
|
1082
|
+
.filter(({ score }) => Number.isFinite(score))
|
|
1083
|
+
.sort((left, right) => left.score - right.score || left.index - right.index)
|
|
1084
|
+
.map(({ entry }) => entry)
|
|
1085
|
+
.filter((entry) => {
|
|
1086
|
+
if (seen.has(entry.id)) return false
|
|
1087
|
+
seen.add(entry.id)
|
|
1088
|
+
return true
|
|
1089
|
+
})
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
export function discoverHarnessCandidates(settingsPath, options = {}, query = '') {
|
|
1093
|
+
return findHarnessCandidates(settingsPath, options, query)
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
async function resolveRegistry(options = {}) {
|
|
1097
|
+
if (options.registry !== undefined) return options.registry
|
|
1098
|
+
if (typeof options.fetchRegistry === 'function') return options.fetchRegistry(options)
|
|
1099
|
+
return fetchAcpRegistry(options)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function validateCliArgs(argv) {
|
|
1103
|
+
const [action, id, ...tokens] = argv
|
|
1104
|
+
const help = ['help', '-h', '--help']
|
|
1105
|
+
if (action === undefined || help.includes(action)) return
|
|
1106
|
+
if (!['list', 'find', 'add', 'use', 'remove'].includes(action)) throw new HarnessUsageError(`Unknown Harness command ${JSON.stringify(action)}`)
|
|
1107
|
+
if (id === '--help' || id === '-h') return
|
|
1108
|
+
if (action === 'add') {
|
|
1109
|
+
if (id === undefined || id === 'help' || (tokens.length === 1 && help.includes(tokens[0]))) return
|
|
1110
|
+
parseHarnessAdd(id, tokens)
|
|
1111
|
+
} else if (action === 'list' && id !== undefined) throw new HarnessUsageError('Usage: martty harness list')
|
|
1112
|
+
else if (action === 'use' && (!id || tokens.length)) throw new HarnessUsageError('Usage: martty harness use <id>')
|
|
1113
|
+
else if (action === 'find') {
|
|
1114
|
+
for (const token of argv.slice(1)) if (token.startsWith('-') && token !== '--refresh') throw new HarnessUsageError(`Unknown find option ${JSON.stringify(token)}`)
|
|
1115
|
+
} else if (action === 'remove') {
|
|
1116
|
+
if (!id || !HARNESS_ID.test(id) || tokens.some(token => !['--cleanup', '--yes', '--dry-run'].includes(token))) {
|
|
1117
|
+
throw new HarnessUsageError('Usage: martty harness remove <id> [--cleanup] [--yes] [--dry-run]')
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** CLI setup persists recipes only; runtime/session/auth belong to explicit launch. */
|
|
1123
|
+
export async function runHarnessCommandAsync(argv, options = {}) {
|
|
1124
|
+
validateCliArgs(argv)
|
|
1125
|
+
const action = argv[0]
|
|
1126
|
+
if (action !== 'add' && ['--help', '-h'].includes(argv[1])) return runHarnessCommand(['help'], options)
|
|
1127
|
+
if (action === 'remove') {
|
|
1128
|
+
const { planHarnessRemoval, removeHarness } = await import('./harness-removal.js')
|
|
1129
|
+
const id = argv[1], cleanup = argv.includes('--cleanup')
|
|
1130
|
+
const plan = planHarnessRemoval(options.settingsPath, id, options)
|
|
1131
|
+
if (cleanup && plan.cleanupReason) throw new HarnessUsageError(plan.cleanupReason)
|
|
1132
|
+
const preview = `Remove ${plan.entry.label} (${id})\nConfiguration: ${options.settingsPath}\n`
|
|
1133
|
+
+ (cleanup ? `Delete private installation:\n${plan.resources.map(resource => ` ${resource}`).join('\n')}\n` : 'Installed files will be kept.\n')
|
|
1134
|
+
+ 'Its saved default reference will be cleared. History, credentials and shared caches are kept.\n'
|
|
1135
|
+
if (argv.includes('--dry-run')) return { code: 0, stdout: preview, stderr: '' }
|
|
1136
|
+
if (!argv.includes('--yes')) {
|
|
1137
|
+
if (typeof options.confirmRemoval !== 'function') return { code: 2, stdout: preview, stderr: 'Confirmation required. Rerun with --yes, or use an interactive terminal.\n' }
|
|
1138
|
+
if (!await options.confirmRemoval(preview)) return { code: 0, stdout: 'Removal cancelled; nothing changed.\n', stderr: '' }
|
|
1139
|
+
}
|
|
1140
|
+
options.signal?.throwIfAborted()
|
|
1141
|
+
const result = removeHarness(options.settingsPath, plan, { ...options, cleanup })
|
|
1142
|
+
return { code: 0, stdout: preview + `Removed ${id}.` + (result.removed.length ? '\nPrivate installation deleted.\n' : '\n'), stderr: '' }
|
|
1143
|
+
}
|
|
1144
|
+
const addId = argv[1]
|
|
1145
|
+
const addHelp = addId === undefined || ['help', '-h', '--help'].includes(addId)
|
|
1146
|
+
|| (argv.length === 3 && ['-h', '--help'].includes(argv[2]))
|
|
1147
|
+
const saved = action === 'add' && savedHarnesses(options.settingsPath).find(entry => entry.id === addId)
|
|
1148
|
+
const needsRegistry = action === 'find'
|
|
1149
|
+
|| (action === 'add' && !addHelp && !saved && !argv.slice(2).includes('--command'))
|
|
1150
|
+
if (!needsRegistry) {
|
|
1151
|
+
if (action === 'add' && saved && !addHelp) {
|
|
1152
|
+
const harness = configureHarness(options.settingsPath, addId, argv.slice(2), options, () => ({ ...saved, status: 'Installed' }))
|
|
1153
|
+
return { code: 0, stdout: savedHarnessOutput(harness, options.color === true), stderr: '' }
|
|
1154
|
+
}
|
|
1155
|
+
return runHarnessCommand(argv, options)
|
|
1156
|
+
}
|
|
1157
|
+
let registry = options.registry ?? readAcpRegistrySnapshot(options)
|
|
1158
|
+
let warning = ''
|
|
1159
|
+
if (options.registry === undefined && (argv.includes('--refresh')
|
|
1160
|
+
|| (action === 'add' && !registry.some(entry => entry.id === addId)))) {
|
|
1161
|
+
try { registry = await resolveRegistry(options) }
|
|
1162
|
+
catch (error) {
|
|
1163
|
+
options.signal?.throwIfAborted()
|
|
1164
|
+
warning = `warning: Registry refresh failed; using local catalog. ${error instanceof Error ? error.message : String(error)}\n`
|
|
1165
|
+
if (action === 'add' && !registry.some(entry => entry.id === addId)) throw new Error(warning.trim())
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
const scoped = { ...options, registry }
|
|
1169
|
+
if (action === 'add') {
|
|
1170
|
+
const [_, id, ...tokens] = argv
|
|
1171
|
+
const color = options.color === true
|
|
1172
|
+
const wantsAddHelp = id === undefined
|
|
1173
|
+
|| ['help', '-h', '--help'].includes(id)
|
|
1174
|
+
|| (tokens.length === 1 && ['-h', '--help'].includes(tokens[0]))
|
|
1175
|
+
if (wantsAddHelp) return runHarnessCommand(argv, scoped)
|
|
1176
|
+
const harness = await addHarnessAsync(options.settingsPath, id, tokens, scoped)
|
|
1177
|
+
return { code: 0, stdout: savedHarnessOutput(harness, color), stderr: warning }
|
|
1178
|
+
}
|
|
1179
|
+
const result = runHarnessCommand(argv.filter(token => token !== '--refresh'), scoped)
|
|
1180
|
+
return { ...result, stderr: warning + result.stderr }
|
|
1181
|
+
}
|
|
1182
|
+
|
|
166
1183
|
export function runHarnessCommand(argv, options) {
|
|
1184
|
+
validateCliArgs(argv)
|
|
167
1185
|
const settingsPath = options?.settingsPath
|
|
168
1186
|
if (typeof settingsPath !== 'string' || settingsPath.length === 0) {
|
|
169
1187
|
throw new Error('harness command needs a settings path')
|
|
170
1188
|
}
|
|
171
1189
|
const [action, id, ...tokens] = argv
|
|
172
|
-
if (
|
|
173
|
-
return { code: 0, stdout:
|
|
1190
|
+
if (['help', '-h', '--help'].includes(action) || action === undefined) {
|
|
1191
|
+
return { code: 0, stdout: harnessHelp(options.color === true), stderr: '' }
|
|
1192
|
+
}
|
|
1193
|
+
if (action === 'find') {
|
|
1194
|
+
const query = [id, ...tokens].filter(Boolean).join(' ').trim()
|
|
1195
|
+
const candidates = findHarnessCandidates(settingsPath, options, query)
|
|
1196
|
+
return { code: 0, stdout: formatHarnessFind(candidates, query, options), stderr: '' }
|
|
174
1197
|
}
|
|
175
1198
|
if (action === 'list') {
|
|
176
1199
|
const settings = readSettings(settingsPath)
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
const command = [entry.command, ...entry.args]
|
|
181
|
-
.map((part) => /\s/.test(part) ? JSON.stringify(part) : part)
|
|
182
|
-
.join(' ')
|
|
183
|
-
return `${marker} ${entry.id}\t${entry.source}\t${entry.label}\t${command}`
|
|
184
|
-
}).join('\n')
|
|
185
|
-
return { code: 0, stdout: stdout.length > 0 ? `${stdout}\n` : '', stderr: '' }
|
|
1200
|
+
const defaultId = persistedDefaultId(settings)
|
|
1201
|
+
const entries = discoverHarnesses(settingsPath, options)
|
|
1202
|
+
return { code: 0, stdout: formatHarnessList(entries, defaultId, options), stderr: '' }
|
|
186
1203
|
}
|
|
187
1204
|
if (action === 'use') {
|
|
188
1205
|
const harness = discoverHarnesses(settingsPath, options).find((entry) => entry.id === id)
|
|
189
1206
|
if (harness === undefined) throw new Error(`unknown harness ${JSON.stringify(id ?? '')}`)
|
|
190
1207
|
upsertHarness(settingsPath, harness)
|
|
191
|
-
|
|
1208
|
+
setDefaultHarness(settingsPath, id)
|
|
192
1209
|
return {
|
|
193
1210
|
code: 0,
|
|
194
|
-
stdout: `
|
|
1211
|
+
stdout: `default harness ${id}; next standalone launch starts a new session\n`,
|
|
195
1212
|
stderr: '',
|
|
196
1213
|
}
|
|
197
1214
|
}
|
|
198
1215
|
if (action !== 'add') throw new Error(`unknown harness command ${JSON.stringify(action ?? '')}`)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (token === '--command') command = value
|
|
206
|
-
else if (token === '--label') label = value
|
|
207
|
-
else if (token === '--arg') args.push(value ?? '')
|
|
208
|
-
else throw new Error(`unknown harness add option ${JSON.stringify(token)}`)
|
|
209
|
-
index += 1
|
|
1216
|
+
const color = options.color === true
|
|
1217
|
+
const wantsAddHelp = id === undefined
|
|
1218
|
+
|| ['help', '-h', '--help'].includes(id)
|
|
1219
|
+
|| (tokens.length === 1 && ['-h', '--help'].includes(tokens[0]))
|
|
1220
|
+
if (wantsAddHelp) {
|
|
1221
|
+
return { code: 0, stdout: addHarnessHelp(color), stderr: '' }
|
|
210
1222
|
}
|
|
211
|
-
|
|
212
|
-
return { code: 0, stdout:
|
|
1223
|
+
const harness = addHarness(settingsPath, id, tokens, options)
|
|
1224
|
+
return { code: 0, stdout: savedHarnessOutput(harness, color), stderr: '' }
|
|
213
1225
|
}
|