@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.81

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 (200) hide show
  1. package/README.md +11 -2
  2. package/dist/node/index.cjs +76 -38
  3. package/dist/node/index.d.cts +946 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +564 -51
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +76 -38
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +34 -16
  10. package/src/advisor/advisor-command-module.ts +47 -0
  11. package/src/advisor/advisor-command.test.ts +102 -0
  12. package/src/advisor/advisor-command.ts +55 -0
  13. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  14. package/src/advisor/index.ts +6 -0
  15. package/src/agent/__tests__/agent-command.test.ts +39 -8
  16. package/src/agent/agent-command-module.ts +14 -7
  17. package/src/agent/agent-command-parser.ts +1 -1
  18. package/src/agent/agent-command.ts +3 -3
  19. package/src/background/__tests__/background-command-module.test.ts +59 -52
  20. package/src/background/background-command-module.ts +2 -1
  21. package/src/background/background-command.ts +3 -3
  22. package/src/command-module-utils.ts +33 -0
  23. package/src/compact/__tests__/compact-command-module.test.ts +31 -23
  24. package/src/compact/compact-command-module.ts +10 -1
  25. package/src/compact/compact-command.ts +6 -3
  26. package/src/context/__tests__/context-command-module.test.ts +214 -63
  27. package/src/context/auto-compact-format.ts +25 -0
  28. package/src/context/context-breakdown.ts +295 -0
  29. package/src/context/context-command-module.ts +45 -14
  30. package/src/context/context-command.ts +34 -257
  31. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
  32. package/src/default/__tests__/default-command-modules.test.ts +59 -7
  33. package/src/default/__tests__/model-exposure.test.ts +241 -0
  34. package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
  35. package/src/default/default-command-modules.ts +130 -18
  36. package/src/default/index.ts +4 -1
  37. package/src/devices/__tests__/devices-command-module.test.ts +212 -0
  38. package/src/devices/devices-command-module.ts +224 -0
  39. package/src/devices/devices-command-port.ts +82 -0
  40. package/src/devices/index.ts +11 -0
  41. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  42. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  43. package/src/doctor/__tests__/doctor-fixture.ts +145 -0
  44. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  45. package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
  46. package/src/doctor/doctor-command-module.ts +132 -0
  47. package/src/doctor/doctor-extensions-probe.ts +299 -0
  48. package/src/doctor/doctor-node-deps.ts +89 -0
  49. package/src/doctor/doctor-redaction.ts +99 -0
  50. package/src/doctor/doctor-render.ts +70 -0
  51. package/src/doctor/doctor-repair.ts +136 -0
  52. package/src/doctor/doctor-runner.ts +122 -0
  53. package/src/doctor/doctor-settings-probe.ts +239 -0
  54. package/src/doctor/doctor-storage-probe.ts +187 -0
  55. package/src/doctor/doctor-types.ts +109 -0
  56. package/src/doctor/index.ts +32 -0
  57. package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
  58. package/src/editor/editor-command-module.ts +6 -5
  59. package/src/editor/editor-command.ts +10 -5
  60. package/src/effort/effort-command-module.ts +51 -0
  61. package/src/effort/effort-command.test.ts +140 -0
  62. package/src/effort/effort-command.ts +122 -0
  63. package/src/effort/index.ts +6 -0
  64. package/src/exit/__tests__/exit-command-module.test.ts +16 -9
  65. package/src/exit/exit-command-module.ts +2 -1
  66. package/src/exit/exit-command.ts +6 -6
  67. package/src/fork/__tests__/fork-command.test.ts +184 -0
  68. package/src/fork/fork-command-module.ts +61 -0
  69. package/src/fork/fork-command.ts +162 -0
  70. package/src/fork/index.ts +8 -0
  71. package/src/git/__tests__/fake-git-port.ts +28 -0
  72. package/src/git/__tests__/git-command-module.test.ts +103 -0
  73. package/src/git/__tests__/git-commit.test.ts +185 -0
  74. package/src/git/__tests__/git-diff.test.ts +125 -0
  75. package/src/git/__tests__/git-process.test.ts +221 -0
  76. package/src/git/__tests__/git-status.test.ts +117 -0
  77. package/src/git/git-command-module.ts +139 -0
  78. package/src/git/git-commit.ts +251 -0
  79. package/src/git/git-diff.ts +129 -0
  80. package/src/git/git-process.ts +169 -0
  81. package/src/git/git-status.ts +134 -0
  82. package/src/git/index.ts +17 -0
  83. package/src/goal/__tests__/goal-command.test.ts +9 -4
  84. package/src/goal/goal-command-module.ts +2 -1
  85. package/src/goal/goal-command.ts +6 -9
  86. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  87. package/src/handoff/handoff-command-module.ts +48 -0
  88. package/src/handoff/handoff-command.ts +187 -0
  89. package/src/handoff/index.ts +6 -0
  90. package/src/help/__tests__/help-command-module.test.ts +63 -40
  91. package/src/help/__tests__/help-command.test.ts +46 -37
  92. package/src/help/help-command-module.ts +2 -1
  93. package/src/help/help-command.ts +3 -3
  94. package/src/index.ts +124 -2
  95. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  96. package/src/keybindings/index.ts +6 -0
  97. package/src/keybindings/keybindings-command-module.ts +74 -0
  98. package/src/language/__tests__/language-command-module.test.ts +33 -28
  99. package/src/language/language-command-module.ts +2 -1
  100. package/src/language/language-command.ts +8 -6
  101. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
  102. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
  103. package/src/mcp-activation/index.ts +12 -0
  104. package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
  105. package/src/mcp-activation/mcp-activation-command.ts +448 -0
  106. package/src/mcp-activation/mcp-model-notice.ts +85 -0
  107. package/src/mcp-activation/mcp-model-status.ts +99 -0
  108. package/src/memory/__tests__/memory-command-module.test.ts +167 -74
  109. package/src/memory/memory-command-module.ts +18 -1
  110. package/src/memory/memory-command.ts +38 -38
  111. package/src/mode/__tests__/mode-command-module.test.ts +51 -38
  112. package/src/mode/mode-command-module.ts +2 -1
  113. package/src/mode/mode-command.ts +15 -7
  114. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  115. package/src/output-style/index.ts +6 -0
  116. package/src/output-style/output-style-command-module.ts +143 -0
  117. package/src/peers/__tests__/peers-command.test.ts +241 -0
  118. package/src/peers/index.ts +9 -0
  119. package/src/peers/peers-command-module.ts +47 -0
  120. package/src/peers/peers-command.ts +142 -0
  121. package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
  122. package/src/permissions/permissions-command-module.ts +2 -1
  123. package/src/permissions/permissions-command.ts +46 -4
  124. package/src/plan/__tests__/plan-command.test.ts +129 -0
  125. package/src/plan/index.ts +9 -0
  126. package/src/plan/plan-command-module.ts +49 -0
  127. package/src/plan/plan-command.ts +92 -0
  128. package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
  129. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  130. package/src/plugin/plugin-command-module.ts +3 -1
  131. package/src/plugin/plugin-command.ts +17 -16
  132. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  133. package/src/preset/__tests__/preset-command-module.test.ts +102 -56
  134. package/src/preset/preset-command-module.ts +26 -8
  135. package/src/preset/preset-command.ts +85 -27
  136. package/src/provider/__tests__/org-policy.test.ts +4 -5
  137. package/src/provider/__tests__/provider-command-module.test.ts +11 -14
  138. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  139. package/src/provider/__tests__/scripted-interaction.ts +7 -5
  140. package/src/provider/provider-command-execution.ts +4 -4
  141. package/src/provider/provider-command-module.ts +2 -1
  142. package/src/provider/provider-command-profile-lifecycle.ts +4 -4
  143. package/src/provider/provider-command-profile-operations.ts +3 -3
  144. package/src/provider/provider-command-profile.ts +1 -1
  145. package/src/provider/provider-command-setup.ts +4 -4
  146. package/src/provider/provider-startup.ts +62 -35
  147. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  148. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  149. package/src/remote-control/index.ts +8 -0
  150. package/src/remote-control/remote-control-command-module.ts +61 -0
  151. package/src/remote-control/remote-control-command.ts +138 -0
  152. package/src/reset/__tests__/reset-command-module.test.ts +5 -5
  153. package/src/reset/reset-command-module.ts +2 -1
  154. package/src/reset/reset-command.ts +3 -4
  155. package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
  156. package/src/rewind/rewind-command-module.ts +2 -1
  157. package/src/rewind/rewind-command.ts +73 -6
  158. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  159. package/src/sandbox/index.ts +2 -0
  160. package/src/sandbox/sandbox-command-module.ts +56 -0
  161. package/src/sandbox/sandbox-command.ts +94 -0
  162. package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
  163. package/src/schedule/__tests__/loop-command.test.ts +360 -0
  164. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  165. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  166. package/src/schedule/__tests__/schedule-command.test.ts +143 -3
  167. package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
  168. package/src/schedule/index.ts +3 -0
  169. package/src/schedule/loop-command.ts +333 -0
  170. package/src/schedule/loop-jitter.ts +41 -0
  171. package/src/schedule/schedule-command-module.ts +92 -14
  172. package/src/schedule/schedule-command.ts +98 -9
  173. package/src/schedule/schedule-spec-parser.ts +5 -1
  174. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  175. package/src/session/__tests__/session-command-module.test.ts +210 -45
  176. package/src/session/session-command-module.ts +68 -37
  177. package/src/session/session-command.ts +147 -80
  178. package/src/settings/settings-command-module.ts +3 -2
  179. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  180. package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
  181. package/src/shell/resolve-shell.ts +2 -2
  182. package/src/shell/shell-command-module.ts +6 -5
  183. package/src/shell/shell-command.ts +9 -5
  184. package/src/skills/__tests__/skills-command-module.test.ts +69 -28
  185. package/src/skills/index.ts +1 -0
  186. package/src/skills/skills-command-module.ts +22 -5
  187. package/src/skills/skills-command.ts +14 -13
  188. package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
  189. package/src/statusline/statusline-command-module.ts +2 -1
  190. package/src/statusline/statusline-command.ts +4 -4
  191. package/src/theme/__tests__/theme-command.test.ts +204 -0
  192. package/src/theme/index.ts +12 -0
  193. package/src/theme/theme-command-module.ts +211 -0
  194. package/src/user-local/__tests__/user-local-command.test.ts +145 -110
  195. package/src/user-local/user-local-command-constants.ts +1 -1
  196. package/src/user-local/user-local-command-module.ts +6 -5
  197. package/src/user-local/user-local-command.ts +12 -6
  198. package/src/user-local/user-local-memory-command.ts +17 -7
  199. package/src/plugins/default-plugin-command-adapter.ts +0 -164
  200. package/src/plugins/default-plugin-command-source-loader.ts +0 -31
@@ -0,0 +1,132 @@
1
+ import { createSystemCommandFromEntry } from '../command-module-utils.js';
2
+ /**
3
+ * `/doctor` — the interactive surface of the same runner the shell route uses (OBSERVABILITY-1991).
4
+ *
5
+ * `/doctor` renders the report; `/doctor repair <check-id>` confirms through the host's user
6
+ * interaction port and treats an absent port or a cancelled answer as "nothing written". Neither
7
+ * form creates a provider turn or submits user input: the command reads and, on explicit
8
+ * confirmation, runs one allowlisted writer.
9
+ */
10
+ import { confirmAction } from '@robota-sdk/agent-core';
11
+
12
+ import { createNodeDoctorDeps } from './doctor-node-deps.js';
13
+ import { renderDoctorReport } from './doctor-render.js';
14
+ import { applyDoctorRepair } from './doctor-repair.js';
15
+ import { runDoctor } from './doctor-runner.js';
16
+
17
+ import type { IDoctorRepairPlan } from './doctor-repair.js';
18
+ import type { IDoctorDisplayVocabulary } from './doctor-render.js';
19
+ import type { IDoctorDeps, IDoctorInputs } from './doctor-types.js';
20
+ import type {
21
+ ICommandHostUserInteraction,
22
+ ICommandModule,
23
+ ISystemCommand,
24
+ } from '@robota-sdk/agent-framework';
25
+ import type { ICommand, ICommandResult, ICommandSource } from '@robota-sdk/agent-interface-command';
26
+
27
+ export function createDoctorCommandEntry(): ICommand {
28
+ return {
29
+ name: 'doctor',
30
+ displayName: 'Doctor',
31
+ description:
32
+ 'Diagnose configuration and runtime readiness; `repair <check-id>` applies an allowlisted fix',
33
+ source: 'doctor',
34
+ // User-only: install diagnostics and confirmed repairs that write settings are the user's.
35
+ modelInvocable: false,
36
+ userInvocable: true,
37
+ argumentHint: '[repair <check-id>]',
38
+ };
39
+ }
40
+
41
+ export class DoctorCommandSource implements ICommandSource {
42
+ readonly name = 'doctor';
43
+
44
+ getCommands(): ICommand[] {
45
+ return [createDoctorCommandEntry()];
46
+ }
47
+ }
48
+
49
+ async function confirmThroughHost(
50
+ context: ICommandHostUserInteraction,
51
+ plan: IDoctorRepairPlan,
52
+ ): Promise<boolean> {
53
+ const ui = context.getUserInteraction();
54
+ if (ui === undefined) return false;
55
+ const response = await ui.ask(
56
+ confirmAction(`doctor-repair:${plan.id}`, `Repair ${plan.id}?`, {
57
+ description: `${plan.description} (${plan.path})`,
58
+ defaultYes: false,
59
+ }),
60
+ );
61
+ return response.type === 'answer' && response.values[0] === 'yes';
62
+ }
63
+
64
+ async function executeRepair(
65
+ id: string,
66
+ inputs: IDoctorInputs,
67
+ deps: IDoctorDeps,
68
+ context: ICommandHostUserInteraction,
69
+ display: IDoctorDisplayVocabulary,
70
+ ): Promise<ICommandResult> {
71
+ const outcome = await applyDoctorRepair(id, inputs, deps, (plan) =>
72
+ confirmThroughHost(context, plan),
73
+ );
74
+ if (!outcome.applied) {
75
+ return { success: false, message: outcome.reason, data: { repair: id, applied: false } };
76
+ }
77
+ const report = await runDoctor(inputs, deps);
78
+ const check = report.checks.find((candidate) => candidate.id === id);
79
+ return {
80
+ success: true,
81
+ message: [
82
+ `Repaired ${id}: ${outcome.plan.description}.`,
83
+ ...renderDoctorReport(report, `${display.title ?? 'Doctor'} (after repair)`, display),
84
+ ].join('\n'),
85
+ data: { repair: id, applied: true, status: check?.status, exitCode: report.exitCode },
86
+ };
87
+ }
88
+
89
+ async function executeDoctorCommand(
90
+ inputs: IDoctorInputs,
91
+ deps: IDoctorDeps,
92
+ context: ICommandHostUserInteraction,
93
+ args: string,
94
+ display: IDoctorDisplayVocabulary,
95
+ ): Promise<ICommandResult> {
96
+ const [verb, target] = args.trim().split(/\s+/);
97
+ if (verb === 'repair') {
98
+ if (target === undefined || target.length === 0) {
99
+ return { success: false, message: 'Usage: /doctor repair <check-id>' };
100
+ }
101
+ return executeRepair(target, inputs, deps, context, display);
102
+ }
103
+ const report = await runDoctor(inputs, deps);
104
+ return {
105
+ success: report.failCount === 0,
106
+ message: renderDoctorReport(report, display.title, display).join('\n'),
107
+ data: {
108
+ failCount: report.failCount,
109
+ warnCount: report.warnCount,
110
+ repairable: [...report.repairable],
111
+ },
112
+ };
113
+ }
114
+
115
+ /** Register `/doctor` over the host-composed inputs; the host decides whether to supply them. */
116
+ export function createDoctorCommandModule(
117
+ inputs: IDoctorInputs,
118
+ deps: IDoctorDeps = createNodeDoctorDeps({ ...inputs.env }),
119
+ display: IDoctorDisplayVocabulary = {},
120
+ ): ICommandModule {
121
+ const entry = createDoctorCommandEntry();
122
+ const command: ISystemCommand = createSystemCommandFromEntry(entry, {
123
+ requiresPermission: false,
124
+ lifecycle: 'inline',
125
+ execute: (context, args) => executeDoctorCommand(inputs, deps, context, args, display),
126
+ });
127
+ return {
128
+ name: 'agent-command-doctor',
129
+ commandSources: [new DoctorCommandSource()],
130
+ systemCommands: [command],
131
+ };
132
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Plugin, skill, hook and MCP probes (OBSERVABILITY-1991) — each over the owner's inspection API.
3
+ *
4
+ * Statuses follow the spec: a plugin the user installed that cannot load is `fail`; a hooks.json the
5
+ * schema refuses is `warn` (report-only — the loader still loads it); an MCP structural fault is
6
+ * `warn` until the CLI has an MCP consumer; MCP connection and hook execution are the enumerated
7
+ * `not-probed` exclusions of a read-only doctor.
8
+ */
9
+ import {
10
+ inspectSkillSources,
11
+ loadHostBundlePluginInspectionFromScopes,
12
+ } from '@robota-sdk/agent-framework';
13
+
14
+ import type { IDoctorCheck, IDoctorDeps, IDoctorInputs } from './doctor-types.js';
15
+ import type {
16
+ IBundlePluginInspection,
17
+ IBundlePluginMcpServer,
18
+ ISettingsInspection,
19
+ } from '@robota-sdk/agent-framework';
20
+
21
+ const SKIP_CAUSE: Record<string, string> = {
22
+ 'manifest-unreadable': 'manifest could not be parsed',
23
+ 'manifest-invalid': 'manifest is not a valid plugin.json (name, version, description required)',
24
+ 'load-failed': 'plugin assets could not be loaded',
25
+ };
26
+
27
+ function pluginChecks(inspections: readonly IBundlePluginInspection[]): IDoctorCheck[] {
28
+ const checks: IDoctorCheck[] = [];
29
+ const loaded = inspections.reduce((n, i) => n + i.loaded.length, 0);
30
+ const present = inspections.filter((i) => i.cacheDirPresent);
31
+ checks.push(
32
+ present.length === 0
33
+ ? {
34
+ id: 'plugins',
35
+ label: 'Plugins',
36
+ status: 'not-configured',
37
+ cause: 'no plugin cache directory',
38
+ detail: inspections.map((i) => i.pluginsDir),
39
+ }
40
+ : {
41
+ id: 'plugins',
42
+ label: 'Plugins',
43
+ status: 'ok',
44
+ cause: `${loaded} plugin(s) loaded`,
45
+ detail: present.map(
46
+ (i) => `${i.pluginsDir}: ${i.loaded.length} loaded, ${i.skipped.length} skipped`,
47
+ ),
48
+ },
49
+ );
50
+ for (const inspection of inspections) {
51
+ for (const skip of inspection.skipped) {
52
+ if (skip.reason === 'disabled') continue;
53
+ checks.push({
54
+ id: `plugin.${skip.pluginId}`,
55
+ label: 'Plugin',
56
+ status: 'fail',
57
+ path: skip.manifestPath,
58
+ cause: SKIP_CAUSE[skip.reason] ?? skip.reason,
59
+ ...(skip.detail === undefined ? {} : { detail: [skip.detail] }),
60
+ });
61
+ }
62
+ for (const issue of inspection.hookIssues) {
63
+ checks.push({
64
+ id: `plugin.${issue.pluginId}.hooks`,
65
+ label: 'Plugin hooks',
66
+ status: 'warn',
67
+ path: issue.hooksPath,
68
+ cause: `hooks.json fails the hooks schema: ${issue.issues.map((i) => `${i.path} (${i.code})`).join(', ')}`,
69
+ detail: ['Report-only: the plugin still loads; these hooks may not run.'],
70
+ });
71
+ }
72
+ }
73
+ return checks;
74
+ }
75
+
76
+ function skillChecks(inputs: IDoctorInputs): IDoctorCheck[] {
77
+ const inspection = inspectSkillSources(inputs.contributionSources, inputs.skillRoots);
78
+ const present = inspection.roots.filter((root) => root.present);
79
+ const discovered = present.reduce((n, root) => n + root.discovered.length, 0);
80
+ const checks: IDoctorCheck[] = [
81
+ present.length === 0
82
+ ? {
83
+ id: 'skills',
84
+ label: 'Skills and commands',
85
+ status: 'not-configured',
86
+ cause: 'no skill or command root present',
87
+ }
88
+ : {
89
+ id: 'skills',
90
+ label: 'Skills and commands',
91
+ status: 'ok',
92
+ cause: `${discovered} discovered`,
93
+ detail: present.map(
94
+ (root) => `${root.sourceDisplayName}: ${root.root} (${root.discovered.length})`,
95
+ ),
96
+ },
97
+ ];
98
+ for (const root of present) {
99
+ for (const skip of root.skipped) {
100
+ // A value the parser refuses ends session discovery with a throw; every other skip is tolerated.
101
+ checks.push({
102
+ id: `skill.${skip.path}`,
103
+ label: 'Skill definition',
104
+ status: skip.reason === 'frontmatter-invalid' ? 'fail' : 'warn',
105
+ path: `${root.sourceDisplayName}: ${skip.path}`,
106
+ cause: skip.reason,
107
+ ...(skip.detail === undefined ? {} : { detail: [skip.detail] }),
108
+ });
109
+ }
110
+ }
111
+ return checks;
112
+ }
113
+
114
+ /** The shape of a hook group as both settings layers and plugin `hooks.json` carry it. */
115
+ interface IHookGroupLike {
116
+ readonly hooks?: ReadonlyArray<{ readonly type?: string; readonly command?: string }>;
117
+ }
118
+
119
+ function commandHooks(
120
+ settings: ISettingsInspection,
121
+ plugins: readonly IBundlePluginInspection[],
122
+ ): string[] {
123
+ const commands: string[] = [];
124
+ const collect = (hooks: object | undefined): void => {
125
+ if (hooks === undefined) return;
126
+ for (const groups of Object.values(
127
+ hooks as Record<string, readonly IHookGroupLike[] | undefined>,
128
+ )) {
129
+ for (const group of groups ?? []) {
130
+ for (const hook of group.hooks ?? []) {
131
+ if (hook.type === 'command' && typeof hook.command === 'string')
132
+ commands.push(hook.command);
133
+ }
134
+ }
135
+ }
136
+ };
137
+ collect(settings.merged.hooks);
138
+ for (const inspection of plugins) for (const plugin of inspection.loaded) collect(plugin.hooks);
139
+ return commands;
140
+ }
141
+
142
+ function hookChecks(
143
+ settings: ISettingsInspection,
144
+ plugins: readonly IBundlePluginInspection[],
145
+ deps: IDoctorDeps,
146
+ ): IDoctorCheck[] {
147
+ const commands = commandHooks(settings, plugins);
148
+ const missing = commands.filter((command) => !deps.resolveCommand(command));
149
+ return [
150
+ commands.length === 0
151
+ ? {
152
+ id: 'hooks',
153
+ label: 'Hooks',
154
+ status: 'not-configured',
155
+ cause: 'no command hooks configured',
156
+ }
157
+ : missing.length === 0
158
+ ? {
159
+ id: 'hooks',
160
+ label: 'Hooks',
161
+ status: 'ok',
162
+ cause: `${commands.length} command hook(s); every executable resolves`,
163
+ }
164
+ : {
165
+ id: 'hooks',
166
+ label: 'Hooks',
167
+ status: 'warn',
168
+ cause: `${missing.length} command hook executable(s) not found on PATH`,
169
+ detail: missing.map((command) => command.split(/\s+/)[0] ?? command),
170
+ },
171
+ {
172
+ id: 'hooks.execution',
173
+ label: 'Hook execution',
174
+ status: 'not-probed',
175
+ cause: 'the doctor does not run hooks',
176
+ },
177
+ ];
178
+ }
179
+
180
+ function mcpServerCheck(server: IBundlePluginMcpServer, deps: IDoctorDeps): IDoctorCheck {
181
+ const id = `mcp.plugin.${server.pluginId}.${server.name}`;
182
+ if (server.transport === 'stdio' && server.command !== undefined) {
183
+ return deps.resolveCommand(server.command)
184
+ ? {
185
+ id,
186
+ label: 'MCP server (plugin)',
187
+ status: 'ok',
188
+ path: server.mcpPath,
189
+ cause: `stdio command ${server.command} resolves`,
190
+ }
191
+ : {
192
+ id,
193
+ label: 'MCP server (plugin)',
194
+ status: 'warn',
195
+ path: server.mcpPath,
196
+ cause: `stdio command ${server.command} not found on PATH`,
197
+ detail: server.envKeys.length === 0 ? [] : [`env keys: ${server.envKeys.join(', ')}`],
198
+ };
199
+ }
200
+ if (server.transport === 'http' && server.url !== undefined) {
201
+ try {
202
+ new URL(server.url);
203
+ return {
204
+ id,
205
+ label: 'MCP server (plugin)',
206
+ status: 'ok',
207
+ path: server.mcpPath,
208
+ cause: `url ${server.url}`,
209
+ };
210
+ } catch {
211
+ // allow-fallback: an unparseable url is the finding
212
+ return {
213
+ id,
214
+ label: 'MCP server (plugin)',
215
+ status: 'warn',
216
+ path: server.mcpPath,
217
+ cause: 'url is not parseable',
218
+ };
219
+ }
220
+ }
221
+ return {
222
+ id,
223
+ label: 'MCP server (plugin)',
224
+ status: 'warn',
225
+ path: server.mcpPath,
226
+ cause: 'neither command nor url declared',
227
+ };
228
+ }
229
+
230
+ function mcpChecks(
231
+ inputs: IDoctorInputs,
232
+ plugins: readonly IBundlePluginInspection[],
233
+ deps: IDoctorDeps,
234
+ ): IDoctorCheck[] {
235
+ const checks: IDoctorCheck[] = [];
236
+ if (inputs.mcpActivation === undefined) {
237
+ checks.push({
238
+ id: 'mcp.activation',
239
+ label: 'MCP activation',
240
+ status: 'not-configured',
241
+ cause: 'this CLI composes no MCP activation adapter',
242
+ });
243
+ } else {
244
+ const summaries = inputs.mcpActivation.list();
245
+ checks.push({
246
+ id: 'mcp.activation',
247
+ label: 'MCP activation',
248
+ status: 'ok',
249
+ cause: `${summaries.length} server(s) known`,
250
+ });
251
+ for (const summary of summaries) {
252
+ checks.push({
253
+ id: `mcp.${summary.serverId}`,
254
+ label: 'MCP server',
255
+ status: summary.allowed ? 'ok' : 'warn',
256
+ cause: `${summary.status} (${summary.source}): ${summary.reason}`,
257
+ });
258
+ }
259
+ }
260
+ for (const inspection of plugins) {
261
+ for (const server of inspection.mcpServers) checks.push(mcpServerCheck(server, deps));
262
+ for (const fault of inspection.mcpFaults) {
263
+ checks.push({
264
+ id: `mcp.plugin.${fault.pluginId}`,
265
+ label: 'MCP declaration (plugin)',
266
+ status: 'warn',
267
+ path: fault.mcpPath,
268
+ cause: `.mcp.json ${fault.reason}`,
269
+ });
270
+ }
271
+ }
272
+ checks.push({
273
+ id: 'mcp.connection',
274
+ label: 'MCP connection',
275
+ status: 'not-probed',
276
+ cause: 'the doctor does not connect to MCP servers',
277
+ });
278
+ return checks;
279
+ }
280
+
281
+ /** Plugins, skills, hooks and MCP — in that order. */
282
+ export function probeExtensions(
283
+ inputs: IDoctorInputs,
284
+ deps: IDoctorDeps,
285
+ settings: ISettingsInspection,
286
+ ): IDoctorCheck[] {
287
+ // Enablement is read the way the session loader reads it — from the user settings file through the
288
+ // plugin settings store — so the doctor and the session agree even where that store's own policy is
289
+ // a separate open item (recorded on #2670).
290
+ const plugins = loadHostBundlePluginInspectionFromScopes(inputs.pluginsDirs, {
291
+ settingsPath: inputs.userSettingsPath,
292
+ });
293
+ return [
294
+ ...pluginChecks(plugins),
295
+ ...skillChecks(inputs),
296
+ ...hookChecks(settings, plugins, deps),
297
+ ...mcpChecks(inputs, plugins, deps),
298
+ ];
299
+ }
@@ -0,0 +1,89 @@
1
+ /** Node defaults for {@link IDoctorDeps}: a TCP connect probe and read-only filesystem facts. */
2
+ import { accessSync, constants, existsSync, statSync } from 'node:fs';
3
+ import { createConnection } from 'node:net';
4
+ import { delimiter, isAbsolute, join } from 'node:path';
5
+
6
+ import { ownerOnlyGuarantee } from '@robota-sdk/agent-core/node';
7
+
8
+ import type { IDoctorDeps, IDoctorEndpointProbeResult, IDoctorPathFacts } from './doctor-types.js';
9
+
10
+ const NETWORK_CHECK_TIMEOUT_MS = 3000;
11
+ const MS_PER_SECOND = 1000;
12
+ const MODE_BITS = 0o777;
13
+
14
+ /** TCP connect with a bounded timeout; a refusal or a timeout is a result, never a throw. */
15
+ export function probeEndpointViaSocket(
16
+ host: string,
17
+ port: number,
18
+ ): Promise<IDoctorEndpointProbeResult> {
19
+ return new Promise((resolve) => {
20
+ const start = Date.now();
21
+ const socket = createConnection({ host, port });
22
+ const timeout = setTimeout(() => {
23
+ socket.destroy();
24
+ resolve({
25
+ reachable: false,
26
+ error: `timeout (${NETWORK_CHECK_TIMEOUT_MS / MS_PER_SECOND}s)`,
27
+ });
28
+ }, NETWORK_CHECK_TIMEOUT_MS);
29
+ socket.on('connect', () => {
30
+ clearTimeout(timeout);
31
+ socket.destroy();
32
+ resolve({ reachable: true, elapsedMs: Date.now() - start });
33
+ });
34
+ socket.on('error', (error) => {
35
+ clearTimeout(timeout);
36
+ resolve({ reachable: false, error: error.message });
37
+ });
38
+ });
39
+ }
40
+
41
+ /** Existence, directory-ness, `W_OK` and mode — probed without writing. */
42
+ export function inspectPathFacts(path: string): IDoctorPathFacts {
43
+ if (!existsSync(path)) return { exists: false, isDirectory: false, writable: false };
44
+ let isDirectory = false;
45
+ let mode: number | undefined;
46
+ try {
47
+ const stat = statSync(path);
48
+ isDirectory = stat.isDirectory();
49
+ mode = stat.mode & MODE_BITS;
50
+ } catch {
51
+ // allow-fallback: a path that exists but cannot be stat'ed is reported as not a writable directory
52
+ return { exists: true, isDirectory: false, writable: false };
53
+ }
54
+ let writable = false;
55
+ try {
56
+ accessSync(path, constants.W_OK);
57
+ writable = true;
58
+ } catch {
59
+ // allow-fallback: W_OK refused IS the fact being reported
60
+ writable = false;
61
+ }
62
+ return { exists: true, isDirectory, writable, ...(mode === undefined ? {} : { mode }) };
63
+ }
64
+
65
+ /** A bare command resolves through `PATH`; a path-form command must exist as given. */
66
+ export function resolveCommandOnPath(
67
+ command: string,
68
+ env: Readonly<Record<string, string | undefined>> = process.env,
69
+ ): boolean {
70
+ const executable = command.trim().split(/\s+/)[0];
71
+ if (executable === undefined || executable.length === 0) return false;
72
+ if (isAbsolute(executable) || executable.includes('/')) return existsSync(executable);
73
+ const extensions = process.platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : [''];
74
+ return (env.PATH ?? '')
75
+ .split(delimiter)
76
+ .filter((dir) => dir.length > 0)
77
+ .some((dir) => extensions.some((ext) => existsSync(join(dir, executable + ext))));
78
+ }
79
+
80
+ export function createNodeDoctorDeps(
81
+ env: Readonly<Record<string, string | undefined>> = process.env,
82
+ ): IDoctorDeps {
83
+ return {
84
+ probeEndpoint: probeEndpointViaSocket,
85
+ inspectPath: inspectPathFacts,
86
+ resolveCommand: (command) => resolveCommandOnPath(command, env),
87
+ ownerOnlyGuarantee: () => ownerOnlyGuarantee(),
88
+ };
89
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The doctor's single rendering boundary for secrets (OBSERVABILITY-1991).
3
+ *
4
+ * Defence in depth, not the first line: the inspection APIs the probes read already return facts
5
+ * (states, paths, issue paths, env KEY names) rather than file content. This pass exists for the
6
+ * text the doctor cannot prevent from carrying a value — an owner error message, a URL a user typed
7
+ * a credential into — and for the case the structural layer cannot see: a literal that IS a secret
8
+ * because the settings layers or the environment say so.
9
+ */
10
+
11
+ import { SettingsParseError } from '@robota-sdk/agent-framework';
12
+
13
+ import type { TSettings } from '@robota-sdk/agent-framework';
14
+
15
+ const REDACTED = '[redacted]';
16
+
17
+ /**
18
+ * `scheme://user:pass@host` → `scheme://[redacted]@host`. The scheme is bounded: an unbounded run,
19
+ * retried from every word start, was quadratic on long text that never reaches `://`.
20
+ */
21
+ const URL_USERINFO = /(\b[a-z][a-z0-9+.-]{0,31}:\/\/)([^\s/@]+)@/gi;
22
+ /** `Bearer <token>` in any casing. */
23
+ const BEARER_TOKEN = /\b(bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
24
+ /** Vendor-key shapes: `sk-…`, `sk-ant-…`, `AIza…`, `ghp_…`, `xox?-…`. */
25
+ const KEY_SHAPES =
26
+ /\b(?:sk-[A-Za-z0-9_-]{8,}|AIza[0-9A-Za-z_-]{20,}|ghp_[A-Za-z0-9]{20,}|xox[abprs]-[A-Za-z0-9-]{10,})\b/g;
27
+
28
+ const MIN_SECRET_LENGTH = 4;
29
+ /** The quoted excerpt of the input a JSON parser embeds in its message: `..."<snippet>"...`. */
30
+ const PARSER_SNIPPET = /\.\.\."[^"]*"\.\.\./g;
31
+
32
+ function escapeRegExp(value: string): string {
33
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34
+ }
35
+
36
+ /**
37
+ * Mask every known secret value and every credential-shaped token in `text`.
38
+ *
39
+ * `secrets` are literal values the doctor learned from its inputs — the resolved credential, every
40
+ * literal `apiKey` in any settings layer (active or not), every value a `$ENV:` reference names,
41
+ * every `env` map value. Values shorter than four characters are not masked: they cannot be
42
+ * credentials and masking them would shred ordinary words.
43
+ */
44
+ export function redactDiagnosticText(text: string, secrets: Iterable<string> = []): string {
45
+ let out = text;
46
+ const seen = new Set<string>();
47
+ for (const secret of secrets) {
48
+ if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH || seen.has(secret))
49
+ continue;
50
+ seen.add(secret);
51
+ out = out.replace(new RegExp(escapeRegExp(secret), 'g'), REDACTED);
52
+ }
53
+ out = out.replace(URL_USERINFO, `$1${REDACTED}@`);
54
+ out = out.replace(BEARER_TOKEN, `$1 ${REDACTED}`);
55
+ out = out.replace(KEY_SHAPES, REDACTED);
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * An owner error as the doctor may show it. A parse error is named by its class and file only —
61
+ * its message quotes the file around the fault, which is exactly the content a broken settings
62
+ * layer never had a chance to contribute to the secret list. Any other message loses a parser
63
+ * snippet it may carry; the redactor still runs over the result.
64
+ */
65
+ export function describeDiagnosticError(error: Error): string {
66
+ if (error instanceof SettingsParseError) {
67
+ return `${error.name}: ${error.filePath} is not valid JSON (see the settings check)`;
68
+ }
69
+ return `${error.name}: ${error.message.replace(PARSER_SNIPPET, '…')}`;
70
+ }
71
+
72
+ /**
73
+ * Collect the literal secrets a set of parsed settings layers and the environment expose:
74
+ * `providers.*.apiKey`, `provider.apiKey` (literal, or the value of a `$ENV:` reference), and
75
+ * every `env` map value. The doctor never RENDERS any of these; they feed the redactor so that a
76
+ * value which nonetheless reaches a message is masked.
77
+ */
78
+ export function collectSettingsSecrets(
79
+ layers: ReadonlyArray<TSettings | undefined>,
80
+ env: Readonly<Record<string, string | undefined>>,
81
+ ): string[] {
82
+ const secrets = new Set<string>();
83
+ const addKey = (value: string | undefined): void => {
84
+ if (value === undefined) return;
85
+ if (value.startsWith('$ENV:')) {
86
+ const resolved = env[value.slice('$ENV:'.length).trim()];
87
+ if (resolved !== undefined) secrets.add(resolved);
88
+ return;
89
+ }
90
+ secrets.add(value);
91
+ };
92
+ for (const layer of layers) {
93
+ if (layer === undefined) continue;
94
+ addKey(layer.provider?.apiKey);
95
+ for (const profile of Object.values(layer.providers ?? {})) addKey(profile.apiKey);
96
+ for (const value of Object.values(layer.env ?? {})) secrets.add(value);
97
+ }
98
+ return [...secrets];
99
+ }
@@ -0,0 +1,70 @@
1
+ /** Plain-text rendering of a doctor report — the same lines for the shell route and `/doctor`. */
2
+ import type { IDoctorCheck, IDoctorReport, TDoctorCheckStatus } from './doctor-types.js';
3
+
4
+ const ICON: Record<TDoctorCheckStatus, string> = {
5
+ ok: '✓',
6
+ warn: '⚠',
7
+ fail: '✗',
8
+ 'not-configured': '○',
9
+ 'not-probed': '–',
10
+ };
11
+
12
+ /** Optional product wording supplied by the host; omission keeps diagnostics product-neutral. */
13
+ export interface IDoctorDisplayVocabulary {
14
+ readonly title?: string;
15
+ readonly productName?: string;
16
+ readonly formatRepairCommand?: (checkId: string) => string;
17
+ readonly repairOffer?: string;
18
+ }
19
+
20
+ function renderCheck(check: IDoctorCheck, display: IDoctorDisplayVocabulary): string[] {
21
+ const head = ` ${ICON[check.status]} ${check.label} [${check.id}] ${check.status}${check.cause === undefined ? '' : `: ${check.cause}`}`;
22
+ const lines = [head];
23
+ if (check.path !== undefined) lines.push(` path: ${check.path}`);
24
+ for (const line of check.detail ?? []) lines.push(` ${line}`);
25
+ if (check.repair !== undefined) {
26
+ const repairAction = display.formatRepairCommand?.(check.repair);
27
+ lines.push(
28
+ ` repair: ${repairAction ?? `request repair for ${check.repair} through your host`}`,
29
+ );
30
+ }
31
+ return lines;
32
+ }
33
+
34
+ function summary(report: IDoctorReport, display: IDoctorDisplayVocabulary): string {
35
+ const productName = display.productName?.trim();
36
+ if (report.failCount === 0 && report.warnCount === 0)
37
+ return productName
38
+ ? `✓ All checks passed. ${productName} is ready to use.`
39
+ : '✓ All checks passed.';
40
+ if (report.failCount > 0)
41
+ return productName
42
+ ? `✗ ${report.failCount} issue(s) found. Fix the items above to use ${productName}.`
43
+ : `✗ ${report.failCount} issue(s) found. Fix the items above before use.`;
44
+ return productName
45
+ ? `⚠ ${report.warnCount} warning(s). ${productName} may work but check the items above.`
46
+ : `⚠ ${report.warnCount} warning(s). Check the items above.`;
47
+ }
48
+
49
+ /** Render the report as lines: a title, every check, then the summary and the repair offers. */
50
+ export function renderDoctorReport(
51
+ report: IDoctorReport,
52
+ title = 'Doctor',
53
+ display: IDoctorDisplayVocabulary = {},
54
+ ): string[] {
55
+ const lines = [
56
+ '',
57
+ title,
58
+ '',
59
+ ...report.checks.flatMap((check) => renderCheck(check, display)),
60
+ '',
61
+ summary(report, display),
62
+ ];
63
+ if (report.repairable.length > 0) {
64
+ lines.push(
65
+ ` repairable: ${report.repairable.join(', ')} — ${display.repairOffer ?? 'ask your host to apply a repair'}`,
66
+ );
67
+ }
68
+ lines.push('');
69
+ return lines;
70
+ }