@pellux/goodvibes-tui 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +363 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +79 -0
  141. package/src/runtime/services.ts +38 -37
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. package/src/runtime/render-scheduler.ts +0 -90
@@ -0,0 +1,167 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createHookDispatcher, createHookWorkbench, getHookPointContract } from '@pellux/goodvibes-sdk/platform/hooks';
3
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
4
+ import type { HookDefinition, HookEventPath, HookPointContract, HookType } from '@pellux/goodvibes-sdk/platform/hooks';
5
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Shared hooks validation core. The single source of truth for both
9
+ // `goodvibes hooks validate` (src/cli/hooks-command.ts) and `goodvibes doctor
10
+ // hooks` (src/cli/doctor.ts). There is NO second validator: field-level
11
+ // acceptance comes from running the SDK's own HookDispatcher.loadFromFile (the
12
+ // same loader the app uses at startup), and the "is this a real hook event
13
+ // point" verdict comes from the SDK's getHookPointContract. This module only
14
+ // reads and reports — it changes no config and fires no hooks.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Per-hook acceptance verdict, keyed to the on-disk position it was declared at. */
18
+ export interface HookCheck {
19
+ readonly pattern: string;
20
+ readonly index: number;
21
+ readonly name: string;
22
+ /** The declared hook type, when present and readable (command/prompt/agent/http/ts). */
23
+ readonly type: string;
24
+ readonly ok: boolean;
25
+ readonly reason?: string;
26
+ readonly contract?: HookPointContract | null;
27
+ }
28
+
29
+ /** The full validation result for one hooks file. */
30
+ export interface HooksValidation {
31
+ /** Absolute path the app resolves `tools.hooksFile` to — the "from where" of every hook. */
32
+ readonly path: string;
33
+ readonly present: boolean;
34
+ readonly valid: boolean;
35
+ /** A whole-file parse failure (invalid JSON / not an object). Present ⇒ no per-hook checks. */
36
+ readonly reason?: string;
37
+ readonly topLevelIssues: readonly string[];
38
+ readonly checks: readonly HookCheck[];
39
+ readonly chains: { readonly declared: number; readonly accepted: number };
40
+ readonly passCount: number;
41
+ readonly failCount: number;
42
+ }
43
+
44
+ export const LOADER_FIELD_REASON =
45
+ "the hooks loader skipped this entry — a hook needs a string \"match\" and a \"type\" of command, prompt, agent, http, or ts";
46
+
47
+ /** Build a consumable multiset of the loader-accepted definitions, keyed by pattern. */
48
+ function acceptedMultiset(accepted: Map<string, HookDefinition[]>): Map<string, Map<string, number>> {
49
+ const byPattern = new Map<string, Map<string, number>>();
50
+ for (const [pattern, defs] of accepted.entries()) {
51
+ const counts = new Map<string, number>();
52
+ for (const def of defs) {
53
+ const key = JSON.stringify(def);
54
+ counts.set(key, (counts.get(key) ?? 0) + 1);
55
+ }
56
+ byPattern.set(pattern, counts);
57
+ }
58
+ return byPattern;
59
+ }
60
+
61
+ /** True when this exact definition was accepted by the loader (consumes one match). */
62
+ function consumeAccepted(counts: Map<string, number> | undefined, def: unknown): boolean {
63
+ if (!counts) return false;
64
+ const key = JSON.stringify(def);
65
+ const remaining = counts.get(key);
66
+ if (remaining === undefined || remaining <= 0) return false;
67
+ counts.set(key, remaining - 1);
68
+ return true;
69
+ }
70
+
71
+ function readHookType(def: unknown): string {
72
+ if (def && typeof def === 'object' && typeof (def as { type?: unknown }).type === 'string') {
73
+ return (def as { type: string }).type;
74
+ }
75
+ return '(none)';
76
+ }
77
+
78
+ function readHookName(def: unknown): string {
79
+ if (def && typeof def === 'object' && typeof (def as { name?: unknown }).name === 'string') {
80
+ return (def as { name: string }).name;
81
+ }
82
+ return '(unnamed)';
83
+ }
84
+
85
+ /**
86
+ * Resolve the hooks file the same way the running app does (tools.hooksFile),
87
+ * load it through the real dispatcher, and report per-hook acceptance plus each
88
+ * hook's event-point contract. Empty/absent file ⇒ present=false, valid=true.
89
+ */
90
+ export function buildHooksValidation(configManager: Pick<ConfigManager, 'get' | 'getWorkingDirectory'>): HooksValidation {
91
+ const dispatcher = createHookDispatcher();
92
+ const workbench = createHookWorkbench({ hookDispatcher: dispatcher, configManager });
93
+ const path = workbench.getHooksFilePath();
94
+
95
+ if (!existsSync(path)) {
96
+ return { path, present: false, valid: true, topLevelIssues: [], checks: [], chains: { declared: 0, accepted: 0 }, passCount: 0, failCount: 0 };
97
+ }
98
+
99
+ let parsed: unknown;
100
+ try {
101
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
102
+ } catch (error) {
103
+ return { path, present: true, valid: false, reason: `hooks.json is not valid JSON — ${summarizeError(error)}`, topLevelIssues: [], checks: [], chains: { declared: 0, accepted: 0 }, passCount: 0, failCount: 1 };
104
+ }
105
+
106
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
107
+ return { path, present: true, valid: false, reason: 'hooks.json must be a JSON object with "hooks" and/or "chains".', topLevelIssues: [], checks: [], chains: { declared: 0, accepted: 0 }, passCount: 0, failCount: 1 };
108
+ }
109
+
110
+ const record = parsed as { hooks?: unknown; chains?: unknown };
111
+ const topLevelIssues: string[] = [];
112
+
113
+ // Run the real loader once so field-level acceptance mirrors runtime behavior.
114
+ dispatcher.loadFromFile(path);
115
+ const accepted = acceptedMultiset(dispatcher.getHooks());
116
+
117
+ const checks: HookCheck[] = [];
118
+ if (record.hooks !== undefined) {
119
+ if (typeof record.hooks !== 'object' || record.hooks === null || Array.isArray(record.hooks)) {
120
+ topLevelIssues.push('the "hooks" field must be an object keyed by event-path pattern.');
121
+ } else {
122
+ for (const [pattern, defs] of Object.entries(record.hooks as Record<string, unknown>)) {
123
+ const contract = getHookPointContract(pattern as HookEventPath);
124
+ if (!Array.isArray(defs)) {
125
+ checks.push({ pattern, index: 0, name: '(all)', type: '(none)', ok: false, contract, reason: `the value for "${pattern}" must be an array of hook definitions.` });
126
+ continue;
127
+ }
128
+ const counts = accepted.get(pattern);
129
+ defs.forEach((def, index) => {
130
+ const loaderAccepted = consumeAccepted(counts, def);
131
+ let ok = true;
132
+ let reason: string | undefined;
133
+ if (!loaderAccepted) {
134
+ ok = false;
135
+ reason = LOADER_FIELD_REASON;
136
+ } else if (contract === null) {
137
+ ok = false;
138
+ reason = `"${pattern}" is not a recognized hook event point (no matching hook contract).`;
139
+ }
140
+ checks.push({ pattern, index, name: readHookName(def), type: readHookType(def), ok, contract, ...(reason ? { reason } : {}) });
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ const declaredChains = Array.isArray(record.chains) ? record.chains.length : 0;
147
+ if (record.chains !== undefined && !Array.isArray(record.chains)) {
148
+ topLevelIssues.push('the "chains" field must be an array.');
149
+ }
150
+ const acceptedChains = dispatcher.getChains().length;
151
+
152
+ const failCount = checks.filter((c) => !c.ok).length + topLevelIssues.length;
153
+ const passCount = checks.filter((c) => c.ok).length;
154
+
155
+ return {
156
+ path,
157
+ present: true,
158
+ valid: failCount === 0,
159
+ topLevelIssues,
160
+ checks,
161
+ chains: { declared: declaredChains, accepted: acceptedChains },
162
+ passCount,
163
+ failCount,
164
+ };
165
+ }
166
+
167
+ export type { HookType };
package/src/cli/index.ts CHANGED
@@ -8,4 +8,7 @@ export * from './endpoints.ts';
8
8
  export * from './surface-command.ts';
9
9
  export * from './service-command.ts';
10
10
  export * from './bundle-command.ts';
11
+ export * from './hooks-command.ts';
12
+ export * from './doctor.ts';
13
+ export * from './plugin-command.ts';
11
14
  export * from './management.ts';
@@ -21,6 +21,8 @@ import { applyRuntimeEndpointFlagOverrides } from './config-overrides.ts';
21
21
  import type { RuntimeEndpointId } from './endpoints.ts';
22
22
  import { handleServiceCommand } from './service-command.ts';
23
23
  import { handleBundleCommand } from './bundle-command.ts';
24
+ import { handleHooksCommand } from './hooks-command.ts';
25
+ import { handlePluginCommand } from './plugin-command.ts';
24
26
  import { buildListenerTestResult, formatListenerTestResult, handleSurfacesCommand } from './surface-command.ts';
25
27
  import { buildControlPlaneStatusResult, formatControlPlaneStatus, handleSecrets, handleSessions, handleTasks, renderPairing, renderRemote, renderSubscriptions, renderWeb } from './management-commands.ts';
26
28
  import {
@@ -487,11 +489,21 @@ export async function handleGoodVibesCliCommand(runtime: CliCommandRuntime): Pro
487
489
  case 'pair':
488
490
  console.log(await renderPairing(runtime));
489
491
  return { handled: true, exitCode: 0 };
490
- case 'bundle': {
492
+ case 'support-bundle': {
491
493
  const result = await handleBundleCommand(runtime);
492
494
  console.log(result.output);
493
495
  return { handled: true, exitCode: result.exitCode };
494
496
  }
497
+ case 'hooks': {
498
+ const result = await handleHooksCommand(runtime);
499
+ console.log(result.output);
500
+ return { handled: true, exitCode: result.exitCode };
501
+ }
502
+ case 'plugin': {
503
+ const result = await handlePluginCommand(runtime);
504
+ console.log(result.output);
505
+ return { handled: true, exitCode: result.exitCode };
506
+ }
495
507
  case 'remote':
496
508
  console.log(await renderRemote(runtime, 'remote'));
497
509
  return { handled: true, exitCode: 0 };
package/src/cli/parser.ts CHANGED
@@ -45,10 +45,18 @@ const COMMAND_ALIASES: Readonly<Record<string, GoodVibesCliCommand>> = {
45
45
  'control-plane': 'control-plane',
46
46
  controlplane: 'control-plane',
47
47
  cp: 'control-plane',
48
- bundle: 'bundle',
49
- bundles: 'bundle',
48
+ 'support-bundle': 'support-bundle',
49
+ // `bundle`/`bundles` are retained as backward-compat aliases for the shipped
50
+ // command word; the primary, unambiguous name is `support-bundle` (distinct
51
+ // from the `plugin bundles` capability-bundle surface).
52
+ bundle: 'support-bundle',
53
+ bundles: 'support-bundle',
50
54
  remote: 'remote',
51
55
  bridge: 'bridge',
56
+ hooks: 'hooks',
57
+ hook: 'hooks',
58
+ plugin: 'plugin',
59
+ plugins: 'plugin',
52
60
  completion: 'completion',
53
61
  completions: 'completion',
54
62
  help: 'help',
@@ -0,0 +1,228 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import {
4
+ parseMarketplaceIndex,
5
+ fetchAndVerifyBundle,
6
+ validateCapabilityBundleManifest,
7
+ planBundleActivation,
8
+ BundlePinRefusal,
9
+ type PinnedMarketplaceIndexEntry,
10
+ type CapabilityBundleManifest,
11
+ type BundleActivationPlan,
12
+ } from '@pellux/goodvibes-sdk/platform/runtime/ecosystem';
13
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
14
+ import type { CliCommandOutput, CliCommandRuntime } from './types.ts';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // `goodvibes plugin bundles browse|install|list` — the capability-bundle
18
+ // marketplace surface. Extends the existing plugin-command.ts family with the
19
+ // SDK's SHA-256-pinned bundle distribution (platform/runtime/ecosystem):
20
+ // parseMarketplaceIndex, fetchAndVerifyBundle, planBundleActivation. There is
21
+ // deliberately NO flag that bypasses a pin mismatch — `install` either
22
+ // verifies or refuses; `--yes` only confirms an ALREADY-verified activation
23
+ // plan, it never substitutes for the pin.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ interface InstalledBundleRecord {
27
+ readonly id: string;
28
+ readonly name: string;
29
+ readonly version: string;
30
+ readonly kind: CapabilityBundleManifest['kind'];
31
+ readonly source: { readonly kind: 'file' | 'url'; readonly location: string; readonly sha256: string };
32
+ readonly capabilities: BundleActivationPlan['capabilityManifest'];
33
+ readonly quarantine: BundleActivationPlan['quarantine'];
34
+ readonly installedAt: number;
35
+ }
36
+
37
+ function installedBundlesPath(runtime: CliCommandRuntime): string {
38
+ return join(runtime.homeDirectory, '.goodvibes', 'bundles', 'installed.json');
39
+ }
40
+
41
+ function readInstalledBundles(runtime: CliCommandRuntime): InstalledBundleRecord[] {
42
+ const path = installedBundlesPath(runtime);
43
+ if (!existsSync(path)) return [];
44
+ try {
45
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
46
+ return Array.isArray(parsed) ? (parsed as InstalledBundleRecord[]) : [];
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ function writeInstalledBundles(runtime: CliCommandRuntime, records: readonly InstalledBundleRecord[]): void {
53
+ const path = installedBundlesPath(runtime);
54
+ mkdirSync(dirname(path), { recursive: true });
55
+ writeFileSync(path, `${JSON.stringify(records, null, 2)}\n`, 'utf-8');
56
+ }
57
+
58
+ function formatJsonOrText(runtime: CliCommandRuntime, value: unknown, text: string): string {
59
+ return runtime.cli.flags.outputFormat === 'json' ? JSON.stringify(value, null, 2) : text;
60
+ }
61
+
62
+ /** file:// / bare-path vs http(s):// — the only two source kinds this CLI surface accepts (no git ref flag exists yet). */
63
+ function inferSourceKind(ref: string): 'file' | 'url' {
64
+ return ref.startsWith('http://') || ref.startsWith('https://') ? 'url' : 'file';
65
+ }
66
+
67
+ async function readIndexBytes(ref: string): Promise<string> {
68
+ if (inferSourceKind(ref) === 'url') {
69
+ const res = await fetch(ref);
70
+ if (!res.ok) throw new Error(`URL '${ref}' returned HTTP ${res.status}`);
71
+ return res.text();
72
+ }
73
+ return readFileSync(ref, 'utf-8');
74
+ }
75
+
76
+ function summarizeCapabilities(summary: PinnedMarketplaceIndexEntry['capabilities']): string {
77
+ const parts = [`tools:${summary.toolCount}`, `hooks:${summary.hookCount}`, `configDomains:${summary.configDomainCount}`, `channels:${summary.channelCount}`];
78
+ if (summary.runtime.length > 0) parts.push(`runtime:${summary.runtime.join('+')}`);
79
+ if (summary.highRisk) parts.push('HIGH-RISK');
80
+ return parts.join(' ');
81
+ }
82
+
83
+ async function handleBrowse(runtime: CliCommandRuntime, ref: string | undefined): Promise<CliCommandOutput> {
84
+ if (!ref) return { output: 'Usage: goodvibes plugin bundles browse <index-url-or-file>', exitCode: 2 };
85
+ let raw: string;
86
+ try {
87
+ raw = await readIndexBytes(ref);
88
+ } catch (error) {
89
+ return { output: `Could not read marketplace index '${ref}': ${summarizeError(error)}`, exitCode: 1 };
90
+ }
91
+ let value: unknown;
92
+ try {
93
+ value = JSON.parse(raw);
94
+ } catch (error) {
95
+ return { output: `Marketplace index at '${ref}' is not valid JSON: ${summarizeError(error)}`, exitCode: 1 };
96
+ }
97
+ const result = parseMarketplaceIndex(value);
98
+ if (!result.ok) {
99
+ return {
100
+ output: formatJsonOrText(runtime, result, [
101
+ `Marketplace index at '${ref}' was refused (a governed index requires a SHA-256 pin + capability summary on every entry):`,
102
+ ...result.errors.map((e) => ` - ${e}`),
103
+ ].join('\n')),
104
+ exitCode: 1,
105
+ };
106
+ }
107
+ const { bundles } = result.index;
108
+ if (bundles.length === 0) {
109
+ return { output: formatJsonOrText(runtime, result.index, `Marketplace index at '${ref}': no bundles listed.`), exitCode: 0 };
110
+ }
111
+ const lines = [`Marketplace index at '${ref}' (${bundles.length} bundle${bundles.length === 1 ? '' : 's'}):`];
112
+ for (const entry of bundles) {
113
+ lines.push(
114
+ ` ${entry.id} — ${entry.name} v${entry.version} (${entry.kind})`,
115
+ ` ${entry.summary}`,
116
+ ` capabilities: ${summarizeCapabilities(entry.capabilities)}`,
117
+ ` pin: sha256:${entry.source.sha256} (${entry.source.kind}: ${entry.source.location})`,
118
+ );
119
+ }
120
+ return { output: formatJsonOrText(runtime, result.index, lines.join('\n')), exitCode: 0 };
121
+ }
122
+
123
+ /** Render an activation plan (granted capabilities, withheld/quarantined ones) for confirmation. */
124
+ function renderPlan(plan: BundleActivationPlan, committed: boolean): string {
125
+ const lines = [
126
+ `${committed ? 'Installed' : 'Activation plan (preview — re-run with --yes to install)'}: ${plan.manifest.id} v${plan.manifest.version} (${plan.manifest.kind})`,
127
+ ` trust tier: ${plan.trustTier}`,
128
+ ` declared runtime capabilities: ${plan.manifest.capabilities.runtime.join(', ') || 'none'}`,
129
+ ` declared tools: ${plan.manifest.capabilities.tools.join(', ') || 'none'}`,
130
+ ` declared hooks: ${plan.manifest.capabilities.hooks.join(', ') || 'none'}`,
131
+ ` declared configDomains: ${plan.manifest.capabilities.configDomains.join(', ') || 'none'}`,
132
+ ` declared channels: ${plan.manifest.capabilities.channels.join(', ') || 'none'}`,
133
+ ` granted capabilities: ${plan.capabilityManifest.granted.join(', ') || 'none'}`,
134
+ ];
135
+ if (plan.quarantine.required) {
136
+ lines.push(
137
+ ` QUARANTINED: yes — ${plan.quarantine.reason ?? 'over-reached its trust tier'}`,
138
+ ` withheld capabilities: ${plan.quarantine.revokedCapabilities.join(', ')}`,
139
+ );
140
+ } else {
141
+ lines.push(' quarantined: no — every declared capability fit the trust tier');
142
+ }
143
+ return lines.join('\n');
144
+ }
145
+
146
+ async function handleInstall(runtime: CliCommandRuntime, ref: string | undefined, sha256: string | undefined): Promise<CliCommandOutput> {
147
+ if (!ref || !sha256) {
148
+ return { output: 'Usage: goodvibes plugin bundles install <ref> --sha256 <pin> (the pin is required — there is no unpinned install path)', exitCode: 2 };
149
+ }
150
+ const kind = inferSourceKind(ref);
151
+ const source = { kind, location: ref, sha256 } as const;
152
+ let bytes: Uint8Array;
153
+ try {
154
+ const fetched = await fetchAndVerifyBundle(source);
155
+ bytes = fetched.bytes;
156
+ } catch (error) {
157
+ if (error instanceof BundlePinRefusal) {
158
+ return {
159
+ output: formatJsonOrText(runtime, { ok: false, source, verification: error.verification }, `Refused: ${error.message}`),
160
+ exitCode: 1,
161
+ };
162
+ }
163
+ return { output: `Could not fetch bundle from '${ref}': ${summarizeError(error)}`, exitCode: 1 };
164
+ }
165
+ let parsed: unknown;
166
+ try {
167
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
168
+ } catch (error) {
169
+ return { output: `Bundle at '${ref}' is not valid JSON: ${summarizeError(error)}`, exitCode: 1 };
170
+ }
171
+ const validation = validateCapabilityBundleManifest(parsed);
172
+ if (!validation.ok) {
173
+ return {
174
+ output: formatJsonOrText(runtime, validation, [
175
+ `Bundle manifest at '${ref}' was refused:`,
176
+ ...validation.errors.map((e) => ` - ${e}`),
177
+ ].join('\n')),
178
+ exitCode: 1,
179
+ };
180
+ }
181
+ const plan = planBundleActivation(validation.manifest);
182
+ const committed = runtime.cli.flags.yes;
183
+ if (committed) {
184
+ const records = readInstalledBundles(runtime).filter((r) => r.id !== plan.manifest.id);
185
+ records.push({
186
+ id: plan.manifest.id,
187
+ name: plan.manifest.name,
188
+ version: plan.manifest.version,
189
+ kind: plan.manifest.kind,
190
+ source,
191
+ capabilities: plan.capabilityManifest,
192
+ quarantine: plan.quarantine,
193
+ installedAt: Date.now(),
194
+ });
195
+ writeInstalledBundles(runtime, records);
196
+ }
197
+ return { output: formatJsonOrText(runtime, { ok: true, committed, plan }, renderPlan(plan, committed)), exitCode: 0 };
198
+ }
199
+
200
+ function handleList(runtime: CliCommandRuntime): CliCommandOutput {
201
+ const records = readInstalledBundles(runtime);
202
+ if (records.length === 0) {
203
+ return { output: formatJsonOrText(runtime, records, 'No capability bundles installed.'), exitCode: 0 };
204
+ }
205
+ const lines = [`Installed capability bundles (${records.length}):`];
206
+ for (const record of records) {
207
+ lines.push(
208
+ ` ${record.id} — ${record.name} v${record.version} (${record.kind})`,
209
+ ` installedAt: ${new Date(record.installedAt).toISOString()}`,
210
+ ` source: ${record.source.kind}:${record.source.location} (sha256:${record.source.sha256})`,
211
+ ` quarantined: ${record.quarantine.required ? `yes — ${record.quarantine.reason ?? 'over-reached its trust tier'}` : 'no'}`,
212
+ );
213
+ }
214
+ return { output: formatJsonOrText(runtime, records, lines.join('\n')), exitCode: 0 };
215
+ }
216
+
217
+ export async function handlePluginBundlesCommand(runtime: CliCommandRuntime, args: readonly string[]): Promise<CliCommandOutput> {
218
+ const [sub, ...rest] = args;
219
+ if (sub === 'browse') return handleBrowse(runtime, rest[0]);
220
+ if (sub === 'install') {
221
+ const sha256Index = rest.indexOf('--sha256');
222
+ const sha256 = sha256Index >= 0 ? rest[sha256Index + 1] : undefined;
223
+ const ref = rest.find((token, i) => (sha256Index < 0 || (i !== sha256Index && i !== sha256Index + 1)) && !token.startsWith('-'));
224
+ return handleInstall(runtime, ref, sha256);
225
+ }
226
+ if (sub === 'list') return handleList(runtime);
227
+ return { output: 'Usage: goodvibes plugin bundles browse <index-url-or-file>|install <ref> --sha256 <pin>|list', exitCode: 2 };
228
+ }
@@ -0,0 +1,179 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { discoverPlugins } from '../plugins/loader.ts';
5
+ import { validateManifestV2 } from '@/runtime/index.ts';
6
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
7
+ import type { CliCommandOutput, CliCommandRuntime } from './types.ts';
8
+ import { handlePluginBundlesCommand } from './plugin-bundles-command.ts';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // `goodvibes plugin init` / `plugin validate` — scaffold and check plugins
12
+ // against the SAME classic loader the running TUI uses (PluginManager wraps
13
+ // discoverPlugins from @pellux/goodvibes-sdk/platform/plugins). The pass/fail
14
+ // verdict is the real loader's acceptance: we run discoverPlugins against the
15
+ // target directory (isolated from the operator's own plugin folders) and check
16
+ // whether it accepts the directory. Reasons for a rejected manifest are sourced
17
+ // from the SDK's validateManifestV2 (string|null) plus observable file facts —
18
+ // no second manifest schema is defined here.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ interface PluginValidation {
22
+ readonly ok: boolean;
23
+ readonly path: string;
24
+ readonly reasons: readonly string[];
25
+ readonly manifestName?: string;
26
+ readonly manifestVersion?: string;
27
+ }
28
+
29
+ /** A base directory whose standard plugin folders do not exist, so discoverPlugins
30
+ * only considers the target's parent (passed via additionalDirectories). */
31
+ function isolatedBase(): string {
32
+ return join(tmpdir(), '__goodvibes_plugin_validate_isolate__');
33
+ }
34
+
35
+ function validatePluginDirectory(dirPath: string): PluginValidation {
36
+ const resolved = resolve(dirPath);
37
+ if (!existsSync(resolved)) {
38
+ return { ok: false, path: resolved, reasons: ['directory not found.'] };
39
+ }
40
+ const manifestPath = join(resolved, 'manifest.json');
41
+
42
+ // Authoritative verdict: the real classic loader's discovery scan.
43
+ const base = isolatedBase();
44
+ const discovered = discoverPlugins({
45
+ cwd: base,
46
+ homeDir: base,
47
+ additionalDirectories: [dirname(resolved)],
48
+ });
49
+ const match = discovered.find((plugin) => resolve(plugin.pluginDir) === resolved);
50
+ if (match) {
51
+ return {
52
+ ok: true,
53
+ path: resolved,
54
+ reasons: [],
55
+ manifestName: match.manifest.name,
56
+ manifestVersion: match.manifest.version,
57
+ };
58
+ }
59
+
60
+ // Rejected — explain why, delegating field checks to the SDK validator.
61
+ const reasons: string[] = [];
62
+ if (!existsSync(manifestPath)) {
63
+ reasons.push('manifest.json not found in the plugin directory.');
64
+ return { ok: false, path: resolved, reasons };
65
+ }
66
+ let manifest: unknown;
67
+ try {
68
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
69
+ } catch (error) {
70
+ reasons.push(`manifest.json is not valid JSON — ${summarizeError(error)}`);
71
+ return { ok: false, path: resolved, reasons };
72
+ }
73
+ const fieldReason = validateManifestV2(manifest);
74
+ if (fieldReason) {
75
+ reasons.push(fieldReason);
76
+ } else {
77
+ reasons.push('the plugin loader did not accept this directory — if "main" is set it must be a relative path.');
78
+ }
79
+ return { ok: false, path: resolved, reasons };
80
+ }
81
+
82
+ function renderValidation(result: PluginValidation, json: boolean): CliCommandOutput {
83
+ if (json) {
84
+ return { output: JSON.stringify(result, null, 2), exitCode: result.ok ? 0 : 1 };
85
+ }
86
+ const lines = ['GoodVibes plugin validation', ` path: ${result.path}`];
87
+ if (result.ok) {
88
+ lines.push(` PASS ${result.manifestName} v${result.manifestVersion} — the plugin loader accepts this directory.`);
89
+ } else {
90
+ lines.push(' FAIL the plugin loader rejects this directory:');
91
+ for (const reason of result.reasons) lines.push(` - ${reason}`);
92
+ }
93
+ return { output: lines.join('\n'), exitCode: result.ok ? 0 : 1 };
94
+ }
95
+
96
+ const ENTRY_FILE_CONTENTS = `// Minimal GoodVibes plugin entry point.
97
+ // The loader calls init(api) once after the plugin is loaded.
98
+ export function init(api) {
99
+ // Register commands, tools, or hooks through the sandboxed api here.
100
+ void api;
101
+ }
102
+ `;
103
+
104
+ function handlePluginInit(runtime: CliCommandRuntime): CliCommandOutput {
105
+ const json = runtime.cli.flags.outputFormat === 'json';
106
+ const name = runtime.cli.commandArgs[1];
107
+ if (!name) {
108
+ return { output: 'Usage: goodvibes plugin init <name> [directory]', exitCode: 2 };
109
+ }
110
+ const dirArg = runtime.cli.commandArgs[2];
111
+ const targetDir = dirArg
112
+ ? resolve(dirArg)
113
+ : join(runtime.workingDirectory, '.goodvibes', 'plugins', name);
114
+ const manifestPath = join(targetDir, 'manifest.json');
115
+ if (existsSync(manifestPath)) {
116
+ return {
117
+ output: json
118
+ ? JSON.stringify({ ok: false, path: targetDir, reason: 'a plugin already exists here' }, null, 2)
119
+ : `A plugin already exists at ${targetDir} (manifest.json present). Choose another name or directory.`,
120
+ exitCode: 1,
121
+ };
122
+ }
123
+
124
+ const manifest = {
125
+ name,
126
+ version: '0.1.0',
127
+ description: `${name} plugin`,
128
+ main: 'index.js',
129
+ };
130
+ try {
131
+ mkdirSync(targetDir, { recursive: true });
132
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8');
133
+ writeFileSync(join(targetDir, 'index.js'), ENTRY_FILE_CONTENTS, 'utf-8');
134
+ } catch (error) {
135
+ return {
136
+ output: json
137
+ ? JSON.stringify({ ok: false, path: targetDir, reason: summarizeError(error) }, null, 2)
138
+ : `Failed to scaffold plugin at ${targetDir} — ${summarizeError(error)}`,
139
+ exitCode: 1,
140
+ };
141
+ }
142
+
143
+ // Prove the freshly scaffolded plugin passes our own validation path.
144
+ const validation = validatePluginDirectory(targetDir);
145
+ if (json) {
146
+ return {
147
+ output: JSON.stringify({ ok: validation.ok, path: targetDir, created: ['manifest.json', 'index.js'], validation }, null, 2),
148
+ exitCode: validation.ok ? 0 : 1,
149
+ };
150
+ }
151
+ const lines = [
152
+ `Scaffolded plugin "${name}" at ${targetDir}`,
153
+ ' created: manifest.json, index.js',
154
+ validation.ok
155
+ ? ' validation: PASS — the plugin loader accepts this directory.'
156
+ : ` validation: FAIL — ${validation.reasons.join('; ')}`,
157
+ ` next: run "goodvibes plugin validate ${targetDir}" or restart the TUI to load it.`,
158
+ ];
159
+ return { output: lines.join('\n'), exitCode: validation.ok ? 0 : 1 };
160
+ }
161
+
162
+ export async function handlePluginCommand(runtime: CliCommandRuntime): Promise<CliCommandOutput> {
163
+ const json = runtime.cli.flags.outputFormat === 'json';
164
+ const [sub] = runtime.cli.commandArgs;
165
+ if (sub === 'init') {
166
+ return handlePluginInit(runtime);
167
+ }
168
+ if (sub === 'validate') {
169
+ const target = runtime.cli.commandArgs[1];
170
+ if (!target) {
171
+ return { output: 'Usage: goodvibes plugin validate <path>', exitCode: 2 };
172
+ }
173
+ return renderValidation(validatePluginDirectory(target), json);
174
+ }
175
+ if (sub === 'bundles') {
176
+ return handlePluginBundlesCommand(runtime, runtime.cli.commandArgs.slice(1));
177
+ }
178
+ return { output: 'Usage: goodvibes plugin [init <name> [directory]|validate <path>|bundles browse|install|list]', exitCode: 2 };
179
+ }
@@ -62,6 +62,16 @@ const ENDPOINTS: readonly { readonly id: RuntimeEndpointId; readonly label: stri
62
62
 
63
63
  const SOURCE_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
64
64
 
65
+ /**
66
+ * The packaged root of this install (the directory that owns `vendor/`,
67
+ * `dist/`, and `bin/`), resolved the same way the daemon executable search
68
+ * resolves its package paths. Exposed so the install self-check can look for
69
+ * the vendored release binaries without re-deriving this path.
70
+ */
71
+ export function getGoodVibesPackageRoot(): string {
72
+ return SOURCE_PACKAGE_ROOT;
73
+ }
74
+
65
75
  export interface DaemonExecutableResolutionOptions {
66
76
  readonly env?: NodeJS.ProcessEnv;
67
77
  readonly argv?: readonly string[];