@robota-sdk/agent-command 3.0.0-beta.100

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 (251) hide show
  1. package/CHANGELOG.md +1351 -0
  2. package/LICENSE +661 -0
  3. package/README.md +100 -0
  4. package/dist/node/index.cjs +121 -0
  5. package/dist/node/index.d.cts +1053 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1053 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +122 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +83 -0
  12. package/src/advisor/advisor-command-module.ts +47 -0
  13. package/src/advisor/advisor-command.test.ts +102 -0
  14. package/src/advisor/advisor-command.ts +55 -0
  15. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  16. package/src/advisor/index.ts +6 -0
  17. package/src/agent/__tests__/agent-command.test.ts +597 -0
  18. package/src/agent/agent-command-module.ts +89 -0
  19. package/src/agent/agent-command-parser.ts +180 -0
  20. package/src/agent/agent-command.ts +255 -0
  21. package/src/agent/index.ts +7 -0
  22. package/src/background/__tests__/background-command-module.test.ts +261 -0
  23. package/src/background/background-command-module.ts +52 -0
  24. package/src/background/background-command.ts +65 -0
  25. package/src/background/index.ts +6 -0
  26. package/src/command-module-utils.test.ts +37 -0
  27. package/src/command-module-utils.ts +35 -0
  28. package/src/compact/__tests__/compact-command-module.test.ts +181 -0
  29. package/src/compact/compact-command-module.ts +59 -0
  30. package/src/compact/compact-command.ts +33 -0
  31. package/src/compact/index.ts +6 -0
  32. package/src/context/__tests__/context-command-module.test.ts +502 -0
  33. package/src/context/auto-compact-format.ts +25 -0
  34. package/src/context/context-breakdown.ts +295 -0
  35. package/src/context/context-command-module.ts +82 -0
  36. package/src/context/context-command.ts +291 -0
  37. package/src/context/index.ts +6 -0
  38. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
  39. package/src/default/__tests__/default-command-modules.test.ts +192 -0
  40. package/src/default/__tests__/model-exposure.test.ts +269 -0
  41. package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
  42. package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
  43. package/src/default/default-command-modules.ts +247 -0
  44. package/src/default/index.ts +5 -0
  45. package/src/devices/__tests__/devices-command-module.test.ts +299 -0
  46. package/src/devices/devices-command-module.ts +308 -0
  47. package/src/devices/devices-command-port.ts +143 -0
  48. package/src/devices/index.ts +14 -0
  49. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  50. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  51. package/src/doctor/__tests__/doctor-fixture.ts +146 -0
  52. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  53. package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
  54. package/src/doctor/doctor-command-module.ts +132 -0
  55. package/src/doctor/doctor-extensions-probe.ts +323 -0
  56. package/src/doctor/doctor-node-deps.ts +88 -0
  57. package/src/doctor/doctor-redaction.ts +99 -0
  58. package/src/doctor/doctor-render.ts +70 -0
  59. package/src/doctor/doctor-repair.ts +136 -0
  60. package/src/doctor/doctor-runner.ts +122 -0
  61. package/src/doctor/doctor-settings-probe.ts +239 -0
  62. package/src/doctor/doctor-storage-probe.ts +187 -0
  63. package/src/doctor/doctor-types.ts +109 -0
  64. package/src/doctor/index.ts +32 -0
  65. package/src/editor/__tests__/editor-command-functional.test.ts +124 -0
  66. package/src/editor/editor-command-module.ts +53 -0
  67. package/src/editor/editor-command.ts +58 -0
  68. package/src/editor/index.ts +7 -0
  69. package/src/editor/resolve-editor.ts +21 -0
  70. package/src/effort/effort-command-module.ts +51 -0
  71. package/src/effort/effort-command.test.ts +203 -0
  72. package/src/effort/effort-command.ts +136 -0
  73. package/src/effort/index.ts +6 -0
  74. package/src/events/__tests__/events-command.test.ts +73 -0
  75. package/src/events/events-command-module.ts +49 -0
  76. package/src/events/events-command.ts +69 -0
  77. package/src/events/index.ts +2 -0
  78. package/src/exit/__tests__/exit-command-module.test.ts +61 -0
  79. package/src/exit/exit-command-module.ts +47 -0
  80. package/src/exit/exit-command.ts +26 -0
  81. package/src/exit/index.ts +6 -0
  82. package/src/fork/__tests__/fork-command.test.ts +184 -0
  83. package/src/fork/fork-command-module.ts +61 -0
  84. package/src/fork/fork-command.ts +162 -0
  85. package/src/fork/index.ts +8 -0
  86. package/src/git/__tests__/fake-git-port.ts +35 -0
  87. package/src/git/__tests__/git-command-module.test.ts +111 -0
  88. package/src/git/__tests__/git-commit.test.ts +185 -0
  89. package/src/git/git-command-module.ts +138 -0
  90. package/src/git/git-commit.ts +254 -0
  91. package/src/git/index.ts +20 -0
  92. package/src/goal/__tests__/goal-command.test.ts +80 -0
  93. package/src/goal/goal-command-module.ts +49 -0
  94. package/src/goal/goal-command.ts +69 -0
  95. package/src/goal/index.ts +6 -0
  96. package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
  97. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  98. package/src/handoff/handoff-command-module.ts +49 -0
  99. package/src/handoff/handoff-command.ts +187 -0
  100. package/src/handoff/index.ts +6 -0
  101. package/src/help/__tests__/help-command-module.test.ts +134 -0
  102. package/src/help/__tests__/help-command.test.ts +130 -0
  103. package/src/help/help-command-module.ts +47 -0
  104. package/src/help/help-command.ts +11 -0
  105. package/src/help/index.ts +6 -0
  106. package/src/index.ts +163 -0
  107. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  108. package/src/keybindings/index.ts +6 -0
  109. package/src/keybindings/keybindings-command-module.ts +88 -0
  110. package/src/language/__tests__/language-command-module.test.ts +126 -0
  111. package/src/language/index.ts +6 -0
  112. package/src/language/language-command-module.ts +55 -0
  113. package/src/language/language-command.ts +52 -0
  114. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
  115. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
  116. package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
  117. package/src/mcp-activation/index.ts +12 -0
  118. package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
  119. package/src/mcp-activation/mcp-activation-command.ts +662 -0
  120. package/src/mcp-activation/mcp-model-notice.ts +99 -0
  121. package/src/mcp-activation/mcp-model-status.ts +104 -0
  122. package/src/memory/__tests__/memory-command-module.test.ts +476 -0
  123. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  124. package/src/memory/index.ts +6 -0
  125. package/src/memory/memory-command-module.ts +77 -0
  126. package/src/memory/memory-command.ts +326 -0
  127. package/src/mode/__tests__/mode-command-module.test.ts +230 -0
  128. package/src/mode/index.ts +6 -0
  129. package/src/mode/mode-command-module.ts +55 -0
  130. package/src/mode/mode-command.ts +67 -0
  131. package/src/model/__tests__/model-command-module.test.ts +80 -0
  132. package/src/model/__tests__/model-command.test.ts +265 -0
  133. package/src/model/index.ts +6 -0
  134. package/src/model/model-command-module.ts +56 -0
  135. package/src/model/model-command.ts +249 -0
  136. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  137. package/src/output-style/index.ts +6 -0
  138. package/src/output-style/output-style-command-module.ts +143 -0
  139. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  140. package/src/peers/__tests__/peers-command.test.ts +399 -0
  141. package/src/peers/index.ts +9 -0
  142. package/src/peers/peers-command-module.ts +49 -0
  143. package/src/peers/peers-command.ts +220 -0
  144. package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
  145. package/src/permissions/index.ts +6 -0
  146. package/src/permissions/permissions-command-module.ts +55 -0
  147. package/src/permissions/permissions-command.ts +89 -0
  148. package/src/plan/__tests__/plan-command.test.ts +129 -0
  149. package/src/plan/index.ts +9 -0
  150. package/src/plan/plan-command-module.ts +49 -0
  151. package/src/plan/plan-command.ts +92 -0
  152. package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
  153. package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
  154. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  155. package/src/plugin/index.ts +7 -0
  156. package/src/plugin/plugin-command-module.ts +85 -0
  157. package/src/plugin/plugin-command.ts +271 -0
  158. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  159. package/src/preset/__tests__/preset-command-module.test.ts +220 -0
  160. package/src/preset/index.ts +6 -0
  161. package/src/preset/preset-command-module.ts +80 -0
  162. package/src/preset/preset-command.ts +148 -0
  163. package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
  164. package/src/provider/__tests__/org-policy.test.ts +174 -0
  165. package/src/provider/__tests__/provider-command-module.test.ts +884 -0
  166. package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
  167. package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
  168. package/src/provider/__tests__/provider-setup-flow.test.ts +244 -0
  169. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  170. package/src/provider/__tests__/scripted-interaction.ts +40 -0
  171. package/src/provider/index.ts +32 -0
  172. package/src/provider/provider-command-execution.ts +235 -0
  173. package/src/provider/provider-command-module.ts +82 -0
  174. package/src/provider/provider-command-profile-lifecycle.ts +239 -0
  175. package/src/provider/provider-command-profile-operations.ts +218 -0
  176. package/src/provider/provider-command-profile.ts +116 -0
  177. package/src/provider/provider-command-setup.ts +79 -0
  178. package/src/provider/provider-connection-setup.ts +287 -0
  179. package/src/provider/provider-connection-validation.ts +38 -0
  180. package/src/provider/provider-onboarding.ts +73 -0
  181. package/src/provider/provider-prompt-interaction.ts +28 -0
  182. package/src/provider/provider-setup-ask.ts +67 -0
  183. package/src/provider/provider-setup-flow.ts +326 -0
  184. package/src/provider/provider-startup.ts +225 -0
  185. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  186. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  187. package/src/remote-control/index.ts +8 -0
  188. package/src/remote-control/remote-control-command-module.ts +61 -0
  189. package/src/remote-control/remote-control-command.ts +138 -0
  190. package/src/reset/__tests__/reset-command-module.test.ts +63 -0
  191. package/src/reset/index.ts +2 -0
  192. package/src/reset/reset-command-module.ts +47 -0
  193. package/src/reset/reset-command.ts +10 -0
  194. package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
  195. package/src/rewind/index.ts +2 -0
  196. package/src/rewind/rewind-command-module.ts +56 -0
  197. package/src/rewind/rewind-command.ts +279 -0
  198. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  199. package/src/sandbox/index.ts +2 -0
  200. package/src/sandbox/sandbox-command-module.ts +56 -0
  201. package/src/sandbox/sandbox-command.ts +94 -0
  202. package/src/schedule/__tests__/loop-command-functional.test.ts +399 -0
  203. package/src/schedule/__tests__/loop-command.test.ts +594 -0
  204. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  205. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  206. package/src/schedule/__tests__/schedule-command.test.ts +247 -0
  207. package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
  208. package/src/schedule/index.ts +12 -0
  209. package/src/schedule/loop-command.ts +427 -0
  210. package/src/schedule/loop-jitter.ts +41 -0
  211. package/src/schedule/schedule-command-module.ts +184 -0
  212. package/src/schedule/schedule-command.ts +161 -0
  213. package/src/schedule/schedule-spec-parser.ts +70 -0
  214. package/src/session/__tests__/model-pricing.test.ts +66 -0
  215. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  216. package/src/session/__tests__/session-command-module.test.ts +589 -0
  217. package/src/session/index.ts +17 -0
  218. package/src/session/model-pricing.ts +26 -0
  219. package/src/session/session-command-module.ts +197 -0
  220. package/src/session/session-command.ts +272 -0
  221. package/src/settings/index.ts +7 -0
  222. package/src/settings/settings-command-module.ts +47 -0
  223. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  224. package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
  225. package/src/shell/index.ts +8 -0
  226. package/src/shell/resolve-shell.ts +25 -0
  227. package/src/shell/shell-command-module.ts +53 -0
  228. package/src/shell/shell-command.ts +48 -0
  229. package/src/shell/spawn-inherited.ts +32 -0
  230. package/src/skills/__tests__/skill-read-command.test.ts +64 -0
  231. package/src/skills/__tests__/skills-command-module.test.ts +200 -0
  232. package/src/skills/index.ts +7 -0
  233. package/src/skills/skill-read-command.ts +60 -0
  234. package/src/skills/skills-command-module.ts +76 -0
  235. package/src/skills/skills-command.ts +108 -0
  236. package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
  237. package/src/statusline/index.ts +6 -0
  238. package/src/statusline/statusline-command-module.ts +55 -0
  239. package/src/statusline/statusline-command.ts +80 -0
  240. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  241. package/src/terminal-client/index.ts +5 -0
  242. package/src/terminal-client/terminal-client-commands.ts +109 -0
  243. package/src/theme/__tests__/theme-command.test.ts +204 -0
  244. package/src/theme/index.ts +12 -0
  245. package/src/theme/theme-command-module.ts +221 -0
  246. package/src/user-local/__tests__/user-local-command.test.ts +180 -0
  247. package/src/user-local/index.ts +13 -0
  248. package/src/user-local/user-local-command-constants.ts +5 -0
  249. package/src/user-local/user-local-command-module.ts +65 -0
  250. package/src/user-local/user-local-command.ts +214 -0
  251. package/src/user-local/user-local-memory-command.ts +160 -0
@@ -0,0 +1,484 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import {
5
+ createNodeHostSettingsSource,
6
+ createRestrictedWorkspaceProjectAccess,
7
+ } from '@robota-sdk/agent-framework';
8
+ import { afterEach, describe, expect, it } from 'vitest';
9
+
10
+ import { renderDoctorReport } from '../doctor-render.js';
11
+ import { applyDoctorRepair, planDoctorRepair } from '../doctor-repair.js';
12
+ import { runDoctor } from '../doctor-runner.js';
13
+ import {
14
+ MARKERS,
15
+ createDoctorFixture,
16
+ fixtureProviderDefinition,
17
+ installBrokenHome,
18
+ } from './doctor-fixture.js';
19
+
20
+ import type { IDoctorFixture } from './doctor-fixture.js';
21
+ import type { IDoctorReport } from '../doctor-types.js';
22
+
23
+ const fixtures: IDoctorFixture[] = [];
24
+ afterEach(() => {
25
+ for (const fixture of fixtures.splice(0)) fixture.cleanup();
26
+ });
27
+
28
+ function fixture(...args: Parameters<typeof createDoctorFixture>): IDoctorFixture {
29
+ const created = createDoctorFixture(...args);
30
+ fixtures.push(created);
31
+ return created;
32
+ }
33
+
34
+ function byId(report: IDoctorReport, id: string) {
35
+ const check = report.checks.find((candidate) => candidate.id === id);
36
+ if (check === undefined)
37
+ throw new Error(`no check ${id}; have ${report.checks.map((c) => c.id).join(', ')}`);
38
+ return check;
39
+ }
40
+
41
+ const CLEAN_SETTINGS = {
42
+ currentProvider: 'doctor-env',
43
+ providers: {
44
+ 'doctor-env': { type: 'fixture', model: 'fixture-model', apiKey: '$ENV:FIXTURE_DOCTOR_MARKER' },
45
+ 'doctor-inactive': { type: 'fixture', model: 'fixture-model', apiKey: MARKERS.profile },
46
+ },
47
+ };
48
+
49
+ describe('runDoctor (OBSERVABILITY-1991)', () => {
50
+ it('uses neutral provider and trust guidance unless the host supplies its own commands', async () => {
51
+ const f = fixture({ env: {} });
52
+ const neutral = await runDoctor(f.inputs, f.deps);
53
+ expect(byId(neutral, 'provider.resolution').detail?.join(' ')).not.toMatch(/fixture-agent/i);
54
+ expect(byId(neutral, 'workspace.trust').detail?.join(' ')).not.toMatch(/fixture-agent/i);
55
+
56
+ const hosted = await runDoctor(
57
+ {
58
+ ...f.inputs,
59
+ diagnosticGuidance: {
60
+ providerResolution: 'Run atlas configure.',
61
+ projectTrust: 'Run atlas trust.',
62
+ },
63
+ },
64
+ f.deps,
65
+ );
66
+ expect(byId(hosted, 'provider.resolution').detail).toEqual(['Run atlas configure.']);
67
+ expect(byId(hosted, 'workspace.trust').detail).toEqual(['Run atlas trust.']);
68
+ });
69
+
70
+ it('shows each effective settings hook source without embedding payloads in provenance', async () => {
71
+ const f = fixture({ env: {} });
72
+ const userPath = f.write(
73
+ '.fixture-product/settings.json',
74
+ JSON.stringify({
75
+ disabledHooks: ['project-muted'],
76
+ hooks: {
77
+ PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'user-private' }] }],
78
+ },
79
+ }),
80
+ );
81
+ const laterPath = f.write(
82
+ '.claude/settings.json',
83
+ JSON.stringify({
84
+ hooks: {
85
+ PreToolUse: [
86
+ { matcher: '*', hooks: [{ type: 'command', command: 'project-private' }] },
87
+ {
88
+ id: 'project-muted',
89
+ matcher: '*',
90
+ hooks: [{ type: 'prompt', prompt: 'muted-private' }],
91
+ },
92
+ ],
93
+ },
94
+ }),
95
+ );
96
+ const inputs = {
97
+ ...f.inputs,
98
+ settingsSources: [
99
+ createNodeHostSettingsSource('user', userPath),
100
+ createNodeHostSettingsSource('user', laterPath),
101
+ ],
102
+ };
103
+
104
+ const report = await runDoctor(inputs, f.deps);
105
+ const output = renderDoctorReport(report).join('\n');
106
+ const mergeDetail = byId(report, 'settings.merge').detail?.join('\n') ?? '';
107
+
108
+ expect(byId(report, 'settings.merge').detail).toContain(
109
+ `settings hook 1: PreToolUse/command ← ${userPath}`,
110
+ );
111
+ expect(byId(report, 'settings.merge').detail).toContain(
112
+ `settings hook 2: PreToolUse/command ← ${laterPath}`,
113
+ );
114
+ expect(output).toContain(`settings hook 1: PreToolUse/command ← ${userPath}`);
115
+ expect(output).toContain(`settings hook 2: PreToolUse/command ← ${laterPath}`);
116
+ expect(mergeDetail).not.toMatch(/private/);
117
+ expect(output).not.toContain('PreToolUse/prompt');
118
+ });
119
+
120
+ it('TC-05/TC-02: names every failing path and cause in the broken HOME and exits 1', async () => {
121
+ const f = fixture({ env: { FIXTURE_DOCTOR_MARKER: MARKERS.env } });
122
+ installBrokenHome(f);
123
+ const report = await runDoctor(f.inputs, f.deps);
124
+
125
+ expect(report.exitCode).toBe(1);
126
+ expect(byId(report, 'host.fixture').status).toBe('ok');
127
+ const user = byId(report, 'settings.user.fixture-product');
128
+ expect(user).toMatchObject({
129
+ status: 'fail',
130
+ cause: 'empty',
131
+ path: join(f.home, '.fixture-product', 'settings.json'),
132
+ repair: 'settings.user.fixture-product',
133
+ });
134
+ const claude = byId(report, 'settings.user.claude');
135
+ expect(claude.status).toBe('fail');
136
+ expect(claude.path).toBe(join(f.home, '.claude', 'settings.json'));
137
+ expect(claude.cause).toContain('schema-invalid');
138
+ expect(claude.cause).toContain('defaultTrustLevel');
139
+ expect(claude.cause).not.toContain('42');
140
+ expect(claude.repair).toBeUndefined();
141
+ expect(byId(report, 'settings.merge').status).toBe('warn');
142
+
143
+ const broken = byId(report, 'plugin.broken-plugin@fixture-market');
144
+ expect(broken.status).toBe('fail');
145
+ expect(broken.path).toContain(join('broken-plugin', '1.0.0', '.claude-plugin', 'plugin.json'));
146
+ const ghost = byId(report, 'mcp.plugin.mcp-plugin@fixture-market.ghost');
147
+ expect(ghost).toMatchObject({ status: 'warn' });
148
+ expect(ghost.cause).toContain('agent-test-doctor-missing-binary');
149
+ const declaration = byId(report, 'mcp.plugin.mcp-plugin@fixture-market.ready');
150
+ expect(declaration.status).toBe('warn');
151
+ expect(declaration.detail?.join(' ')).toContain('not a runtime source');
152
+ expect(byId(report, 'mcp.activation').status).toBe('not-configured');
153
+ expect(byId(report, 'mcp.connection').status).toBe('not-probed');
154
+ expect(byId(report, 'hooks.execution').status).toBe('not-probed');
155
+ expect(byId(report, 'storage.user').status).toBe('ok');
156
+ expect(byId(report, 'storage.project').status).toBe('not-configured');
157
+ expect(byId(report, 'workspace.trust').status).toBe('warn');
158
+ expect(report.repairable).toEqual(['settings.user.fixture-product']);
159
+ });
160
+
161
+ it('TC-03: no rendered line carries a marker secret from any input', async () => {
162
+ const f = fixture({ env: { FIXTURE_DOCTOR_MARKER: MARKERS.env } });
163
+ installBrokenHome(f);
164
+ // Overwrite the schema-invalid layer with a syntax error beside a secret (the parser-snippet case).
165
+ f.write('.claude/settings.json', `{"providers":{"x":{"apiKey": ${MARKERS.adjacent}}}}`);
166
+ const report = await runDoctor(f.inputs, f.deps);
167
+ const text = renderDoctorReport(report).join('\n');
168
+ for (const marker of Object.values(MARKERS)) expect(text).not.toContain(marker);
169
+ expect(byId(report, 'settings.user.claude').cause).toContain('invalid-json');
170
+ });
171
+
172
+ it('TC-03: masks the resolved credential and inactive-profile literals in a clean configuration', async () => {
173
+ const f = fixture({ env: { FIXTURE_DOCTOR_MARKER: MARKERS.env } });
174
+ f.mkdir('.fixture-product');
175
+ f.mkdir('.fixture-product/sessions');
176
+ f.write('.fixture-product/settings.json', JSON.stringify(CLEAN_SETTINGS));
177
+ const report = await runDoctor(f.inputs, f.deps);
178
+ const text = renderDoctorReport(report).join('\n');
179
+ expect(report.exitCode).toBe(0);
180
+ expect(text).not.toContain(MARKERS.env);
181
+ expect(text).not.toContain(MARKERS.profile);
182
+ expect(byId(report, 'provider.resolution').cause).toContain('fixture (fixture-model)');
183
+ });
184
+
185
+ it('TC-03: a parse error in a settings layer never renders the file excerpt around the fault', async () => {
186
+ const f = fixture({ env: {} });
187
+ f.mkdir('.fixture-product');
188
+ f.mkdir('.fixture-product/sessions');
189
+ // Unquoted value: JSON.parse's message quotes the bytes around the fault, i.e. the credential.
190
+ f.write(
191
+ '.fixture-product/settings.json',
192
+ `{"currentProvider":"p","providers":{"p":{"apiKey": ${MARKERS.adjacent}}}}`,
193
+ );
194
+ const report = await runDoctor(f.inputs, f.deps);
195
+ const text = renderDoctorReport(report, 'fixture-agent doctor').join('\n');
196
+ expect(text).not.toContain(MARKERS.adjacent);
197
+ expect(text).not.toContain(MARKERS.adjacent.slice(0, 10));
198
+ expect(text).not.toContain('..."');
199
+ expect(byId(report, 'provider.resolution')).toMatchObject({
200
+ status: 'fail',
201
+ cause: expect.stringContaining('SettingsParseError'),
202
+ });
203
+ expect(byId(report, 'settings.user.fixture-product')).toMatchObject({ status: 'fail' });
204
+ });
205
+
206
+ it('an unparseable user settings file is a failed plugin check, not a crash', async () => {
207
+ const f = fixture({ env: {} });
208
+ f.mkdir('.fixture-product');
209
+ f.mkdir('.fixture-product/sessions');
210
+ f.write('.fixture-product/settings.json', '{ "enabledPlugins": { "helper": false }, }');
211
+
212
+ const report = await runDoctor(f.inputs, f.deps);
213
+
214
+ expect(byId(report, 'plugins')).toMatchObject({
215
+ status: 'fail',
216
+ cause: expect.stringContaining('not valid JSON'),
217
+ });
218
+ });
219
+
220
+ it('TC-04: derives the reachability host from profile baseURL, then defaults.baseURL, then endpoint', async () => {
221
+ const profile = fixture({ env: {} });
222
+ profile.write(
223
+ '.fixture-product/settings.json',
224
+ JSON.stringify({
225
+ currentProvider: 'p',
226
+ providers: {
227
+ p: { type: 'fixture', model: 'm', apiKey: 'k1234', baseURL: 'http://127.0.0.1:9' },
228
+ },
229
+ }),
230
+ );
231
+ await runDoctor(profile.inputs, profile.deps);
232
+ expect(profile.probes).toEqual([{ host: '127.0.0.1', port: 9 }]);
233
+
234
+ const defaults = fixture({
235
+ env: {},
236
+ providerDefinitions: [
237
+ fixtureProviderDefinition({
238
+ defaults: { model: 'm', baseURL: 'https://compat.example/v1' },
239
+ endpoint: undefined,
240
+ }),
241
+ ],
242
+ });
243
+ defaults.write(
244
+ '.fixture-product/settings.json',
245
+ JSON.stringify({
246
+ currentProvider: 'p',
247
+ providers: { p: { type: 'fixture', model: 'm', apiKey: 'k1234' } },
248
+ }),
249
+ );
250
+ await runDoctor(defaults.inputs, defaults.deps);
251
+ expect(defaults.probes).toEqual([{ host: 'compat.example', port: 443 }]);
252
+
253
+ const endpoint = fixture({ env: {} });
254
+ endpoint.write(
255
+ '.fixture-product/settings.json',
256
+ JSON.stringify({
257
+ currentProvider: 'p',
258
+ providers: { p: { type: 'fixture', model: 'm', apiKey: 'k1234' } },
259
+ }),
260
+ );
261
+ const report = await runDoctor(endpoint.inputs, endpoint.deps);
262
+ expect(endpoint.probes).toEqual([{ host: 'api.fixture.example', port: 443 }]);
263
+ expect(byId(report, 'provider.reachability').cause).toContain('definition endpoint');
264
+
265
+ const none = fixture({
266
+ env: {},
267
+ providerDefinitions: [fixtureProviderDefinition({ endpoint: undefined })],
268
+ });
269
+ none.write(
270
+ '.fixture-product/settings.json',
271
+ JSON.stringify({
272
+ currentProvider: 'p',
273
+ providers: { p: { type: 'fixture', model: 'm', apiKey: 'k1234' } },
274
+ }),
275
+ );
276
+ const noneReport = await runDoctor(none.inputs, none.deps);
277
+ expect(none.probes).toEqual([]);
278
+ expect(byId(noneReport, 'provider.reachability')).toMatchObject({ status: 'warn' });
279
+ });
280
+
281
+ it('TC-04: an unreachable host is warn, not fail, so an offline doctor still exits 0', async () => {
282
+ const f = fixture({ env: {}, reachable: false });
283
+ f.mkdir('.fixture-product');
284
+ f.mkdir('.fixture-product/sessions');
285
+ f.write(
286
+ '.fixture-product/settings.json',
287
+ JSON.stringify({
288
+ currentProvider: 'p',
289
+ providers: { p: { type: 'fixture', model: 'm', apiKey: 'k1234' } },
290
+ }),
291
+ );
292
+ const report = await runDoctor(f.inputs, f.deps);
293
+ expect(byId(report, 'provider.reachability').status).toBe('warn');
294
+ expect(report.exitCode).toBe(0);
295
+ });
296
+
297
+ it('TC-05: a missing user storage root is warn and repairable; non-posix mode is not-probed', async () => {
298
+ const missing = fixture({ env: {} });
299
+ const report = await runDoctor(missing.inputs, missing.deps);
300
+ expect(byId(report, 'storage.user')).toMatchObject({ status: 'warn', repair: 'storage.user' });
301
+ expect(byId(report, 'provider.resolution').status).toBe('fail');
302
+
303
+ const win = fixture({ env: {}, guarantee: 'windows-acl' });
304
+ win.mkdir('.fixture-product');
305
+ win.mkdir('.fixture-product/sessions');
306
+ expect(byId(await runDoctor(win.inputs, win.deps), 'storage.user').status).toBe('not-probed');
307
+ });
308
+
309
+ it('TC-05: a too-open storage directory is warn and repairable', async () => {
310
+ const f = fixture({ env: {} });
311
+ f.mkdir('.fixture-product', 0o755);
312
+ f.mkdir('.fixture-product/sessions', 0o755);
313
+ const report = await runDoctor(f.inputs, f.deps);
314
+ expect(byId(report, 'storage.user')).toMatchObject({ status: 'warn', repair: 'storage.user' });
315
+ });
316
+
317
+ it('TC-07: repair rewrites only an empty user settings file, re-reads before writing, and is idempotent', async () => {
318
+ const f = fixture({ env: {} });
319
+ installBrokenHome(f);
320
+ const path = join(f.home, '.fixture-product', 'settings.json');
321
+
322
+ expect(planDoctorRepair('nope', f.inputs, f.deps)).toMatchObject({ ok: false });
323
+ expect(planDoctorRepair('settings.user.claude', f.inputs, f.deps)).toMatchObject({ ok: false });
324
+
325
+ const refused = await applyDoctorRepair(
326
+ 'settings.user.fixture-product',
327
+ f.inputs,
328
+ f.deps,
329
+ async () => false,
330
+ );
331
+ expect(refused.applied).toBe(false);
332
+ expect(readFileSync(path, 'utf8')).toBe('');
333
+
334
+ // The state changes between confirmation and write: refused, nothing overwritten.
335
+ const raced = await applyDoctorRepair('settings.user.fixture-product', f.inputs, f.deps, async () => {
336
+ f.write('.fixture-product/settings.json', '{"language":"ko"}');
337
+ return true;
338
+ });
339
+ expect(raced).toMatchObject({ applied: false });
340
+ expect(readFileSync(path, 'utf8')).toBe('{"language":"ko"}');
341
+
342
+ f.write('.fixture-product/settings.json', '');
343
+ const applied = await applyDoctorRepair(
344
+ 'settings.user.fixture-product',
345
+ f.inputs,
346
+ f.deps,
347
+ async () => true,
348
+ );
349
+ expect(applied.applied).toBe(true);
350
+ expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({});
351
+ expect(byId(await runDoctor(f.inputs, f.deps), 'settings.user.fixture-product').status).toBe('ok');
352
+
353
+ const again = await applyDoctorRepair(
354
+ 'settings.user.fixture-product',
355
+ f.inputs,
356
+ f.deps,
357
+ async () => true,
358
+ );
359
+ expect(again).toMatchObject({ applied: false });
360
+ });
361
+
362
+ it('TC-07: storage repair creates owner-only directories and is refused once clean', async () => {
363
+ const f = fixture({ env: {} });
364
+ const applied = await applyDoctorRepair('storage.user', f.inputs, f.deps, async () => true);
365
+ expect(applied.applied).toBe(true);
366
+ expect(statSync(join(f.home, '.fixture-product', 'sessions')).mode & 0o077).toBe(0);
367
+ expect(byId(await runDoctor(f.inputs, f.deps), 'storage.user').status).toBe('ok');
368
+ expect(
369
+ await applyDoctorRepair('storage.user', f.inputs, f.deps, async () => true),
370
+ ).toMatchObject({ applied: false });
371
+ });
372
+
373
+ it('TC-05 (skills): a skill definition session discovery throws on is a fail check, not a warning', async () => {
374
+ const f = fixture({ env: {} });
375
+ f.mkdir('.fixture-product');
376
+ f.mkdir('.fixture-product/sessions');
377
+ f.write('.fixture-product/settings.json', JSON.stringify(CLEAN_SETTINGS));
378
+ f.write('.fixture-product/skills/bad-effort/SKILL.md', '---\nname: bad\neffort: extreme\n---\nbody\n');
379
+ f.write('.fixture-product/skills/no-frontmatter/SKILL.md', 'just text\n');
380
+ const report = await runDoctor(f.inputs, f.deps);
381
+ expect(
382
+ byId(report, `skill.${join('.fixture-product', 'skills', 'bad-effort', 'SKILL.md')}`),
383
+ ).toMatchObject({
384
+ status: 'fail',
385
+ cause: 'frontmatter-invalid',
386
+ detail: [expect.stringContaining('effort: expected one of')],
387
+ });
388
+ expect(JSON.stringify(report)).not.toContain('received "extreme"');
389
+ expect(
390
+ byId(report, `skill.${join('.fixture-product', 'skills', 'no-frontmatter', 'SKILL.md')}`).status,
391
+ ).toBe('warn');
392
+ expect(report.exitCode).toBe(1);
393
+ });
394
+
395
+ it('TC-05 (workspace.trust): a trust service that swallowed an error is a fail check naming the cause', async () => {
396
+ const f = fixture({ env: {} });
397
+ const report = await runDoctor(
398
+ {
399
+ ...f.inputs,
400
+ projectAccess: createRestrictedWorkspaceProjectAccess(
401
+ 'store-unavailable',
402
+ f.home,
403
+ new RangeError('trust store is corrupt'),
404
+ ),
405
+ },
406
+ f.deps,
407
+ );
408
+ const trust = byId(report, 'workspace.trust');
409
+ expect(trust.status).toBe('fail');
410
+ expect(trust.cause).toBe('store-unavailable: RangeError: trust store is corrupt');
411
+ expect(trust.path).toBe(f.home);
412
+ expect(report.exitCode).toBe(1);
413
+
414
+ const untrusted = byId(await runDoctor(f.inputs, f.deps), 'workspace.trust');
415
+ expect(untrusted).toMatchObject({ status: 'warn', cause: 'untrusted' });
416
+ });
417
+
418
+ it('TC-05: reports the quarantined provider endpoint without printing either credential', async () => {
419
+ const f = fixture({ env: {} });
420
+ f.mkdir('.fixture-product');
421
+ f.mkdir('.fixture-product/sessions');
422
+ f.write(
423
+ '.fixture-product/settings.json',
424
+ JSON.stringify({
425
+ currentProvider: 'q',
426
+ providers: {
427
+ q: {
428
+ type: 'fixture',
429
+ model: 'm',
430
+ apiKey: MARKERS.profile,
431
+ baseURL: 'http://127.0.0.1:9',
432
+ },
433
+ },
434
+ }),
435
+ );
436
+ f.write(
437
+ '.claude/settings.json',
438
+ JSON.stringify({ providers: { q: { baseURL: 'http://127.0.0.1:10' } } }),
439
+ );
440
+ const report = await runDoctor(f.inputs, f.deps);
441
+ expect(byId(report, 'provider.security')).toMatchObject({
442
+ status: 'warn',
443
+ cause: expect.stringContaining('quarantined'),
444
+ });
445
+ const text = renderDoctorReport(report, 'fixture-agent doctor').join('\n');
446
+ expect(text).not.toContain(MARKERS.profile);
447
+ expect(text).toContain('[provider.security] warn');
448
+ });
449
+
450
+ it('a throwing endpoint probe is a provider fail check, not a rejected run', async () => {
451
+ const f = fixture({ env: {} });
452
+ f.mkdir('.fixture-product');
453
+ f.mkdir('.fixture-product/sessions');
454
+ f.write('.fixture-product/settings.json', JSON.stringify(CLEAN_SETTINGS));
455
+ const deps = {
456
+ ...f.deps,
457
+ probeEndpoint: () => Promise.reject(new RangeError('socket table exhausted')),
458
+ };
459
+ const report = await runDoctor(f.inputs, deps);
460
+ expect(byId(report, 'provider')).toMatchObject({
461
+ status: 'fail',
462
+ cause: expect.stringContaining('RangeError'),
463
+ });
464
+ expect(report.exitCode).toBe(1);
465
+ });
466
+
467
+ it('renders a composition failure as a fail check instead of crashing', async () => {
468
+ const f = fixture({ env: {} });
469
+ const report = await runDoctor(
470
+ {
471
+ ...f.inputs,
472
+ compositionFailure: {
473
+ id: 'workspace.composition',
474
+ label: 'Workspace composition',
475
+ status: 'fail',
476
+ cause: 'WorkspaceAuthorityRequiredError: boom',
477
+ },
478
+ },
479
+ f.deps,
480
+ );
481
+ expect(byId(report, 'workspace.composition').status).toBe('fail');
482
+ expect(report.exitCode).toBe(1);
483
+ });
484
+ });
@@ -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
+ }