@xingular/harness 0.1.6 → 0.1.7
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/CHANGELOG.md +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +6 -1
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +68 -4
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +92 -0
package/src/accounts/accounts.ts
CHANGED
|
@@ -45,8 +45,26 @@ function validProvider(provider: string): string {
|
|
|
45
45
|
|
|
46
46
|
interface PendingLogin {
|
|
47
47
|
accountId: string
|
|
48
|
+
provider: string
|
|
49
|
+
method: 'oauth' | 'api_key'
|
|
48
50
|
cancel: () => void
|
|
49
51
|
prompts: Map<string, { resolve: (value: string) => void; reject: (error: Error) => void }>
|
|
52
|
+
revision: number
|
|
53
|
+
events: Record<string, unknown>[]
|
|
54
|
+
prompt: Record<string, unknown> | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AccountLoginSnapshot {
|
|
58
|
+
accountId: string
|
|
59
|
+
loginId: string
|
|
60
|
+
provider: string
|
|
61
|
+
method: string
|
|
62
|
+
revision: number
|
|
63
|
+
status: 'running' | 'completed' | 'failed' | 'cancelled'
|
|
64
|
+
events: Record<string, unknown>[]
|
|
65
|
+
prompt: Record<string, unknown> | null
|
|
66
|
+
success: boolean | null
|
|
67
|
+
error: string | null
|
|
50
68
|
}
|
|
51
69
|
|
|
52
70
|
interface Probe {
|
|
@@ -71,7 +89,9 @@ export class Accounts {
|
|
|
71
89
|
private readonly bus: Bus
|
|
72
90
|
private readonly runtimes = new Map<string, Promise<ModelRuntime>>()
|
|
73
91
|
private readonly logins = new Map<string, PendingLogin>()
|
|
92
|
+
private readonly latestLogins = new Map<string, AccountLoginSnapshot>()
|
|
74
93
|
private readonly terminals = new Map<string, number>()
|
|
94
|
+
private installationDeviceIdPromise: Promise<string> | null = null
|
|
75
95
|
|
|
76
96
|
constructor(options: AccountsOptions) {
|
|
77
97
|
this.dir = options.dir
|
|
@@ -157,6 +177,25 @@ export class Accounts {
|
|
|
157
177
|
}
|
|
158
178
|
}
|
|
159
179
|
|
|
180
|
+
/** Pi's OAuth device ID belongs to this Harness installation, not to one isolated account. */
|
|
181
|
+
installationDeviceId(): Promise<string> {
|
|
182
|
+
if (!this.installationDeviceIdPromise) {
|
|
183
|
+
this.installationDeviceIdPromise = this.loadInstallationDeviceId().catch((error: unknown) => {
|
|
184
|
+
this.installationDeviceIdPromise = null
|
|
185
|
+
throw error
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
return this.installationDeviceIdPromise
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async loadInstallationDeviceId(): Promise<string> {
|
|
192
|
+
const { SettingsManager } = await import('@earendil-works/pi-coding-agent')
|
|
193
|
+
const settings = SettingsManager.create(process.cwd(), env.ensureSharedDir(this.dir))
|
|
194
|
+
const deviceId = settings.getOrCreateDeviceId()
|
|
195
|
+
await settings.flush()
|
|
196
|
+
return deviceId
|
|
197
|
+
}
|
|
198
|
+
|
|
160
199
|
// ---- The Pi runtime ---------------------------------------------------------------------
|
|
161
200
|
|
|
162
201
|
/** The lazily loaded Pi runtime of one account: its models and its credentials. Pi's own `~/.pi` is never used. */
|
|
@@ -250,7 +289,7 @@ export class Accounts {
|
|
|
250
289
|
const keepLimitError = current.lastError !== null && store.isLimitCategory(current.lastError.category) && store.limitErrorActive(current.lastError)
|
|
251
290
|
if (probe.signedIn === true && !keepLimitError) current.lastError = null
|
|
252
291
|
} else {
|
|
253
|
-
current.lastError = { category: 'unknown', message: store.scrub(failure ?? ''), at: now(), resetsAt: null }
|
|
292
|
+
current.lastError = { category: classifyMessage(failure ?? '') ?? 'unknown', message: store.scrub(failure ?? ''), at: now(), resetsAt: null }
|
|
254
293
|
}
|
|
255
294
|
})
|
|
256
295
|
if (previous !== state.health) store.audit(this.dir, account.id, 'health.changed', `${previous} → ${state.health} (status check)`)
|
|
@@ -295,6 +334,8 @@ export class Accounts {
|
|
|
295
334
|
/** Deletes the account: removes its stored credentials and its runtime folder. Sessions pinned to it ask for another account next time they start. */
|
|
296
335
|
async remove(accountId: string): Promise<AccountsOverview> {
|
|
297
336
|
const account = this.find(accountId)
|
|
337
|
+
for (const login of this.logins.values()) if (login.accountId === accountId) login.cancel()
|
|
338
|
+
this.latestLogins.delete(accountId)
|
|
298
339
|
await this.forgetCredentials(account).catch(() => undefined)
|
|
299
340
|
await this.secrets.delete(secretKey(account.id, 'apiKey')).catch(() => undefined)
|
|
300
341
|
store.forgetAccount(this.dir, account)
|
|
@@ -354,7 +395,7 @@ export class Accounts {
|
|
|
354
395
|
return this.overview()
|
|
355
396
|
}
|
|
356
397
|
|
|
357
|
-
private finishLogin(loginId: string, success: boolean, error: string | null): void {
|
|
398
|
+
private finishLogin(loginId: string, success: boolean, error: string | null, cancelled = false): void {
|
|
358
399
|
const login = this.logins.get(loginId)
|
|
359
400
|
if (!login) return
|
|
360
401
|
this.logins.delete(loginId)
|
|
@@ -363,7 +404,25 @@ export class Accounts {
|
|
|
363
404
|
store.audit(this.dir, login.accountId, success ? 'login.completed' : 'login.failed', error ?? '')
|
|
364
405
|
// A fresh sign-in starts clean; a limit that still applies is reported again by the next run.
|
|
365
406
|
if (success) store.updateState(this.dir, login.accountId, (current) => void (current.lastError = null))
|
|
366
|
-
|
|
407
|
+
login.revision += 1
|
|
408
|
+
const status: AccountLoginSnapshot['status'] = success ? 'completed' : cancelled ? 'cancelled' : 'failed'
|
|
409
|
+
if (this.latestLogins.get(login.accountId)?.loginId === loginId) {
|
|
410
|
+
this.latestLogins.set(login.accountId, {
|
|
411
|
+
accountId: login.accountId,
|
|
412
|
+
loginId,
|
|
413
|
+
provider: login.provider,
|
|
414
|
+
method: login.method,
|
|
415
|
+
revision: login.revision,
|
|
416
|
+
status,
|
|
417
|
+
// A terminal result is enough to reconcile the client. In particular, do not retain a
|
|
418
|
+
// device code after the attempt ends.
|
|
419
|
+
events: [],
|
|
420
|
+
prompt: null,
|
|
421
|
+
success,
|
|
422
|
+
error
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
this.bus.emit({ type: 'accounts.login', accountId: login.accountId, loginId, revision: login.revision, success, error })
|
|
367
426
|
try {
|
|
368
427
|
void this.refreshOne(this.find(login.accountId)).catch(() => undefined)
|
|
369
428
|
} catch {
|
|
@@ -379,12 +438,55 @@ export class Accounts {
|
|
|
379
438
|
const account = this.find(accountId)
|
|
380
439
|
if (!provider) throw invalid('Choose which Pi provider to sign in to.')
|
|
381
440
|
store.audit(this.dir, account.id, 'login.started', `${method ?? 'oauth'} · ${provider}`)
|
|
382
|
-
const runtime = await this.runtime(account)
|
|
383
441
|
const loginId = randomUUID()
|
|
384
442
|
const controller = new AbortController()
|
|
385
|
-
const login: PendingLogin = {
|
|
443
|
+
const login: PendingLogin = {
|
|
444
|
+
accountId: account.id,
|
|
445
|
+
provider,
|
|
446
|
+
method: method === 'api_key' ? 'api_key' : 'oauth',
|
|
447
|
+
prompts: new Map(),
|
|
448
|
+
cancel: () => controller.abort(),
|
|
449
|
+
revision: 0,
|
|
450
|
+
events: [],
|
|
451
|
+
prompt: null
|
|
452
|
+
}
|
|
386
453
|
this.logins.set(loginId, login)
|
|
387
|
-
|
|
454
|
+
this.latestLogins.set(account.id, {
|
|
455
|
+
accountId: account.id,
|
|
456
|
+
loginId,
|
|
457
|
+
provider: login.provider,
|
|
458
|
+
method: login.method,
|
|
459
|
+
revision: 0,
|
|
460
|
+
status: 'running',
|
|
461
|
+
events: [],
|
|
462
|
+
prompt: null,
|
|
463
|
+
success: null,
|
|
464
|
+
error: null
|
|
465
|
+
})
|
|
466
|
+
const event = (payload: Record<string, unknown>): void => {
|
|
467
|
+
login.revision += 1
|
|
468
|
+
if (payload.type === 'prompt') login.prompt = payload
|
|
469
|
+
else if (payload.type === 'promptClosed' && login.prompt?.promptId === payload.promptId) login.prompt = null
|
|
470
|
+
else {
|
|
471
|
+
login.events.push(payload)
|
|
472
|
+
if (login.events.length > 50) login.events.shift()
|
|
473
|
+
}
|
|
474
|
+
if (this.latestLogins.get(account.id)?.loginId === loginId) {
|
|
475
|
+
this.latestLogins.set(account.id, {
|
|
476
|
+
accountId: account.id,
|
|
477
|
+
loginId,
|
|
478
|
+
provider: login.provider,
|
|
479
|
+
method: login.method,
|
|
480
|
+
revision: login.revision,
|
|
481
|
+
status: 'running',
|
|
482
|
+
events: login.events.map((step) => ({ ...step })),
|
|
483
|
+
prompt: login.prompt ? { ...login.prompt } : null,
|
|
484
|
+
success: null,
|
|
485
|
+
error: null
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
this.bus.emit({ type: 'accounts.loginEvent', accountId: account.id, loginId, revision: login.revision, event: payload })
|
|
489
|
+
}
|
|
388
490
|
const interaction = {
|
|
389
491
|
signal: controller.signal,
|
|
390
492
|
notify: (notification: Record<string, unknown>) => event(notification),
|
|
@@ -400,24 +502,53 @@ export class Accounts {
|
|
|
400
502
|
event({ type: 'prompt', promptId, prompt: { type: prompt.type, message: prompt.message, placeholder: prompt.placeholder ?? null, options: prompt.options ?? null } })
|
|
401
503
|
})
|
|
402
504
|
}
|
|
403
|
-
runtime
|
|
404
|
-
.
|
|
505
|
+
void this.runtime(account)
|
|
506
|
+
.then(async (runtime) => {
|
|
507
|
+
if (login.method !== 'oauth') return runtime.login(provider, login.method, interaction as never)
|
|
508
|
+
const deviceId = await this.installationDeviceId()
|
|
509
|
+
return runtime.login(provider, login.method, interaction as never, { getDeviceId: () => deviceId })
|
|
510
|
+
})
|
|
405
511
|
.then(() => this.finishLogin(loginId, true, null))
|
|
406
|
-
.catch((error: unknown) => this.finishLogin(
|
|
512
|
+
.catch((error: unknown) => this.finishLogin(
|
|
513
|
+
loginId,
|
|
514
|
+
false,
|
|
515
|
+
controller.signal.aborted ? 'Sign-in was cancelled.' : error instanceof Error ? error.message : String(error),
|
|
516
|
+
controller.signal.aborted
|
|
517
|
+
))
|
|
407
518
|
return { loginId }
|
|
408
519
|
}
|
|
409
520
|
|
|
521
|
+
/** Current in-memory sign-in progress for one account; codes and prompts are never written to disk. */
|
|
522
|
+
loginStatus(accountId: string): AccountLoginSnapshot | null {
|
|
523
|
+
this.find(accountId)
|
|
524
|
+
const snapshot = this.latestLogins.get(accountId)
|
|
525
|
+
return snapshot ? structuredClone(snapshot) : null
|
|
526
|
+
}
|
|
527
|
+
|
|
410
528
|
loginRespond(loginId: string, promptId: string, value: string | null): void {
|
|
411
529
|
const login = this.logins.get(loginId)
|
|
412
530
|
const prompt = login?.prompts.get(promptId)
|
|
413
531
|
if (!login || !prompt) throw new HarnessError('not_found', 'That sign-in step is no longer waiting.')
|
|
414
532
|
login.prompts.delete(promptId)
|
|
533
|
+
// The renderer's recoverable snapshot must stop presenting a prompt as soon as it is answered.
|
|
534
|
+
const current = this.latestLogins.get(login.accountId)
|
|
535
|
+
if (current?.loginId === loginId && current.prompt?.promptId === promptId) {
|
|
536
|
+
login.prompt = null
|
|
537
|
+
current.revision += 1
|
|
538
|
+
login.revision = current.revision
|
|
539
|
+
current.prompt = null
|
|
540
|
+
this.latestLogins.set(login.accountId, current)
|
|
541
|
+
this.bus.emit({ type: 'accounts.loginEvent', accountId: login.accountId, loginId, revision: current.revision, event: { type: 'promptClosed', promptId } })
|
|
542
|
+
}
|
|
415
543
|
if (value === null) prompt.reject(new Error('Cancelled.'))
|
|
416
544
|
else prompt.resolve(value)
|
|
417
545
|
}
|
|
418
546
|
|
|
419
547
|
loginCancel(accountId: string, loginId?: string | null): void {
|
|
420
|
-
if (loginId)
|
|
548
|
+
if (loginId) {
|
|
549
|
+
const login = this.logins.get(loginId)
|
|
550
|
+
if (login?.accountId === accountId) login.cancel()
|
|
551
|
+
} else for (const login of this.logins.values()) if (login.accountId === accountId) login.cancel()
|
|
421
552
|
store.audit(this.dir, accountId, 'login.cancelled', '')
|
|
422
553
|
}
|
|
423
554
|
|
|
@@ -428,6 +559,11 @@ export class Accounts {
|
|
|
428
559
|
* account's stored credentials are written there (mode 0600) while the terminal runs, then
|
|
429
560
|
* read back and removed.
|
|
430
561
|
*/
|
|
562
|
+
/** How many terminal sessions are running on this account right now. */
|
|
563
|
+
terminalCount(accountId: string): number {
|
|
564
|
+
return this.terminals.get(accountId) ?? 0
|
|
565
|
+
}
|
|
566
|
+
|
|
431
567
|
async terminalBegin(account: Account): Promise<void> {
|
|
432
568
|
const count = (this.terminals.get(account.id) ?? 0) + 1
|
|
433
569
|
this.terminals.set(account.id, count)
|
package/src/cli.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { resolve } from 'node:path'
|
|
|
12
12
|
import { log } from './core/bus'
|
|
13
13
|
import { createHarness, type HarnessOptions } from './harness'
|
|
14
14
|
import { runPluginsCli, terminalAsk } from './plugins/cli'
|
|
15
|
+
import { runHubCli } from './hub/cli'
|
|
15
16
|
import { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
|
|
16
17
|
import { RpcPeer, stdioTransport } from './server/rpc'
|
|
17
18
|
import { attach } from './server/serve'
|
|
@@ -19,16 +20,17 @@ import { runAppsCli, runWikiCli } from './terminal/cli'
|
|
|
19
20
|
import { runStandaloneTerminal, standaloneArguments } from './terminal/standalone'
|
|
20
21
|
|
|
21
22
|
const USAGE = `Usage: xingular-harness [--data <dir>] [--state <dir>]
|
|
22
|
-
xingular-harness <terminal | serve | wiki | mcp | plugins> [arguments]
|
|
23
|
+
xingular-harness <terminal | serve | wiki | mcp | plugins | hub> [arguments]
|
|
23
24
|
|
|
24
|
-
(no command) Start the interactive
|
|
25
|
-
terminal Start the interactive
|
|
25
|
+
(no command) Start the interactive Harness terminal
|
|
26
|
+
terminal Start the interactive Harness terminal
|
|
26
27
|
serve --state <dir> [--data <dir>] [--name <product>] [--builtins <dir>]
|
|
27
28
|
[--host-secrets] [--host-policy] [--host-tools] [--host-directory]
|
|
28
29
|
Serve the harness over newline-delimited JSON
|
|
29
30
|
wiki <command> Memory and Wiki commands for a terminal session
|
|
30
31
|
mcp <command> Connected-app commands for a terminal session
|
|
31
32
|
plugins <command> List, install, switch and trust plugins (plugins help)
|
|
33
|
+
hub <command> Browse and install curated items (hub help)
|
|
32
34
|
|
|
33
35
|
Standalone terminal options:
|
|
34
36
|
--data <dir> Data directory (default: ~/.xingular-harness/data)
|
|
@@ -91,6 +93,7 @@ function serveCommand(argv: string[]): Promise<number> {
|
|
|
91
93
|
async function main(): Promise<number> {
|
|
92
94
|
const argv = process.argv.slice(2)
|
|
93
95
|
const [command, ...rest] = argv
|
|
96
|
+
if (command === 'terminal-frontend') return (await import('./terminal/tui/application')).runTerminalFrontend(rest)
|
|
94
97
|
if (argv.includes('--help') || argv.includes('-h')) {
|
|
95
98
|
process.stdout.write(USAGE)
|
|
96
99
|
return 0
|
|
@@ -101,6 +104,7 @@ async function main(): Promise<number> {
|
|
|
101
104
|
if (command === 'wiki') return runWikiCli(rest)
|
|
102
105
|
if (command === 'mcp') return runAppsCli(rest)
|
|
103
106
|
if (command === 'plugins') return runPluginsCli(rest, { ask: terminalAsk() })
|
|
107
|
+
if (command === 'hub') return runHubCli(rest)
|
|
104
108
|
process.stdout.write(USAGE)
|
|
105
109
|
return command ? 2 : 0
|
|
106
110
|
}
|
package/src/harness.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { mkdirSync } from 'node:fs'
|
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
|
|
7
7
|
import { Accounts } from './accounts/accounts'
|
|
8
|
+
import { Hub, type HubOptions } from './hub/hub'
|
|
8
9
|
import * as capabilities from './capabilities/api'
|
|
9
10
|
import { ensureSharedDir, isolateProcess } from './accounts/env'
|
|
10
11
|
import { fileSecretStore, type SecretStore } from './accounts/secrets'
|
|
@@ -41,6 +42,7 @@ import * as registry from './skills/registry'
|
|
|
41
42
|
import { Terminals, type TerminalAssets } from './terminal/terminals'
|
|
42
43
|
|
|
43
44
|
export interface HarnessOptions {
|
|
45
|
+
hub?: HubOptions
|
|
44
46
|
/** Where knowledge and history live. May be set later with `open`. */
|
|
45
47
|
dataRoot?: string | null
|
|
46
48
|
/** Device-local state that never belongs in the data root: accounts, approvals, connections, sockets. */
|
|
@@ -54,11 +56,16 @@ export interface HarnessOptions {
|
|
|
54
56
|
/** Replaces Pi, for tests. */
|
|
55
57
|
sessionFactory?: SessionFactory
|
|
56
58
|
maintainerFactory?: MaintainerFactory
|
|
59
|
+
/** Delegate maintenance execution to the embedding host through durable requests. */
|
|
60
|
+
externalMaintenance?: boolean
|
|
61
|
+
maintenanceExcludedTriggers?: string[]
|
|
62
|
+
/** Queue consolidation after every settled run (the default). False leaves memory to explicit proposals and scheduled merges. */
|
|
63
|
+
maintenanceRunTrigger?: boolean
|
|
57
64
|
/** How long the maintenance queue stays empty before full memory blocks are condensed. */
|
|
58
65
|
idleMs?: number
|
|
59
66
|
/** Where the built commands a terminal session runs are, when they are not beside this package. */
|
|
60
67
|
assets?: TerminalAssets
|
|
61
|
-
/**
|
|
68
|
+
/** Optional legacy executable override. The default is the Harness-owned terminal frontend. */
|
|
62
69
|
piProgram?: () => string | null
|
|
63
70
|
/** Folders of built-in skills, prompts, and rules, each laid out like `registry/`. */
|
|
64
71
|
builtins?: string[]
|
|
@@ -102,6 +109,7 @@ class Env implements HarnessEnv {
|
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
export class Harness {
|
|
112
|
+
readonly hub: Hub
|
|
105
113
|
readonly bus = new Bus()
|
|
106
114
|
readonly env: Env
|
|
107
115
|
readonly stateDir: string
|
|
@@ -129,6 +137,7 @@ export class Harness {
|
|
|
129
137
|
// Before anything can start a session: no inherited credential, no `~/.pi`.
|
|
130
138
|
isolateProcess(join(this.stateDir, 'accounts'))
|
|
131
139
|
this.env = new Env(options.dataRoot || null, options.directory ?? EMPTY_DIRECTORY, options.productName ?? DEFAULT_PRODUCT_NAME, this.bus)
|
|
140
|
+
this.hub = new Hub(this, options.hub)
|
|
132
141
|
this.policy = options.policy ?? {}
|
|
133
142
|
registry.configureBuiltins(options.builtins ?? [])
|
|
134
143
|
const secrets = options.secrets ?? fileSecretStore(join(this.stateDir, 'secrets.json'))
|
|
@@ -183,8 +192,8 @@ export class Harness {
|
|
|
183
192
|
resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }),
|
|
184
193
|
factory: options.sessionFactory
|
|
185
194
|
})
|
|
186
|
-
this.maintenance = new Maintenance({ env: this.env, bus: this.bus, accounts: this.accounts, factory: options.maintainerFactory, idleMs: options.idleMs })
|
|
187
|
-
this.terminals = new Terminals({ env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram })
|
|
195
|
+
this.maintenance = new Maintenance({ env: this.env, bus: this.bus, accounts: this.accounts, factory: options.maintainerFactory, idleMs: options.idleMs, external: options.externalMaintenance, excludedTriggers: options.maintenanceExcludedTriggers, runTrigger: options.maintenanceRunTrigger })
|
|
196
|
+
this.terminals = new Terminals({ env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram, hub: this.hub })
|
|
188
197
|
this.ready = this.apps.start().catch((error) => log('connected apps', error instanceof Error ? error.message : String(error)))
|
|
189
198
|
this.runs.listen((event, run) => {
|
|
190
199
|
this.accounts.onRunEvent(event, run)
|
|
@@ -193,6 +202,7 @@ export class Harness {
|
|
|
193
202
|
if ((event.type === 'completed' || event.type === 'failed') && run) {
|
|
194
203
|
this.hooks.emit({ type: 'history.run.ended', runId: run.id, sessionId: run.sessionId, status: run.status, goal: run.goal ?? null, agentId: run.agentId ?? null, projectId: run.projectId ?? null, startedAt: run.startedAt, endedAt: run.endedAt ?? null, at: event.timestamp })
|
|
195
204
|
}
|
|
205
|
+
if (event.type === 'completed' || event.type === 'failed' || (event.type === 'status.changed' && (event.payload as { status?: string }).status === 'interrupted')) this.maintenance.finishRun(event.runId, event.type !== 'completed')
|
|
196
206
|
this.maintenance.onRunEvent(event)
|
|
197
207
|
})
|
|
198
208
|
}
|
|
@@ -215,8 +225,10 @@ export class Harness {
|
|
|
215
225
|
* older Wiki is migrated.
|
|
216
226
|
*/
|
|
217
227
|
open(dataRoot: string, agentIds: readonly string[] = []): Recovery {
|
|
228
|
+
if (this.hub.busy && this.env.dataRoot !== dataRoot) throw new HarnessError('hub_busy', 'Wait for the hub installation before changing the data location.')
|
|
218
229
|
if (this.env.dataRoot && this.env.dataRoot !== dataRoot) closeIndex(this.env.dataRoot)
|
|
219
230
|
this.env.dataRoot = dataRoot
|
|
231
|
+
this.hub.watch(dataRoot)
|
|
220
232
|
const root = dataRoot
|
|
221
233
|
this.hooks.watch(store.wikiDir(root))
|
|
222
234
|
void this.hooks.reload()
|
|
@@ -281,6 +293,7 @@ export class Harness {
|
|
|
281
293
|
|
|
282
294
|
/** Stops live runs and releases the index. */
|
|
283
295
|
async close(): Promise<void> {
|
|
296
|
+
this.hub.close()
|
|
284
297
|
this.maintenance.stop()
|
|
285
298
|
await this.terminals.close()
|
|
286
299
|
await this.runs.interruptAll()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "xingular/evidence-first",
|
|
6
|
+
"title": "Evidence first",
|
|
7
|
+
"description": "Keep observations, inferences, and unknowns distinct.",
|
|
8
|
+
"publisher": "Xingular",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"version": "1.0.0",
|
|
11
|
+
"category": "rule",
|
|
12
|
+
"channel": "official",
|
|
13
|
+
"tags": [
|
|
14
|
+
"rule"
|
|
15
|
+
],
|
|
16
|
+
"requires": {
|
|
17
|
+
"harness": "0.1.6"
|
|
18
|
+
},
|
|
19
|
+
"sourceUrl": "https://github.com/tanntnny/xingular-hub/tree/main/entries/xingular/evidence-first/1.0.0",
|
|
20
|
+
"delivery": {
|
|
21
|
+
"kind": "registry",
|
|
22
|
+
"registryKind": "rules",
|
|
23
|
+
"url": "https://raw.githubusercontent.com/tanntnny/xingular-hub/main/entries/xingular/evidence-first/1.0.0/payload.json",
|
|
24
|
+
"sha256": "0f77661ea41e27bcb51aad982d15d493b893c5c1ba1eb6046a21315ddac07c5b",
|
|
25
|
+
"bundled": "{\n \"items\": [\n {\n \"name\": \"hub-evidence-first\",\n \"files\": [\n {\n \"path\": \"CONTENT.md\",\n \"encoding\": \"utf8\",\n \"content\": \"---\\nname: Evidence first\\ndescription: Distinguish observations, inferences, and unknowns.\\n---\\n\\nSupport factual claims with inspectable evidence. Label inferences and uncertainties. Correct a claim when better evidence changes it.\\n\"\n }\n ]\n }\n ]\n}\n"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "xingular/github-connector",
|
|
30
|
+
"title": "GitHub connector",
|
|
31
|
+
"description": "A setup recipe for the GitHub MCP server; provide your own credentials.",
|
|
32
|
+
"publisher": "Xingular",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"version": "1.0.0",
|
|
35
|
+
"category": "connector",
|
|
36
|
+
"channel": "official",
|
|
37
|
+
"tags": [
|
|
38
|
+
"connector"
|
|
39
|
+
],
|
|
40
|
+
"requires": {
|
|
41
|
+
"harness": "0.1.6"
|
|
42
|
+
},
|
|
43
|
+
"sourceUrl": "https://github.com/tanntnny/xingular-hub/tree/main/entries/xingular/github-connector/1.0.0",
|
|
44
|
+
"delivery": {
|
|
45
|
+
"kind": "document",
|
|
46
|
+
"url": "https://raw.githubusercontent.com/tanntnny/xingular-hub/main/entries/xingular/github-connector/1.0.0/payload.json",
|
|
47
|
+
"sha256": "ec5bd9675e2977b9d880cf81732d84ac72b04af271048499415eaa6288896223",
|
|
48
|
+
"bundled": "{\n \"id\": \"github-hub\",\n \"name\": \"GitHub\",\n \"description\": \"Repositories, issues, and pull requests\",\n \"kind\": \"remote\",\n \"docsUrl\": \"https://github.com/github/github-mcp-server\",\n \"url\": \"https://api.githubcopilot.com/mcp/\",\n \"auth\": \"headers\",\n \"oauth\": null,\n \"fields\": [\n {\n \"header\": \"Authorization\",\n \"label\": \"Personal access token\",\n \"prefix\": \"Bearer \",\n \"help\": \"Provide a token for only the repositories and permissions you need.\"\n }\n ],\n \"setup\": [\n \"Create a personal access token in GitHub with the access you want to grant.\",\n \"Review the connection before enabling it.\"\n ],\n \"notice\": null,\n \"provenance\": null,\n \"suggestedReadTools\": []\n}\n"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"id": "xingular/research-starter",
|
|
53
|
+
"title": "Research starter",
|
|
54
|
+
"description": "A starting agent for comparing sources and writing supported answers.",
|
|
55
|
+
"publisher": "Xingular",
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"version": "1.0.0",
|
|
58
|
+
"category": "agent-template",
|
|
59
|
+
"channel": "official",
|
|
60
|
+
"tags": [
|
|
61
|
+
"agent-template"
|
|
62
|
+
],
|
|
63
|
+
"requires": {
|
|
64
|
+
"harness": "0.1.6"
|
|
65
|
+
},
|
|
66
|
+
"sourceUrl": "https://github.com/tanntnny/xingular-hub/tree/main/entries/xingular/research-starter/1.0.0",
|
|
67
|
+
"delivery": {
|
|
68
|
+
"kind": "document",
|
|
69
|
+
"url": "https://raw.githubusercontent.com/tanntnny/xingular-hub/main/entries/xingular/research-starter/1.0.0/payload.json",
|
|
70
|
+
"sha256": "a2f672583df6f4049cc153ba1e173192301220e5f8817f9ad5a4fe87af25f52b",
|
|
71
|
+
"bundled": "{\n \"schemaVersion\": 1,\n \"id\": \"research-starter\",\n \"title\": \"Research starter\",\n \"description\": \"Compare sources and explain the evidence behind an answer.\",\n \"version\": \"1.0.0\",\n \"author\": \"Xingular\",\n \"license\": \"MIT\",\n \"agent\": {\n \"name\": \"Research starter\",\n \"icon\": {\n \"kind\": \"mascot\",\n \"shape\": \"prism\",\n \"color\": \"violet\",\n \"material\": \"glass\"\n },\n \"description\": \"Investigates questions with sources.\",\n \"instructions\": \"Help the user investigate a question. Start with reliable sources, explain what the evidence supports, cite the sources, and state unresolved questions. Ask the user when their goal is unclear.\",\n \"model\": null,\n \"effort\": \"high\",\n \"tools\": [\n \"read\",\n \"grep\",\n \"find\",\n \"ls\",\n \"web_search\",\n \"web_fetch\"\n ]\n },\n \"skills\": [],\n \"connections\": []\n}\n"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "xingular/source-review",
|
|
76
|
+
"title": "Review a source",
|
|
77
|
+
"description": "Check who wrote a source, what it supports, and what remains uncertain.",
|
|
78
|
+
"publisher": "Xingular",
|
|
79
|
+
"license": "MIT",
|
|
80
|
+
"version": "1.0.0",
|
|
81
|
+
"category": "skill",
|
|
82
|
+
"channel": "official",
|
|
83
|
+
"tags": [
|
|
84
|
+
"skill"
|
|
85
|
+
],
|
|
86
|
+
"requires": {
|
|
87
|
+
"harness": "0.1.6"
|
|
88
|
+
},
|
|
89
|
+
"sourceUrl": "https://github.com/tanntnny/xingular-hub/tree/main/entries/xingular/source-review/1.0.0",
|
|
90
|
+
"delivery": {
|
|
91
|
+
"kind": "registry",
|
|
92
|
+
"registryKind": "skills",
|
|
93
|
+
"url": "https://raw.githubusercontent.com/tanntnny/xingular-hub/main/entries/xingular/source-review/1.0.0/payload.json",
|
|
94
|
+
"sha256": "bb0617889ee1ca39611a10952bec3086fa3986e9ce10a809c473c57acf47bc07",
|
|
95
|
+
"bundled": "{\n \"items\": [\n {\n \"name\": \"hub-source-review\",\n \"files\": [\n {\n \"path\": \"SKILL.md\",\n \"encoding\": \"utf8\",\n \"content\": \"---\\nname: hub-source-review\\ndescription: Review the evidence behind a source before using its claims.\\n---\\n\\n# Review a source\\n\\nRecord its author and date. Identify the original evidence. Write what the source supports and which uncertainties remain. Cite the source so the reader can inspect it.\\n\"\n }\n ]\n }\n ]\n}\n"
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"id": "xingular/summarize-source",
|
|
100
|
+
"title": "Summarize a source",
|
|
101
|
+
"description": "Summarize a provided source and explain its limitations.",
|
|
102
|
+
"publisher": "Xingular",
|
|
103
|
+
"license": "MIT",
|
|
104
|
+
"version": "1.0.0",
|
|
105
|
+
"category": "prompt",
|
|
106
|
+
"channel": "official",
|
|
107
|
+
"tags": [
|
|
108
|
+
"prompt"
|
|
109
|
+
],
|
|
110
|
+
"requires": {
|
|
111
|
+
"harness": "0.1.6"
|
|
112
|
+
},
|
|
113
|
+
"sourceUrl": "https://github.com/tanntnny/xingular-hub/tree/main/entries/xingular/summarize-source/1.0.0",
|
|
114
|
+
"delivery": {
|
|
115
|
+
"kind": "registry",
|
|
116
|
+
"registryKind": "prompts",
|
|
117
|
+
"url": "https://raw.githubusercontent.com/tanntnny/xingular-hub/main/entries/xingular/summarize-source/1.0.0/payload.json",
|
|
118
|
+
"sha256": "12a646d474fe3cd822970869c972d1d9400c7f6350388dc735dce5c31a22680a",
|
|
119
|
+
"bundled": "{\n \"items\": [\n {\n \"name\": \"hub-summarize-source\",\n \"files\": [\n {\n \"path\": \"CONTENT.md\",\n \"encoding\": \"utf8\",\n \"content\": \"---\\nname: summarize-source\\ndescription: Summarize the supplied source and its limits.\\n---\\n\\nSummarize the source below. Include its main claims, supporting evidence, limitations, and questions it leaves unanswered.\\n\\n$@\\n\"\n }\n ]\n }\n ]\n}\n"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
}
|
package/src/hub/cli.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { Harness, type HarnessOptions } from '../harness'
|
|
4
|
+
|
|
5
|
+
export const HUB_USAGE = `Usage: xingular-harness hub <list | show | install> [id@version] [options]
|
|
6
|
+
|
|
7
|
+
list [--refresh] Browse the curated catalog and locally installed versions
|
|
8
|
+
show <id@version> Show publisher, license, source, and delivery details
|
|
9
|
+
install <id@version> Install the exact reviewed version, disabled and untrusted
|
|
10
|
+
|
|
11
|
+
--data <dir> Target data library (default: ~/.xingular-harness/data)
|
|
12
|
+
--state <dir> Target device state (default: ~/.xingular-harness/state)
|
|
13
|
+
--refresh Refresh the public catalog; cached items remain usable offline
|
|
14
|
+
`
|
|
15
|
+
|
|
16
|
+
export async function runHubCli(argv: string[], dependencies: { home?: string; write?: (text: string) => void; createHarness?: (options: HarnessOptions) => Harness } = {}): Promise<number> {
|
|
17
|
+
const write = dependencies.write ?? ((text: string) => void process.stdout.write(text))
|
|
18
|
+
const args: string[] = []
|
|
19
|
+
const values: Record<string, string> = {}
|
|
20
|
+
let refresh = false
|
|
21
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
22
|
+
const arg = argv[index]
|
|
23
|
+
if (arg === '--refresh') refresh = true
|
|
24
|
+
else if (arg === '--data' || arg === '--state') {
|
|
25
|
+
const value = argv[++index]
|
|
26
|
+
if (!value || value.startsWith('--')) { write(`${arg} needs a directory.\n`); return 2 }
|
|
27
|
+
values[arg.slice(2)] = resolve(value)
|
|
28
|
+
} else if (arg.startsWith('--')) { write(`Unknown option ${arg}.\n`); return 2 }
|
|
29
|
+
else args.push(arg)
|
|
30
|
+
}
|
|
31
|
+
const [command, target] = args
|
|
32
|
+
if (!command || command === 'help') { write(HUB_USAGE); return 0 }
|
|
33
|
+
if (!['list', 'show', 'install'].includes(command)) { write(HUB_USAGE); return 2 }
|
|
34
|
+
const root = join(dependencies.home ?? homedir(), '.xingular-harness')
|
|
35
|
+
const options: HarnessOptions = { dataRoot: values.data ?? join(root, 'data'), stateDir: values.state ?? join(root, 'state') }
|
|
36
|
+
const harness = dependencies.createHarness?.(options) ?? new Harness(options)
|
|
37
|
+
try {
|
|
38
|
+
const overview = await harness.hub.list(refresh)
|
|
39
|
+
if (overview.error) write(`Catalog refresh failed: ${overview.error} Using ${overview.origin} content.\n`)
|
|
40
|
+
if (command === 'list') {
|
|
41
|
+
write(`Library: ${options.dataRoot}\n${overview.entries.map((entry) => `${entry.id}@${entry.version} ${entry.category} ${entry.title}${overview.installed.some((receipt) => receipt.entry.id === entry.id && receipt.entry.version === entry.version) ? ' · installed' : ''}`).join('\n')}\n`)
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
44
|
+
const split = target?.lastIndexOf('@') ?? -1
|
|
45
|
+
if (!target || split < 1) throw new Error('Name an exact item as publisher/name@version. Run hub list to see versions.')
|
|
46
|
+
const id = target.slice(0, split)
|
|
47
|
+
const version = target.slice(split + 1)
|
|
48
|
+
const entry = await harness.hub.get(id, version)
|
|
49
|
+
if (command === 'show') {
|
|
50
|
+
const { bundled: _bundled, ...delivery } = entry.delivery.kind === 'pi-package' ? { ...entry.delivery, bundled: undefined } : entry.delivery
|
|
51
|
+
write(`${entry.title} (${target})\n${entry.description}\nPublisher: ${entry.publisher}\nLicense: ${entry.license}\nSource: ${entry.sourceUrl}\nDelivery: ${JSON.stringify(delivery)}\n`)
|
|
52
|
+
} else {
|
|
53
|
+
const receipt = await harness.hub.install(id, version)
|
|
54
|
+
write(receipt.document ? `Saved ${target} in ${options.dataRoot}/hub/documents/${receipt.document}. Open this library in a compatible application to apply it.\n` : `Installed ${target}, switched off. Attach it separately${entry.delivery.kind === 'pi-package' ? ' and review/trust any code' : ''} before using it.\n`)
|
|
55
|
+
}
|
|
56
|
+
return 0
|
|
57
|
+
} catch (error) { write(`${error instanceof Error ? error.message : String(error)}\n`); return 1 }
|
|
58
|
+
finally { await harness.close() }
|
|
59
|
+
}
|