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/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 HARNESS_HELP = `martty harness configure standalone ACP harnesses
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
- USAGE:
16
- martty harness list
17
- martty harness add <id> --command <cmd> [--label <label>] [--arg <arg>]...
18
- martty harness use <id>
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
- list discovers executable *-acp and *_acp commands on PATH. The active harness
21
- is saved in $MARTTY_HOME/settings.json. The next standalone launch starts that
22
- harness with a new ACP session; sessions are never carried across harnesses.
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: [...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 activateHarness(settingsPath, id) {
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, activeHarness: id })
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
- if (typeof settings.activeHarness !== 'string') return undefined
112
- return configuredHarnesses(settings).find(({ id }) => id === settings.activeHarness)
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
- return [...configured, ...defaults, ...pathEntries].filter((entry) => {
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 (action === 'help' || action === undefined) {
173
- return { code: 0, stdout: HARNESS_HELP, stderr: '' }
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 active = typeof settings.activeHarness === 'string' ? settings.activeHarness : undefined
178
- const stdout = discoverHarnesses(settingsPath, options).map((entry) => {
179
- const marker = entry.id === active ? '*' : ' '
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
- activateHarness(settingsPath, id)
1208
+ setDefaultHarness(settingsPath, id)
192
1209
  return {
193
1210
  code: 0,
194
- stdout: `active harness ${id}; next standalone launch starts a new session\n`,
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
- let command
200
- let label
201
- const args = []
202
- for (let index = 0; index < tokens.length; index += 1) {
203
- const token = tokens[index]
204
- const value = tokens[index + 1]
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
- upsertHarness(settingsPath, { id, label, command, args })
212
- return { code: 0, stdout: `saved harness ${id}\n`, stderr: '' }
1223
+ const harness = addHarness(settingsPath, id, tokens, options)
1224
+ return { code: 0, stdout: savedHarnessOutput(harness, color), stderr: '' }
213
1225
  }