@north-light/crouter 0.3.164 → 0.3.165

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 (155) hide show
  1. package/dist/build-root.d.ts +6 -6
  2. package/dist/build-root.js +12 -13
  3. package/dist/builtin-memory/00-runtime-base.md +3 -4
  4. package/dist/builtin-memory/01-spine/01-no-manager.md +1 -1
  5. package/dist/builtin-memory/02-lifecycle/00-terminal.md +2 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +16 -18
  7. package/dist/builtin-memory/05-kinds/advisor/00-base.md +0 -2
  8. package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +3 -9
  9. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -3
  10. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -10
  11. package/dist/builtin-memory/05-kinds/explore/00-base.md +2 -2
  12. package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +0 -2
  13. package/dist/builtin-memory/05-kinds/general/00-base.md +3 -5
  14. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -4
  15. package/dist/builtin-memory/05-kinds/plan/01-orchestrator.md +1 -3
  16. package/dist/builtin-memory/05-kinds/plan/reviewers/00-base.md +12 -0
  17. package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +0 -2
  18. package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +0 -2
  19. package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +0 -2
  20. package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +1 -1
  21. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +0 -2
  22. package/dist/builtin-memory/05-kinds/review/00-base.md +0 -2
  23. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -3
  24. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  25. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +2 -4
  26. package/dist/builtin-memory/05-kinds/spec/requirements.md +1 -1
  27. package/dist/builtin-memory/advisor/council.md +40 -0
  28. package/dist/builtin-memory/design.md +8 -11
  29. package/dist/builtin-memory/development.md +6 -9
  30. package/dist/builtin-memory/internal/INDEX.md +4 -5
  31. package/dist/builtin-memory/internal/agent-shaping.md +5 -7
  32. package/dist/builtin-memory/internal/examples/imessage-assistant.md +3 -3
  33. package/dist/builtin-memory/internal/marketplaces.md +13 -14
  34. package/dist/builtin-memory/internal/nodes-and-canvas.md +4 -4
  35. package/dist/builtin-memory/internal/plugins.md +61 -45
  36. package/dist/builtin-memory/planning.md +4 -7
  37. package/dist/builtin-memory/spec.md +9 -12
  38. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +8 -22
  39. package/dist/cli.js +1 -1
  40. package/dist/clients/attach/__tests__/attach-keybindings.test.js +17 -4
  41. package/dist/clients/attach/__tests__/context-message.test.js +31 -20
  42. package/dist/clients/attach/__tests__/crtr-output.test.js +39 -30
  43. package/dist/clients/attach/render/chat-view.d.ts +20 -20
  44. package/dist/clients/attach/render/chat-view.js +42 -54
  45. package/dist/clients/attach/render/{frozen-history.d.ts → condensed-history.d.ts} +1 -18
  46. package/dist/clients/attach/render/condensed-history.js +60 -0
  47. package/dist/clients/attach/render/context-message.js +9 -14
  48. package/dist/clients/attach/session/bindings.d.ts +5 -3
  49. package/dist/clients/attach/session/bindings.js +7 -13
  50. package/dist/clients/attach/session/keys.d.ts +2 -0
  51. package/dist/clients/attach/session/keys.js +36 -37
  52. package/dist/clients/attach/session/profile-files.d.ts +8 -0
  53. package/dist/clients/attach/session/profile-files.js +157 -0
  54. package/dist/clients/attach/viewer.js +525 -525
  55. package/dist/commands/node.js +2 -2
  56. package/dist/commands/pkg/plugin-inspect.js +6 -7
  57. package/dist/commands/pkg/plugin-manage.d.ts +1 -1
  58. package/dist/commands/pkg/plugin-manage.js +131 -19
  59. package/dist/commands/pkg/plugin.js +2 -2
  60. package/dist/commands/pkg.js +6 -11
  61. package/dist/commands/profile/env.js +3 -3
  62. package/dist/commands/sys/config.js +17 -76
  63. package/dist/commands/sys/doctor.js +5 -91
  64. package/dist/commands/sys/setup-wizard.d.ts +9 -11
  65. package/dist/commands/sys/setup-wizard.js +47 -81
  66. package/dist/core/__tests__/base-worker-prompt.test.js +18 -21
  67. package/dist/core/__tests__/command-plugins-surfaces.test.js +39 -5
  68. package/dist/core/__tests__/command-plugins.test.js +36 -16
  69. package/dist/core/__tests__/review-model-floor.test.js +2 -2
  70. package/dist/core/__tests__/tmux-surface.test.js +10 -1
  71. package/dist/core/command-manifests/manifest.d.ts +24 -0
  72. package/dist/core/{configured-clis → command-manifests}/manifest.js +21 -25
  73. package/dist/core/command-manifests/registry.d.ts +1 -2
  74. package/dist/core/command-manifests/registry.js +2 -2
  75. package/dist/core/command-manifests/schema.d.ts +11 -13
  76. package/dist/core/command-manifests/schema.js +53 -192
  77. package/dist/core/command-plugins/compose.d.ts +0 -6
  78. package/dist/core/command-plugins/compose.js +28 -75
  79. package/dist/core/command-plugins/discovery.d.ts +25 -58
  80. package/dist/core/command-plugins/discovery.js +152 -259
  81. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  82. package/dist/core/command-plugins/endpoint.js +48 -0
  83. package/dist/core/command-plugins/store.d.ts +16 -0
  84. package/dist/core/command-plugins/store.js +64 -0
  85. package/dist/core/command-plugins/{adapter.d.ts → transport/exec-invoke.d.ts} +3 -3
  86. package/dist/core/command-plugins/{adapter.js → transport/exec-invoke.js} +4 -4
  87. package/dist/core/{configured-clis/fetch.d.ts → command-plugins/transport/http-fetch.d.ts} +9 -18
  88. package/dist/core/{configured-clis/fetch.js → command-plugins/transport/http-fetch.js} +15 -35
  89. package/dist/core/{configured-clis/invoker.d.ts → command-plugins/transport/http-invoke.d.ts} +7 -7
  90. package/dist/core/{configured-clis/invoker.js → command-plugins/transport/http-invoke.js} +21 -23
  91. package/dist/core/command.d.ts +8 -9
  92. package/dist/core/command.js +6 -9
  93. package/dist/core/config.js +6 -10
  94. package/dist/core/env-name.d.ts +6 -0
  95. package/dist/core/env-name.js +9 -0
  96. package/dist/core/io.d.ts +1 -1
  97. package/dist/core/keybindings/__tests__/resolve.test.js +40 -3
  98. package/dist/core/keybindings/attach-control.d.ts +37 -0
  99. package/dist/core/keybindings/attach-control.js +38 -0
  100. package/dist/core/keybindings/catalog.d.ts +5 -4
  101. package/dist/core/keybindings/catalog.js +16 -8
  102. package/dist/core/keybindings/index.d.ts +1 -0
  103. package/dist/core/keybindings/index.js +1 -0
  104. package/dist/core/keybindings/types.d.ts +1 -1
  105. package/dist/core/preview-registry.js +41 -74
  106. package/dist/core/runtime/bearings.d.ts +2 -5
  107. package/dist/core/runtime/bearings.js +2 -5
  108. package/dist/core/runtime/front-door.d.ts +1 -1
  109. package/dist/core/runtime/front-door.js +2 -2
  110. package/dist/core/runtime/kickoff.d.ts +3 -3
  111. package/dist/core/runtime/kickoff.js +4 -3
  112. package/dist/core/runtime/situational-context.d.ts +1 -1
  113. package/dist/core/runtime/situational-context.js +1 -1
  114. package/dist/core/runtime/spawn.js +9 -9
  115. package/dist/core/runtime/tmux.js +29 -2
  116. package/dist/core/scope.d.ts +0 -5
  117. package/dist/core/scope.js +0 -10
  118. package/dist/core/user-settings.d.ts +193 -0
  119. package/dist/core/user-settings.js +252 -0
  120. package/dist/index.d.ts +4 -0
  121. package/dist/index.js +3 -0
  122. package/dist/pi-extensions/canvas-stophook.js +3 -2
  123. package/dist/shared/generated-context.d.ts +34 -0
  124. package/dist/shared/generated-context.js +98 -0
  125. package/dist/types.d.ts +23 -16
  126. package/dist/types.js +3 -14
  127. package/dist/web-client/assets/{index-NIuSCOHM.js → index-BMTOGuOZ.js} +19 -19
  128. package/dist/web-client/assets/index-DvA6Zw-R.css +2 -0
  129. package/dist/web-client/index.html +2 -2
  130. package/dist/web-client/sw.js +1 -1
  131. package/docs/public-api.md +1 -0
  132. package/package.json +1 -1
  133. package/runtime.lock.json +2 -2
  134. package/dist/builtin-memory/05-kinds/product/00-base.md +0 -25
  135. package/dist/builtin-memory/05-kinds/product/01-orchestrator.md +0 -15
  136. package/dist/builtin-memory/05-kinds/product/teardown.md +0 -15
  137. package/dist/builtin-memory/internal/workflow-codification.md +0 -82
  138. package/dist/builtin-memory/product.md +0 -80
  139. package/dist/clients/attach/render/frozen-history.js +0 -100
  140. package/dist/commands/pkg/cli-inspect.d.ts +0 -17
  141. package/dist/commands/pkg/cli-inspect.js +0 -190
  142. package/dist/commands/pkg/cli-manage.d.ts +0 -3
  143. package/dist/commands/pkg/cli-manage.js +0 -206
  144. package/dist/commands/pkg/cli.d.ts +0 -1
  145. package/dist/commands/pkg/cli.js +0 -14
  146. package/dist/core/configured-clis/cache.d.ts +0 -16
  147. package/dist/core/configured-clis/cache.js +0 -57
  148. package/dist/core/configured-clis/compose.d.ts +0 -14
  149. package/dist/core/configured-clis/compose.js +0 -60
  150. package/dist/core/configured-clis/discovery.d.ts +0 -47
  151. package/dist/core/configured-clis/discovery.js +0 -173
  152. package/dist/core/configured-clis/manifest.d.ts +0 -24
  153. package/dist/core/configured-clis/registration.d.ts +0 -40
  154. package/dist/core/configured-clis/registration.js +0 -201
  155. package/dist/web-client/assets/index-CqLKj8Xu.css +0 -2
@@ -1,291 +1,184 @@
1
- // Command-plugin discovery + validation.
2
- //
3
- // An installed, enabled plugin may declare `commands?: string` in its
4
- // `plugin.json` — a plugin-root-relative path to one declarative command
5
- // manifest (`commands.json`). This module reads and validates those manifests
6
- // WITHOUT executing any plugin code, turning each into either an accepted
7
- // contribution (a validated top-level branch node) or one or more typed
8
- // discovery issues. crtr owns parse/help/render/errors; the plugin owns one
9
- // executable that the adapter direct-spawns per leaf invocation.
10
- //
11
- // Everything here is invocation-local and side-effect-free: no cache, no
12
- // persisted state, no spawning. `discoverCommandContributions` is called only
13
- // on the fallthrough (non-core-fast-path) command path (see `build-root.ts`);
14
- // `validateEffectiveCommandPlugins` is the entry point Phase 2 lifecycle
15
- // surfaces (`pkg plugin show`, `sys doctor`) consume to report issues — it
16
- // carries cross-plugin collision issues that the per-plugin
17
- // `validatePluginCommands` structurally cannot see.
18
1
  import { realpathSync, readFileSync, statSync } from 'node:fs';
19
2
  import { resolve, sep } from 'node:path';
20
3
  import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js';
21
4
  import { projectScopeRoots } from '../scope.js';
22
- import { validatePluginNode, } from '../command-manifests/schema.js';
23
- const MANIFEST_KEYS = new Set(['schemaVersion', 'executable', 'mounts']);
24
- const MOUNT_KEYS = new Set(['parent', 'node']);
25
- // ---------------------------------------------------------------------------
26
- // Effective plugin resolution (M3 — reuse, no new resolver)
27
- // ---------------------------------------------------------------------------
28
- /** The effective, deduped set of enabled plugins that declare a command
29
- * manifest, in scope-precedence order. Enumerated with existing machinery
30
- * only: `projectScopeRoots()` (profile-widened, nearest-first) via
31
- * `listInstalledPluginsInRoot('project', root)`, then user scope. Dedup is by
32
- * plugin NAME across ALL copies (highest precedence wins) BEFORE the enabled
33
- * filter — so a disabled higher-precedence copy suppresses lower-precedence
34
- * copies of the same name. */
5
+ import { validateCommandManifest } from '../command-manifests/manifest.js';
6
+ import { resolveCommandRegistry } from '../command-manifests/registry.js';
7
+ import { adaptPluginContributions } from './compose.js';
8
+ import { validateHttpPluginTransport } from './endpoint.js';
9
+ import { readHttpPluginManifest, resolveHttpPluginManifestStore, writeHttpPluginManifest } from './store.js';
10
+ import { fetchHttpPluginManifest } from './transport/http-fetch.js';
35
11
  export function effectiveCommandPlugins(startDir = process.cwd(), profileId) {
36
12
  const seen = new Set();
37
- const deduped = [];
38
- const consider = (p) => {
39
- if (seen.has(p.name))
40
- return;
41
- seen.add(p.name);
42
- deduped.push(p);
43
- };
44
- for (const root of projectScopeRoots(startDir, profileId)) {
45
- for (const p of listInstalledPluginsInRoot('project', root))
46
- consider(p);
47
- }
48
- for (const p of listInstalledPlugins('user'))
49
- consider(p);
50
- return deduped.filter((p) => p.enabled && typeof p.manifest.commands === 'string' && p.manifest.commands.length > 0);
51
- }
52
- // ---------------------------------------------------------------------------
53
- // Discovery snapshot (tree composer entry point)
54
- // ---------------------------------------------------------------------------
55
- /** Get raw plugin candidates before the cross-plugin collision pass.
56
- * Each plugin validation includes its contributions and per-plugin issues;
57
- * call resolveCommandRegistry (or a later unified collision handler) to
58
- * produce the final snapshot with cross-plugin collision detection. */
59
- export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
60
- return effectiveCommandPlugins(startDir, profileId).map((p) => validatePluginCommands(p, new Set()));
13
+ const result = [];
14
+ const consider = (plugin) => { if (!seen.has(plugin.name)) {
15
+ seen.add(plugin.name);
16
+ result.push(plugin);
17
+ } };
18
+ for (const root of projectScopeRoots(startDir, profileId))
19
+ for (const plugin of listInstalledPluginsInRoot('project', root))
20
+ consider(plugin);
21
+ for (const plugin of listInstalledPlugins('user'))
22
+ consider(plugin);
23
+ return result.filter((plugin) => plugin.enabled && (plugin.manifest.commands !== undefined || plugin.manifest.transport !== undefined));
61
24
  }
62
- /** Validate every effective command plugin and compose an invocation-local
63
- * registry snapshot. `reservedNames` are the core top-level subtree names —
64
- * a contribution claiming one is silently skipped from the tree and recorded
65
- * as a `command_collision` issue (core always wins). A plugin-vs-plugin
66
- * top-level name collision drops all claimants with an issue. Accepted
67
- * contributions are sorted by name for deterministic help/order. */
68
- export function discoverCommandContributions(reservedNames, startDir = process.cwd(), profileId) {
69
- const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId);
70
- const contributions = validations.flatMap((v) => v.contributions);
71
- contributions.sort((a, b) => a.node.name.localeCompare(b.node.name));
72
- return { contributions, issues: validations.flatMap((v) => v.issues) };
25
+ function issueFor(plugin, issues) {
26
+ return (issue) => issues.push({ ...issue, plugin: plugin.name });
73
27
  }
74
- /** Validate every effective command plugin, including the cross-plugin
75
- * top-level name collision pass: a name claimed by more than one plugin is
76
- * dropped from EVERY claimant's contributions and a `command_collision`
77
- * issue is recorded on EACH claimant's validation, so per-plugin surfaces
78
- * (`sys doctor`, `pkg plugin show`) see it too. This is the entry point for
79
- * any surface that reports on mounted commands — the per-plugin
80
- * `validatePluginCommands` cannot see other plugins' claims. */
81
- export function validateEffectiveCommandPlugins(reservedNames, startDir = process.cwd(), profileId) {
82
- const validations = effectiveCommandPlugins(startDir, profileId).map((p) => validatePluginCommands(p, reservedNames));
83
- // Plugin-vs-plugin top-level name collision: drop all claimants + flag each.
84
- const claimants = new Map();
85
- for (const v of validations) {
86
- for (const c of v.contributions) {
87
- const list = claimants.get(c.node.name);
88
- if (list !== undefined)
89
- list.push(v);
90
- else
91
- claimants.set(c.node.name, [v]);
92
- }
28
+ function safePath(root, rel) {
29
+ if (typeof rel !== 'string' || rel.length === 0)
30
+ return null;
31
+ try {
32
+ const rootReal = realpathSync(root);
33
+ const real = realpathSync(resolve(root, rel));
34
+ return real === rootReal || real.startsWith(rootReal + sep) ? statSync(real).isFile() ? real : null : null;
93
35
  }
94
- for (const [name, vs] of claimants) {
95
- if (vs.length === 1)
96
- continue;
97
- const names = vs.map((x) => x.plugin.name).join(', ');
98
- for (const v of vs) {
99
- v.contributions = v.contributions.filter((c) => c.node.name !== name);
100
- v.issues.push({
101
- code: 'command_collision',
102
- plugin: v.plugin.name,
103
- path: name,
104
- message: `top-level command "${name}" is claimed by multiple plugins: ${names}`,
105
- received: names,
106
- expected: 'exactly one plugin per top-level command name',
107
- next: `Disable or remove all but one of: ${names}.`,
108
- });
109
- }
36
+ catch {
37
+ return null;
110
38
  }
111
- return validations;
112
39
  }
113
- // ---------------------------------------------------------------------------
114
- // Per-plugin validation (Phase 2 lifecycle entry point)
115
- // ---------------------------------------------------------------------------
116
- /** Validate one plugin's command manifest without executing its binary.
117
- * `reservedNames` are the core top-level names used for the core-collision
118
- * check; pass an empty set to skip it. Never throws. */
40
+ function transportFor(plugin, issues) {
41
+ const raw = plugin.manifest.transport;
42
+ if (raw === undefined) {
43
+ issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: 'commands requires transport in plugin.json', received: '(missing)', expected: 'a transport object', next: 'Add plugin.json.transport.', path: 'transport' });
44
+ return undefined;
45
+ }
46
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
47
+ issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: 'transport must be an object', received: Array.isArray(raw) ? 'array' : typeof raw, expected: 'an exec or http transport object', next: 'Fix plugin.json.transport.', path: 'transport' });
48
+ return undefined;
49
+ }
50
+ const value = raw;
51
+ if (value.kind === 'http') {
52
+ const result = validateHttpPluginTransport(value);
53
+ if (result.transport !== undefined)
54
+ return result.transport;
55
+ issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: result.errors.join('; '), received: JSON.stringify(raw), expected: 'a valid HTTP transport', next: 'Fix plugin.json.transport.', path: 'transport' });
56
+ return undefined;
57
+ }
58
+ const unknown = Object.keys(value).filter((key) => !['kind', 'executable'].includes(key));
59
+ if (value.kind !== 'exec' || unknown.length || typeof value.executable !== 'string' || value.executable.length === 0) {
60
+ issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: unknown.length ? `transport has unknown keys: ${unknown.join(', ')}` : 'exec transport requires executable', received: JSON.stringify(raw), expected: '{ kind: "exec", executable: "plugin-relative path" }', next: 'Fix plugin.json.transport.', path: 'transport' });
61
+ return undefined;
62
+ }
63
+ const executable = safePath(plugin.root, value.executable);
64
+ if (executable === null) {
65
+ issues.push({ code: 'command_path_unsafe', plugin: plugin.name, message: 'executable escapes the plugin root or is not a regular file', received: value.executable, expected: 'a relative path to a regular file inside the plugin root', next: 'Fix plugin.json.transport.executable.', path: 'transport.executable' });
66
+ return undefined;
67
+ }
68
+ if ((statSync(executable).mode & 0o111) === 0) {
69
+ issues.push({ code: 'command_not_executable', plugin: plugin.name, message: 'executable lacks the POSIX exec bit', received: value.executable, expected: 'a file with an executable permission bit', next: 'chmod +x the plugin executable, then update the plugin.', path: 'transport.executable' });
70
+ return undefined;
71
+ }
72
+ return { kind: 'exec', executable };
73
+ }
119
74
  export function validatePluginCommands(plugin, reservedNames = new Set()) {
120
75
  const issues = [];
121
- const contributions = [];
122
- const rel = plugin.manifest.commands ?? '';
123
- const manifestPath = resolve(plugin.root, rel);
124
- const issue = (code, message, received, expected, next, path) => {
125
- issues.push({ code, plugin: plugin.name, ...(path !== undefined ? { path } : {}), message, received, expected, next });
126
- };
127
- // Load + parse the manifest under path-safety confinement.
128
- const safe = safePath(plugin.root, rel);
76
+ const commands = plugin.manifest.commands;
77
+ const transportRaw = plugin.manifest.transport;
78
+ let manifestPath = resolve(plugin.root, typeof commands === 'string' ? commands : 'commands.json');
79
+ if (commands === undefined && transportRaw === undefined)
80
+ return { plugin, manifestPath, contributions: [], issues };
81
+ if (typeof commands !== 'string' || commands.length === 0) {
82
+ issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: 'transport requires commands in plugin.json', received: '(missing)', expected: 'a plugin-root-relative commands path', next: 'Add plugin.json.commands or remove transport.', path: 'commands' });
83
+ return { plugin, manifestPath, contributions: [], issues };
84
+ }
85
+ const transport = transportFor(plugin, issues);
86
+ if (transport === undefined)
87
+ return { plugin, manifestPath, contributions: [], issues };
88
+ const httpManifestStore = transport.kind === 'http'
89
+ ? resolveHttpPluginManifestStore({ root: plugin.root, commands }) ?? undefined
90
+ : undefined;
91
+ const safe = transport.kind === 'http' ? httpManifestStore?.path ?? null : safePath(plugin.root, commands);
129
92
  if (safe === null) {
130
- issue('command_path_unsafe', `commands pointer escapes the plugin root or is not a regular file`, rel, 'a relative path to a regular file inside the plugin root', 'Fix the `commands` field in plugin.json.');
131
- return { plugin, manifestPath, contributions, issues };
93
+ issues.push({ code: 'command_path_unsafe', plugin: plugin.name, message: 'commands pointer escapes the plugin root or is not a regular file', received: commands, expected: 'a relative path to a regular file inside the plugin root', next: 'Fix plugin.json.commands.', path: 'commands' });
94
+ return { plugin, manifestPath, transport, contributions: [], issues };
132
95
  }
133
- let raw;
96
+ manifestPath = safe;
97
+ let bytes;
134
98
  try {
135
- raw = readFileSync(safe, 'utf8');
99
+ bytes = transport.kind === 'http' ? readHttpPluginManifest(httpManifestStore) : readFileSync(safe);
136
100
  }
137
101
  catch {
138
- issue('command_manifest_unreadable', `commands.json is missing or unreadable`, safe, 'a readable JSON file', 'Reinstall or update the plugin.');
139
- return { plugin, manifestPath, contributions, issues };
102
+ bytes = null;
140
103
  }
141
- let parsed;
104
+ if (bytes === null) {
105
+ if (transport.kind === 'http')
106
+ return { plugin, manifestPath, transport, contributions: [], issues, absentStore: true, httpManifestStore };
107
+ issues.push({ code: 'command_manifest_unreadable', plugin: plugin.name, message: 'commands.json is missing or unreadable', received: safe, expected: 'a readable JSON file', next: 'Reinstall or update the plugin.' });
108
+ return { plugin, manifestPath, transport, contributions: [], issues };
109
+ }
110
+ let raw;
142
111
  try {
143
- parsed = JSON.parse(raw);
112
+ raw = JSON.parse(Buffer.from(bytes).toString('utf8'));
144
113
  }
145
114
  catch {
146
- issue('command_manifest_invalid', `commands.json is not valid JSON`, safe, 'a JSON object', 'Regenerate commands.json from the plugin\'s command definitions.');
147
- return { plugin, manifestPath, contributions, issues };
148
- }
149
- if (!isObject(parsed)) {
150
- issue('command_manifest_invalid', `commands.json must be a JSON object`, typeName(parsed), 'a JSON object', 'Regenerate commands.json.');
151
- return { plugin, manifestPath, contributions, issues };
152
- }
153
- // Top-level shape: exact schemaVersion, no unknown keys.
154
- const unknown = Object.keys(parsed).filter((k) => !MANIFEST_KEYS.has(k));
155
- if (unknown.length > 0) {
156
- issue('command_manifest_invalid', `commands.json has unknown top-level keys`, unknown.join(', '), 'only: schemaVersion, executable, mounts', 'Remove the unknown keys.');
157
- return { plugin, manifestPath, contributions, issues };
158
- }
159
- if (parsed['schemaVersion'] !== 1) {
160
- issue('command_schema_version', `unsupported command schemaVersion`, String(parsed['schemaVersion']), 'exactly the integer 1', 'Update the plugin to a version compatible with this crtr.');
161
- return { plugin, manifestPath, contributions, issues };
162
- }
163
- // Executable path safety + exec bit. Optional: a manifest whose mounts are all
164
- // passthrough branches ships no leaf and therefore no protocol binary. A leaf
165
- // without an executable is rejected per-mount below.
166
- const execRel = parsed['executable'];
167
- let execSafe;
168
- if (execRel !== undefined) {
169
- if (typeof execRel !== 'string' || execRel.length === 0) {
170
- issue('command_manifest_invalid', `executable must be a non-empty string`, typeName(execRel), 'a relative path string, or omitted when the manifest declares no leaf', 'Set executable to the plugin-relative path of the command binary.', 'executable');
171
- return { plugin, manifestPath, contributions, issues };
172
- }
173
- const resolved = safePath(plugin.root, execRel);
174
- if (resolved === null) {
175
- issue('command_path_unsafe', `executable escapes the plugin root or is not a regular file`, execRel, 'a relative path to a regular file inside the plugin root', 'Fix the executable path in commands.json.', 'executable');
176
- return { plugin, manifestPath, contributions, issues };
177
- }
178
- if (!hasExecBit(resolved)) {
179
- issue('command_not_executable', `executable lacks the POSIX exec bit`, execRel, 'a file with an executable permission bit', 'chmod +x the plugin\'s command binary, then update the plugin.', 'executable');
180
- return { plugin, manifestPath, contributions, issues };
181
- }
182
- execSafe = resolved;
183
- }
184
- // Mounts.
185
- const mounts = parsed['mounts'];
186
- if (!Array.isArray(mounts)) {
187
- issue('command_manifest_invalid', `mounts must be an array`, typeName(mounts), 'an array of mounts', 'Declare at least one mount.', 'mounts');
188
- return { plugin, manifestPath, ...(execSafe !== undefined ? { executable: execSafe } : {}), contributions, issues };
115
+ if (transport.kind === 'http')
116
+ return { plugin, manifestPath, transport, contributions: [], issues, absentStore: true, httpManifestStore };
117
+ issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: 'commands.json is not valid JSON', received: safe, expected: 'a JSON object', next: 'Regenerate commands.json.' });
118
+ return { plugin, manifestPath, transport, contributions: [], issues };
189
119
  }
190
- if (mounts.length === 0) {
191
- issue('command_manifest_invalid', `manifest declares no mounts (inert)`, '[]', 'at least one top-level mount', 'Declare a mount, or drop the `commands` field.', 'mounts');
120
+ const validation = validateCommandManifest(raw, { transport: transport.kind, reservedCoreNames: reservedNames });
121
+ if (validation.manifest === undefined) {
122
+ validation.issues.forEach(issueFor(plugin, issues));
123
+ return { plugin, manifestPath, transport, contributions: [], issues };
192
124
  }
193
- const topNames = new Set();
194
- mounts.forEach((mount, i) => {
195
- const pathStr = `mounts[${i}]`;
196
- if (!isObject(mount)) {
197
- issue('command_node_invalid', `mount must be an object`, typeName(mount), 'a { parent, node } object', 'Fix the mount.', pathStr);
198
- return;
199
- }
200
- const mUnknown = Object.keys(mount).filter((k) => !MOUNT_KEYS.has(k));
201
- if (mUnknown.length > 0) {
202
- issue('command_node_invalid', `mount has unknown keys`, mUnknown.join(', '), 'only: parent, node', 'Remove the unknown keys.', pathStr);
203
- return;
204
- }
205
- // v1: top-level mounts only — parent must be [].
206
- if (!Array.isArray(mount['parent']) || mount['parent'].length !== 0) {
207
- issue('command_parent_invalid', `mount parent must be [] (top-level only in v1)`, JSON.stringify(mount['parent']), 'an empty array []', 'Mount the node at top level; nested mounts are not supported in v1.', `${pathStr}.parent`);
208
- return;
209
- }
210
- const node = validatePluginNode(mount['node'], `${pathStr}.node`, true, issue);
211
- if (node === null)
212
- return;
213
- // node is a top-level branch here (validatePluginNode enforced kind + rootEntry).
214
- if (topNames.has(node.name)) {
215
- issue('command_node_invalid', `this plugin declares two top-level commands named "${node.name}"`, node.name, 'a unique top-level command name per plugin', 'Rename one of the duplicate top-level mounts.', `${pathStr}.node.name`);
216
- return;
217
- }
218
- topNames.add(node.name);
219
- if (reservedNames.has(node.name)) {
220
- issue('command_collision', `top-level command "${node.name}" collides with a core command`, node.name, 'a name not used by a core crtr command', 'Rename the plugin\'s top-level command.', `${pathStr}.node.name`);
221
- return;
222
- }
223
- if (execSafe === undefined && declaresLeaf(node)) {
224
- issue('command_manifest_invalid', `manifest declares a leaf but no executable`, '(missing)', 'an executable path, or a leafless (passthrough-only) mount', 'Add `executable` to commands.json — every leaf runs the plugin binary over the command protocol.', `${pathStr}.node`);
225
- return;
226
- }
227
- contributions.push({ plugin, ...(execSafe !== undefined ? { executable: execSafe } : {}), node: node });
228
- });
229
- return { plugin, manifestPath, ...(execSafe !== undefined ? { executable: execSafe } : {}), contributions, issues };
125
+ return { plugin, manifestPath, transport, contributions: validation.manifest.roots.map((node) => ({ plugin, transport, node, manifest: validation.manifest })), issues };
230
126
  }
231
- /** True when any node in the subtree is a leaf — i.e. the contribution needs a
232
- * protocol executable. */
233
- function declaresLeaf(node) {
234
- if (node.kind === 'leaf')
235
- return true;
236
- return node.children.some((c) => declaresLeaf(c));
127
+ export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
128
+ return effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin));
237
129
  }
238
- // ---------------------------------------------------------------------------
239
- // Small helpers
240
- // ---------------------------------------------------------------------------
241
- function isObject(v) {
242
- return typeof v === 'object' && v !== null && !Array.isArray(v);
130
+ export function validateEffectiveCommandPlugins(reservedNames, startDir = process.cwd(), profileId) {
131
+ const validations = effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin, reservedNames));
132
+ const claims = new Map();
133
+ for (const validation of validations)
134
+ for (const contribution of validation.contributions)
135
+ (claims.get(contribution.node.name) ?? (claims.set(contribution.node.name, []), claims.get(contribution.node.name))).push(validation);
136
+ for (const [name, claimants] of claims)
137
+ if (claimants.length > 1)
138
+ for (const validation of claimants) {
139
+ validation.contributions = validation.contributions.filter((contribution) => contribution.node.name !== name);
140
+ validation.issues.push({ code: 'command_collision', plugin: validation.plugin.name, path: name, message: `top-level command "${name}" is claimed by multiple plugins`, received: claimants.map((claimant) => claimant.plugin.name).join(', '), expected: 'exactly one plugin per top-level command name', next: 'Disable or remove all but one claimant.' });
141
+ }
142
+ return validations;
243
143
  }
244
- function typeName(v) {
245
- if (v === null)
246
- return 'null';
247
- if (Array.isArray(v))
248
- return 'array';
249
- return typeof v;
144
+ function contributorIssue(plugin, issue) {
145
+ return { ...issue, contributor: { name: plugin.name, scope: plugin.scope } };
250
146
  }
251
- function checkKeys(raw, allowed, path, issue) {
252
- const unknown = Object.keys(raw).filter((k) => !allowed.has(k));
253
- if (unknown.length > 0) {
254
- issue('command_node_invalid', `unknown keys`, unknown.join(', '), `only: ${[...allowed].join(', ')}`, 'Remove the unknown keys.', path);
255
- return false;
256
- }
257
- return true;
147
+ export function discoverCommandContributions(reservedNames, startDir = process.cwd(), profileId) {
148
+ const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId);
149
+ return { contributions: validations.flatMap((validation) => validation.contributions), issues: validations.flatMap((validation) => validation.issues) };
258
150
  }
259
- /** Resolve `rel` under `root`, confining it to the realpath'd root and
260
- * requiring a regular file. Returns the resolved absolute path or null. */
261
- function safePath(root, rel) {
262
- if (typeof rel !== 'string' || rel.length === 0)
263
- return null;
264
- let rootReal;
265
- let real;
266
- try {
267
- rootReal = realpathSync(root);
268
- real = realpathSync(resolve(root, rel));
269
- }
270
- catch {
271
- return null;
272
- }
273
- if (real !== rootReal && !real.startsWith(rootReal + sep))
274
- return null;
275
- try {
276
- if (!statSync(real).isFile())
277
- return null;
278
- }
279
- catch {
280
- return null;
281
- }
282
- return real;
151
+ export function buildExternalCommandSnapshot(reservedCoreNames) {
152
+ const validations = discoverPluginCommandCandidates();
153
+ const candidates = adaptPluginContributions(validations.flatMap((validation) => validation.contributions)).contributions;
154
+ const resolved = resolveCommandRegistry(candidates, reservedCoreNames);
155
+ return { contributions: resolved.contributions, issues: [...validations.flatMap((validation) => validation.issues.map((issue) => contributorIssue(validation.plugin, issue))), ...resolved.issues] };
283
156
  }
284
- function hasExecBit(file) {
285
- try {
286
- return (statSync(file).mode & 0o111) !== 0;
287
- }
288
- catch {
289
- return false;
157
+ export async function hydrateAbsentHttpPlugins() {
158
+ let hydratedAny = false;
159
+ const diagnostics = [];
160
+ for (const validation of discoverPluginCommandCandidates()) {
161
+ if (validation.transport?.kind !== 'http')
162
+ continue;
163
+ const unsafePath = validation.issues.find((issue) => issue.code === 'command_path_unsafe');
164
+ if (unsafePath !== undefined) {
165
+ diagnostics.push(`HTTP plugin "${validation.plugin.name}": ${unsafePath.code}: ${unsafePath.message} — ${unsafePath.next}`);
166
+ continue;
167
+ }
168
+ if (!validation.absentStore || validation.httpManifestStore === undefined)
169
+ continue;
170
+ try {
171
+ const result = await fetchHttpPluginManifest({ name: validation.plugin.name, endpoint: validation.transport.endpoint, ...(validation.transport.authEnv !== undefined ? { authEnv: validation.transport.authEnv } : {}) });
172
+ if (result.status === 200) {
173
+ writeHttpPluginManifest(validation.httpManifestStore, result.raw);
174
+ hydratedAny = true;
175
+ }
176
+ else
177
+ diagnostics.push(`HTTP plugin "${validation.plugin.name}": ${result.message} — run \`crtr pkg plugin update --name ${validation.plugin.name}\``);
178
+ }
179
+ catch (error) {
180
+ diagnostics.push(`HTTP plugin "${validation.plugin.name}": ${error.message} — run \`crtr pkg plugin update --name ${validation.plugin.name}\``);
181
+ }
290
182
  }
183
+ return { hydratedAny, diagnostics };
291
184
  }
@@ -0,0 +1,24 @@
1
+ export interface HttpPluginFetchTarget {
2
+ name: string;
3
+ endpoint: string;
4
+ authEnv?: string;
5
+ }
6
+ export interface HttpPluginRegistration extends HttpPluginFetchTarget {
7
+ root: string;
8
+ commands: string;
9
+ scope: 'user' | 'project';
10
+ }
11
+ export interface EndpointValidation {
12
+ isValid: boolean;
13
+ errors: string[];
14
+ }
15
+ export declare function validateEndpoint(endpoint: unknown): EndpointValidation;
16
+ export declare function normalizeEndpoint(endpoint: string): string;
17
+ export declare function validateHttpPluginTransport(raw: unknown): {
18
+ transport?: {
19
+ kind: 'http';
20
+ endpoint: string;
21
+ authEnv?: string;
22
+ };
23
+ errors: string[];
24
+ };
@@ -0,0 +1,48 @@
1
+ import { validateEnvVarName } from '../env-name.js';
2
+ function isLoopbackHostname(hostname) {
3
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1' || hostname === 'host.docker.internal';
4
+ }
5
+ export function validateEndpoint(endpoint) {
6
+ const errors = [];
7
+ if (typeof endpoint !== 'string')
8
+ errors.push('endpoint must be a string');
9
+ else {
10
+ try {
11
+ const url = new URL(endpoint);
12
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopbackHostname(url.hostname)))
13
+ errors.push('endpoint must be an absolute https:// URL (http:// permitted for loopback hosts only; no credentials or fragment)');
14
+ else if (url.username || url.password || url.hash)
15
+ errors.push('endpoint must be an absolute https:// URL (http:// permitted for loopback hosts only; no credentials or fragment)');
16
+ }
17
+ catch {
18
+ errors.push('endpoint must be an absolute https:// URL (http:// permitted for loopback hosts only; no credentials or fragment)');
19
+ }
20
+ }
21
+ return { isValid: errors.length === 0, errors };
22
+ }
23
+ export function normalizeEndpoint(endpoint) {
24
+ try {
25
+ const url = new URL(endpoint);
26
+ const pathname = url.pathname.length > 1 && url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname;
27
+ return `${url.protocol}//${url.host}${pathname}${url.search}`;
28
+ }
29
+ catch {
30
+ return endpoint;
31
+ }
32
+ }
33
+ export function validateHttpPluginTransport(raw) {
34
+ const errors = [];
35
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
36
+ return { errors: ['transport must be an object'] };
37
+ const transport = raw;
38
+ const unknown = Object.keys(transport).filter((key) => !['kind', 'endpoint', 'authEnv'].includes(key));
39
+ if (unknown.length)
40
+ errors.push(`transport has unknown keys: ${unknown.join(', ')}`);
41
+ if (transport.kind !== 'http')
42
+ errors.push('transport.kind must be "http"');
43
+ const endpoint = validateEndpoint(transport.endpoint);
44
+ errors.push(...endpoint.errors);
45
+ if (transport.authEnv !== undefined)
46
+ errors.push(...validateEnvVarName(transport.authEnv).errors);
47
+ return errors.length ? { errors } : { transport: { kind: 'http', endpoint: normalizeEndpoint(transport.endpoint), ...(transport.authEnv !== undefined ? { authEnv: transport.authEnv } : {}) }, errors };
48
+ }
@@ -0,0 +1,16 @@
1
+ import type { HttpPluginRegistration } from './endpoint.js';
2
+ /** A root-confined HTTP manifest location, created only by
3
+ * `resolveHttpPluginManifestStore`. Store operations accept this resolved
4
+ * location rather than a manifest declaration. */
5
+ export declare class HttpPluginManifestStore {
6
+ #private;
7
+ private constructor();
8
+ get path(): string;
9
+ static resolve(plugin: Pick<HttpPluginRegistration, 'root' | 'commands'>): HttpPluginManifestStore | null;
10
+ }
11
+ /** Resolve an HTTP manifest declaration to a path confined to the plugin root.
12
+ * Existing target and ancestor symlinks are resolved before use; an absent target
13
+ * is allowed only beneath an existing real ancestor inside that root. */
14
+ export declare function resolveHttpPluginManifestStore(plugin: Pick<HttpPluginRegistration, 'root' | 'commands'>): HttpPluginManifestStore | null;
15
+ export declare function readHttpPluginManifest(store: HttpPluginManifestStore): Uint8Array | null;
16
+ export declare function writeHttpPluginManifest(store: HttpPluginManifestStore, rawManifest: Uint8Array): void;
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { dirname, resolve, sep } from 'node:path';
3
+ import { atomicWriteText, pathExists } from '../fs-utils.js';
4
+ /** A root-confined HTTP manifest location, created only by
5
+ * `resolveHttpPluginManifestStore`. Store operations accept this resolved
6
+ * location rather than a manifest declaration. */
7
+ export class HttpPluginManifestStore {
8
+ #path;
9
+ constructor(path) {
10
+ this.#path = path;
11
+ }
12
+ get path() {
13
+ return this.#path;
14
+ }
15
+ static resolve(plugin) {
16
+ if (typeof plugin.commands !== 'string' || plugin.commands.length === 0)
17
+ return null;
18
+ try {
19
+ const root = realpathSync(plugin.root);
20
+ const path = resolve(root, plugin.commands);
21
+ if (!isInside(root, path))
22
+ return null;
23
+ if (existsSync(path)) {
24
+ const real = realpathSync(path);
25
+ if (!isInside(root, real) || !statSync(real).isFile())
26
+ return null;
27
+ }
28
+ else {
29
+ let ancestor = dirname(path);
30
+ while (!existsSync(ancestor) && ancestor !== dirname(ancestor))
31
+ ancestor = dirname(ancestor);
32
+ const realAncestor = realpathSync(ancestor);
33
+ if (!isInside(root, realAncestor) && realAncestor !== root)
34
+ return null;
35
+ }
36
+ return new HttpPluginManifestStore(path);
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ }
43
+ function isInside(root, path) {
44
+ return path !== root && path.startsWith(root + sep);
45
+ }
46
+ /** Resolve an HTTP manifest declaration to a path confined to the plugin root.
47
+ * Existing target and ancestor symlinks are resolved before use; an absent target
48
+ * is allowed only beneath an existing real ancestor inside that root. */
49
+ export function resolveHttpPluginManifestStore(plugin) {
50
+ return HttpPluginManifestStore.resolve(plugin);
51
+ }
52
+ export function readHttpPluginManifest(store) {
53
+ if (!pathExists(store.path))
54
+ return null;
55
+ try {
56
+ return readFileSync(store.path);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ export function writeHttpPluginManifest(store, rawManifest) {
63
+ atomicWriteText(store.path, rawManifest);
64
+ }
@@ -1,5 +1,5 @@
1
- import type { InstalledPlugin } from '../../types.js';
2
- import type { Field } from '../help.js';
1
+ import type { InstalledPlugin } from '../../../types.js';
2
+ import type { Field } from '../../help.js';
3
3
  export interface ExternalLeafSpec {
4
4
  plugin: InstalledPlugin;
5
5
  /** Absolute, resolved executable path. */
@@ -11,7 +11,7 @@ export interface ExternalLeafSpec {
11
11
  }
12
12
  /** Validates a result object against declared output field descriptors.
13
13
  * Returns null if valid; otherwise returns a { field, receivedType } error.
14
- * Transport-neutral: used by both command-plugin and configured-CLI adapters. */
14
+ * Transport-neutral: used by exec and HTTP transport adapters. */
15
15
  export declare function validateDeclaredResult(output: Field[], result: Record<string, unknown>): {
16
16
  field: string;
17
17
  receivedType?: string;