@principles/pd-cli 1.75.0 → 1.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,624 +1,325 @@
1
1
  /**
2
- * pd config doctor tests (PRI-299).
2
+ * config-doctor tests PRI-305
3
3
  *
4
4
  * Covers:
5
- * - workspace + openclaw path discovery
6
- * - feature-flag presence + warnings
7
- * - provider classification: healthy / auth_missing / rate_limit / config_missing / parse_failure
8
- * - JSON output is a single parseable object
9
- * - secrets are never leaked
10
- * - CLI command wiring (pd config doctor --help, --workspace, --json)
5
+ * - Internal agent runtime binding readiness
6
+ * - JSON purity
7
+ * - Missing config defaults
8
+ * - Malformed config fail loud
9
+ * - No secret output
10
+ * - Legacy file detection
11
+ * - CLI handler: --json stdout purity and exit code
11
12
  */
12
13
 
13
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
14
15
  import * as fs from 'node:fs';
15
16
  import * as path from 'node:path';
16
17
  import * as os from 'node:os';
17
18
  import * as yaml from 'js-yaml';
18
- import { execFileSync } from 'node:child_process';
19
- import { buildDoctorOutput, resolveProviderConfigFromWorkflows, getOpenClawHome, getOpenClawConfigPath } from '../../src/services/config-doctor.js';
19
+ import { buildDoctorOutput, type DoctorOutput } from '../../src/services/config-doctor.js';
20
+ import { handleConfigDoctor } from '../../src/commands/config-doctor.js';
20
21
 
21
- // ─── Helpers ─────────────────────────────────────────────────────────────────
22
+ // ── Helpers ─────────────────────────────────────────────────────────────────
22
23
 
23
24
  function mkTmpDir(): string {
24
- return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-doctor-test-'));
25
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-config-doctor-test-'));
25
26
  }
26
27
 
27
28
  function rmTmpDir(dir: string): void {
28
29
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
29
30
  }
30
31
 
31
- function writeWorkflowsYaml(stateDir: string, content: unknown): void {
32
- fs.mkdirSync(stateDir, { recursive: true });
33
- const yamlText = typeof content === 'string' ? content : yaml.dump(content);
34
- fs.writeFileSync(path.join(stateDir, 'workflows.yaml'), yamlText, 'utf8');
32
+ function writeConfig(workspaceDir: string, content: string): void {
33
+ const configDir = path.join(workspaceDir, '.pd');
34
+ fs.mkdirSync(configDir, { recursive: true });
35
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), content, 'utf8');
35
36
  }
36
37
 
37
- function buildWorkflowsYaml(opts: { provider?: string; model?: string; apiKeyEnv?: string; baseUrl?: string }): unknown {
38
- return {
39
- version: '1',
40
- funnels: [
41
- {
42
- workflowId: 'pd-runtime-v2-diagnosis',
43
- stages: [],
44
- policy: {
45
- runtimeKind: 'pi-ai',
46
- ...(opts.provider ? { provider: opts.provider } : {}),
47
- ...(opts.model ? { model: opts.model } : {}),
48
- ...(opts.apiKeyEnv ? { apiKeyEnv: opts.apiKeyEnv } : {}),
49
- ...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
50
- },
38
+ function makeValidConfigYaml(): string {
39
+ return yaml.dump({
40
+ version: 1,
41
+ features: {
42
+ prompt: { category: 'core', enabled: true },
43
+ code_tool_hook: { category: 'core', enabled: true },
44
+ defer_archive: { category: 'core', enabled: true },
45
+ correction_observer: { category: 'quiet', enabled: false },
46
+ empathy_observer: { category: 'quiet', enabled: false },
47
+ },
48
+ runtimeProfiles: {
49
+ 'openclaw.default': { type: 'openclaw', source: 'default' },
50
+ 'openclaw.model.lmstudio.qwen3': { type: 'openclaw', provider: 'lmstudio', model: 'qwen3.6-27b-mtp' },
51
+ 'pd.anthropic-sonnet': { type: 'pi-ai', provider: 'anthropic', model: 'claude-3-5-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY', timeoutMs: 300000 },
52
+ },
53
+ internalAgents: {
54
+ defaultRuntime: 'openclaw.default',
55
+ agents: {
56
+ diagnostician: { enabled: true, runtimeProfile: 'openclaw.model.lmstudio.qwen3' },
57
+ dreamer: { enabled: true },
58
+ scribe: { enabled: true },
59
+ artificer: { enabled: true },
60
+ philosopher: { enabled: false },
61
+ evaluator: { enabled: false },
62
+ rolloutReviewer: { enabled: false },
63
+ trainer: { enabled: false },
64
+ correctionObserver: { enabled: false },
65
+ empathyObserver: { enabled: false },
51
66
  },
52
- ],
53
- };
54
- }
55
-
56
- // ─── resolveProviderConfigFromWorkflows ──────────────────────────────────────
57
-
58
- describe('resolveProviderConfigFromWorkflows', () => {
59
- it('returns source=missing when workflows.yaml is absent and no CLI overrides', async () => {
60
- const tmp = mkTmpDir();
61
- try {
62
- const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'));
63
- expect(result.source).toBe('missing');
64
- expect(result.workflowsFound).toBe(false);
65
- expect(result.provider).toBeNull();
66
- expect(result.model).toBeNull();
67
- expect(result.apiKeyEnv).toBeNull();
68
- } finally { rmTmpDir(tmp); }
67
+ },
68
+ ui: { diagnostics: { mode: 'simple' } },
69
69
  });
70
+ }
70
71
 
71
- it('returns source=cli_flag when workflows.yaml is absent but CLI flags supplied', async () => {
72
- const tmp = mkTmpDir();
73
- try {
74
- const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'), {
75
- cliProvider: 'openrouter',
76
- cliModel: 'anthropic/claude-sonnet-4',
77
- cliApiKeyEnv: 'OPENROUTER_API_KEY',
78
- });
79
- expect(result.source).toBe('cli_flag');
80
- expect(result.workflowsFound).toBe(false);
81
- expect(result.provider).toBe('openrouter');
82
- expect(result.model).toBe('anthropic/claude-sonnet-4');
83
- expect(result.apiKeyEnv).toBe('OPENROUTER_API_KEY');
84
- } finally { rmTmpDir(tmp); }
85
- });
72
+ // ── Internal agent runtime binding readiness ─────────────────────────────────
86
73
 
87
- it('parses a well-formed workflows.yaml with the diagnostic funnel', async () => {
74
+ describe('Internal agent runtime binding readiness', () => {
75
+ it('disabled agents show readiness=disabled', async () => {
88
76
  const tmp = mkTmpDir();
77
+ writeConfig(tmp, makeValidConfigYaml());
89
78
  try {
90
- writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
91
- provider: 'openrouter',
92
- model: 'anthropic/claude-sonnet-4',
93
- apiKeyEnv: 'OPENROUTER_API_KEY',
94
- }));
95
- const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'));
96
- expect(result.source).toBe('workflows.yaml');
97
- expect(result.workflowsFound).toBe(true);
98
- expect(result.parseWarning).toBeUndefined();
99
- expect(result.provider).toBe('openrouter');
100
- expect(result.model).toBe('anthropic/claude-sonnet-4');
101
- expect(result.apiKeyEnv).toBe('OPENROUTER_API_KEY');
79
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
80
+ const philosopher = output.internalAgents.find(a => a.name === 'philosopher');
81
+ expect(philosopher).toBeDefined();
82
+ expect(philosopher!.readiness).toBe('disabled');
83
+ expect(philosopher!.enabled).toBe(false);
102
84
  } finally { rmTmpDir(tmp); }
103
85
  });
104
86
 
105
- it('returns parseWarning on YAML parse error', async () => {
87
+ it('enabled agent with openclaw profile shows readiness=ready', async () => {
106
88
  const tmp = mkTmpDir();
89
+ writeConfig(tmp, makeValidConfigYaml());
107
90
  try {
108
- writeWorkflowsYaml(path.join(tmp, '.state'), 'gfi: [unterminated');
109
- const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'));
110
- expect(result.workflowsFound).toBe(true);
111
- expect(result.parseWarning).toBeDefined();
112
- expect(result.parseWarning).toMatch(/parse error/i);
113
- expect(result.provider).toBeNull();
91
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
92
+ const diagnostician = output.internalAgents.find(a => a.name === 'diagnostician');
93
+ expect(diagnostician).toBeDefined();
94
+ expect(diagnostician!.enabled).toBe(true);
95
+ expect(diagnostician!.readiness).toBe('ready');
114
96
  } finally { rmTmpDir(tmp); }
115
97
  });
116
98
 
117
- it('returns parseWarning when diagnostic funnel is missing', async () => {
99
+ it('enabled agent with pi-ai profile and missing env shows readiness=needs_setup', async () => {
118
100
  const tmp = mkTmpDir();
101
+ writeConfig(tmp, makeValidConfigYaml());
102
+ // Ensure ANTHROPIC_API_KEY is not set
103
+ const originalKey = process.env.ANTHROPIC_API_KEY;
104
+ delete process.env.ANTHROPIC_API_KEY;
119
105
  try {
120
- writeWorkflowsYaml(path.join(tmp, '.state'), {
121
- version: '1',
122
- funnels: [{ workflowId: 'some-other-funnel', stages: [], policy: {} }],
106
+ // Create config with an enabled agent using pi-ai profile
107
+ const config = yaml.dump({
108
+ version: 1,
109
+ features: {
110
+ prompt: { category: 'core', enabled: true },
111
+ code_tool_hook: { category: 'core', enabled: true },
112
+ defer_archive: { category: 'core', enabled: true },
113
+ correction_observer: { category: 'quiet', enabled: false },
114
+ empathy_observer: { category: 'quiet', enabled: false },
115
+ },
116
+ runtimeProfiles: {
117
+ 'openclaw.default': { type: 'openclaw', source: 'default' },
118
+ 'pd.anthropic-sonnet': { type: 'pi-ai', provider: 'anthropic', model: 'claude-3-5-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
119
+ },
120
+ internalAgents: {
121
+ defaultRuntime: 'openclaw.default',
122
+ agents: {
123
+ diagnostician: { enabled: true, runtimeProfile: 'pd.anthropic-sonnet' },
124
+ dreamer: { enabled: true },
125
+ scribe: { enabled: true },
126
+ artificer: { enabled: true },
127
+ philosopher: { enabled: false },
128
+ evaluator: { enabled: false },
129
+ rolloutReviewer: { enabled: false },
130
+ trainer: { enabled: false },
131
+ correctionObserver: { enabled: false },
132
+ empathyObserver: { enabled: false },
133
+ },
134
+ },
123
135
  });
124
- const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'));
125
- expect(result.workflowsFound).toBe(true);
126
- expect(result.parseWarning).toMatch(/funnel 'pd-runtime-v2-diagnosis' not found/);
127
- } finally { rmTmpDir(tmp); }
128
- });
136
+ writeConfig(tmp, config);
129
137
 
130
- it('returns parseWarning when funnels is not an array', async () => {
131
- const tmp = mkTmpDir();
132
- try {
133
- writeWorkflowsYaml(path.join(tmp, '.state'), 'version: 1\nfunnels: not-an-array\n');
134
- const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'));
135
- expect(result.workflowsFound).toBe(true);
136
- expect(result.parseWarning).toMatch(/funnels is not an array/);
137
- } finally { rmTmpDir(tmp); }
138
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
139
+ const diagnostician = output.internalAgents.find(a => a.name === 'diagnostician');
140
+ expect(diagnostician).toBeDefined();
141
+ expect(diagnostician!.readiness).toBe('needs_setup');
142
+ expect(diagnostician!.apiKeyEnv).toBe('ANTHROPIC_API_KEY');
143
+ expect(diagnostician!.apiKeyPresent).toBe(false);
144
+ expect(diagnostician!.nextAction).toContain('ANTHROPIC_API_KEY');
145
+ } finally {
146
+ if (originalKey !== undefined) process.env.ANTHROPIC_API_KEY = originalKey;
147
+ rmTmpDir(tmp);
148
+ }
138
149
  });
139
150
  });
140
151
 
141
- // ─── buildDoctorOutput ───────────────────────────────────────────────────────
152
+ // ── JSON purity ──────────────────────────────────────────────────────────────
142
153
 
143
- describe('buildDoctorOutput — config_missing', () => {
144
- it('classifies provider as config_missing when workflows.yaml is absent and no CLI overrides', async () => {
154
+ describe('JSON purity', () => {
155
+ it('output is a single parseable JSON object', async () => {
145
156
  const tmp = mkTmpDir();
146
157
  try {
147
158
  const output = await buildDoctorOutput({ workspaceDir: tmp });
148
- expect(output.status).toBe('failed');
149
- expect(output.reason).toBeDefined();
150
- expect(output.reason).toMatch(/auth_missing|config_missing/);
151
- expect(output.providerHealth).toHaveLength(1);
152
- const ph = output.providerHealth[0];
153
- expect(ph.classification).toBe('config_missing');
154
- expect(ph.provider).toBeNull();
155
- expect(ph.model).toBeNull();
156
- expect(ph.apiKeyPresent).toBe(false);
157
- expect(ph.reason).toBeDefined();
158
- expect(ph.nextAction).toBeDefined();
159
- expect(output.nextActions.length).toBeGreaterThan(0);
159
+ const json = JSON.stringify(output, null, 2);
160
+ const parsed = JSON.parse(json);
161
+ expect(typeof parsed).toBe('object');
162
+ expect(parsed).not.toBeNull();
163
+ expect(Array.isArray(parsed)).toBe(false);
160
164
  } finally { rmTmpDir(tmp); }
161
165
  });
162
166
  });
163
167
 
164
- describe('buildDoctorOutput auth_missing', () => {
165
- it('classifies provider as auth_missing when apiKeyEnv is set in workflows but env var is unset', async () => {
168
+ // ── Missing config defaults ────────────────────────────────────────────────
169
+
170
+ describe('Missing config → defaults', () => {
171
+ it('returns status=ok when config file is absent (uses defaults)', async () => {
166
172
  const tmp = mkTmpDir();
167
173
  try {
168
- writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
169
- provider: 'openrouter',
170
- model: 'anthropic/claude-sonnet-4',
171
- apiKeyEnv: 'PD_DOCTOR_TEST_KEY_NEVER_SET_X9Z',
172
- }));
173
174
  const output = await buildDoctorOutput({ workspaceDir: tmp });
174
- expect(output.status).toBe('failed');
175
- expect(output.reason).toMatch(/auth_missing|config_missing/);
176
- const ph = output.providerHealth[0];
177
- expect(ph.classification).toBe('auth_missing');
178
- expect(ph.provider).toBe('openrouter');
179
- expect(ph.model).toBe('anthropic/claude-sonnet-4');
180
- expect(ph.apiKeyEnv).toBe('PD_DOCTOR_TEST_KEY_NEVER_SET_X9Z');
181
- expect(ph.apiKeyPresent).toBe(false);
182
- expect(ph.reason).toMatch(/not set or empty/);
175
+ // With defaults, MVP core features are enabled, so status should be ok or degraded
176
+ expect(['ok', 'degraded']).toContain(output.status);
177
+ expect(output.featureFlags.source).toBe('defaults');
178
+ expect(output.featureFlags.enabledMvpChannels).toContain('prompt');
183
179
  } finally { rmTmpDir(tmp); }
184
180
  });
181
+ });
182
+
183
+ // ── Malformed config → fail loud ────────────────────────────────────────────
185
184
 
186
- it('classifies provider as auth_missing when apiKeyEnv is not in workflows.yaml at all', async () => {
185
+ describe('Malformed config fail loud', () => {
186
+ it('returns status=failed with reason and nextActions for YAML parse error', async () => {
187
187
  const tmp = mkTmpDir();
188
+ writeConfig(tmp, 'version: [unterminated');
188
189
  try {
189
- writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
190
- provider: 'openrouter',
191
- model: 'anthropic/claude-sonnet-4',
192
- }));
193
190
  const output = await buildDoctorOutput({ workspaceDir: tmp });
194
191
  expect(output.status).toBe('failed');
195
- const ph = output.providerHealth[0];
196
- expect(ph.classification).toBe('auth_missing');
197
- expect(ph.apiKeyEnv).toBeNull();
198
- expect(ph.apiKeyPresent).toBe(false);
192
+ expect(output.reason).toBeTruthy();
193
+ expect(output.nextActions.length).toBeGreaterThan(0);
199
194
  } finally { rmTmpDir(tmp); }
200
195
  });
201
196
  });
202
197
 
203
- describe('buildDoctorOutput healthy', () => {
204
- it('classifies provider as healthy when config is valid and env var is set', async () => {
205
- const tmp = mkTmpDir();
206
- const envName = 'PD_DOCTOR_TEST_KEY_PRESENT_OK';
207
- const previous = process.env[envName];
208
- process.env[envName] = 'redacted-test-value-not-leaked';
209
- try {
210
- writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
211
- provider: 'openrouter',
212
- model: 'anthropic/claude-sonnet-4',
213
- apiKeyEnv: envName,
214
- }));
215
- const output = await buildDoctorOutput({ workspaceDir: tmp });
216
- expect(output.status).toBe('ok');
217
- const ph = output.providerHealth[0];
218
- expect(ph.classification).toBe('healthy');
219
- expect(ph.apiKeyEnv).toBe(envName);
220
- expect(ph.apiKeyPresent).toBe(true);
221
- // Ensure the env var value is NOT leaked
222
- const json = JSON.stringify(output);
223
- expect(json).not.toContain('redacted-test-value-not-leaked');
224
- } finally {
225
- if (previous === undefined) {
226
- delete process.env[envName];
227
- } else {
228
- process.env[envName] = previous;
229
- }
230
- rmTmpDir(tmp);
231
- }
232
- });
233
- });
198
+ // ── No secret output ─────────────────────────────────────────────────────────
234
199
 
235
- describe('buildDoctorOutput rate_limit', () => {
236
- it('classifies provider as rate_limit when state.db contains a 429 signature', async () => {
200
+ describe('No secret output', () => {
201
+ it('output never contains raw API key values', async () => {
237
202
  const tmp = mkTmpDir();
238
- const envName = 'PD_DOCTOR_TEST_KEY_RATELIMIT_OK';
239
- const previous = process.env[envName];
240
- process.env[envName] = 'redacted-test-value';
203
+ writeConfig(tmp, makeValidConfigYaml());
241
204
  try {
242
- writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
243
- provider: 'openrouter',
244
- model: 'anthropic/claude-sonnet-4',
245
- apiKeyEnv: envName,
246
- }));
247
- // Plant a fake state.db that includes a 429 / rate_limit message
248
- await plantStateDbWithMessage(tmp, "Error: 429 too many requests, rpm exhausted");
249
205
  const output = await buildDoctorOutput({ workspaceDir: tmp });
250
- const ph = output.providerHealth[0];
251
- expect(ph.classification).toBe('rate_limit');
252
- expect(ph.reason).toMatch(/rate_limit|signature/i);
253
- expect(output.status).toBe('degraded');
254
- } finally {
255
- if (previous === undefined) {
256
- delete process.env[envName];
257
- } else {
258
- process.env[envName] = previous;
259
- }
260
- rmTmpDir(tmp);
261
- }
206
+ const json = JSON.stringify(output);
207
+ expect(json).not.toContain('sk-ant-');
208
+ expect(json).not.toMatch(/"apiKey"\s*:/);
209
+ expect(json).not.toContain('"gatewayToken"');
210
+ } finally { rmTmpDir(tmp); }
262
211
  });
263
212
 
264
- it('classifies provider as rate_limit when state.db has candidate_failed + rpm exhausted', async () => {
213
+ it('internal agents show apiKeyEnv name, not value', async () => {
265
214
  const tmp = mkTmpDir();
266
- const envName = 'PD_DOCTOR_TEST_KEY_RATELIMIT_2';
267
- const previous = process.env[envName];
268
- process.env[envName] = 'redacted-test-value';
215
+ writeConfig(tmp, makeValidConfigYaml());
269
216
  try {
270
- writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
271
- provider: 'openrouter',
272
- model: 'anthropic/claude-sonnet-4',
273
- apiKeyEnv: envName,
274
- }));
275
- await plantStateDbWithMessage(tmp, "candidate_failed: rpm exhausted for current model");
276
217
  const output = await buildDoctorOutput({ workspaceDir: tmp });
277
- const ph = output.providerHealth[0];
278
- expect(ph.classification).toBe('rate_limit');
279
- } finally {
280
- if (previous === undefined) {
281
- delete process.env[envName];
282
- } else {
283
- process.env[envName] = previous;
218
+ for (const agent of output.internalAgents) {
219
+ if (agent.apiKeyEnv) {
220
+ // apiKeyEnv should be the env var name, not the key value
221
+ expect(agent.apiKeyEnv).toMatch(/^[A-Z_]+$/);
222
+ }
284
223
  }
285
- rmTmpDir(tmp);
286
- }
224
+ } finally { rmTmpDir(tmp); }
287
225
  });
288
226
  });
289
227
 
290
- describe('buildDoctorOutput secrets redaction', () => {
291
- it('never includes the env var value in the output', async () => {
292
- const tmp = mkTmpDir();
293
- const envName = 'PD_DOCTOR_TEST_KEY_REDACTION';
294
- const secret = 'sk-1234567890abcdef-secret-do-not-leak';
295
- const previous = process.env[envName];
296
- process.env[envName] = secret;
297
- try {
298
- writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
299
- provider: 'openrouter',
300
- model: 'anthropic/claude-sonnet-4',
301
- apiKeyEnv: envName,
302
- }));
303
- const output = await buildDoctorOutput({ workspaceDir: tmp });
304
- const json = JSON.stringify(output);
305
- expect(json).not.toContain(secret);
306
- // env var name is allowed to appear; raw value is not
307
- expect(json).toContain(envName);
308
- } finally {
309
- if (previous === undefined) {
310
- delete process.env[envName];
311
- } else {
312
- process.env[envName] = previous;
313
- }
314
- rmTmpDir(tmp);
315
- }
316
- });
317
- });
228
+ // ── Legacy file detection ────────────────────────────────────────────────────
318
229
 
319
- describe('buildDoctorOutput parse_failure (workflows.yaml)', () => {
320
- it('reports parse_failure warning when workflows.yaml is malformed', async () => {
230
+ describe('Legacy file detection', () => {
231
+ it('detects .pd/feature-flags.yaml and reports as legacy', async () => {
321
232
  const tmp = mkTmpDir();
233
+ const pdDir = path.join(tmp, '.pd');
234
+ fs.mkdirSync(pdDir, { recursive: true });
235
+ fs.writeFileSync(path.join(pdDir, 'feature-flags.yaml'), 'prompt:\n enabled: true\n', 'utf8');
322
236
  try {
323
- writeWorkflowsYaml(path.join(tmp, '.state'), 'gfi: [unterminated');
324
237
  const output = await buildDoctorOutput({ workspaceDir: tmp });
325
- expect(output.warnings.length).toBeGreaterThan(0);
326
- expect(output.warnings.some((w) => /parse error/i.test(w))).toBe(true);
327
- expect(output.nextActions.some((a) => /workflows.yaml/i.test(a))).toBe(true);
238
+ expect(output.legacyFilesDetected.length).toBeGreaterThan(0);
239
+ expect(output.legacyFilesDetected[0]).toContain('feature-flags.yaml');
328
240
  } finally { rmTmpDir(tmp); }
329
241
  });
330
242
  });
331
243
 
332
- describe('buildDoctorOutput feature flags', () => {
333
- it('reports enabled MVP channels from feature-flags.yaml', async () => {
244
+ // ── Feature flags from config.yaml ───────────────────────────────────────────
245
+
246
+ describe('Feature flags from config.yaml', () => {
247
+ it('shows enabled MVP channels from config', async () => {
334
248
  const tmp = mkTmpDir();
249
+ writeConfig(tmp, makeValidConfigYaml());
335
250
  try {
336
- const pdDir = path.join(tmp, '.pd');
337
- fs.mkdirSync(pdDir, { recursive: true });
338
- fs.writeFileSync(
339
- path.join(pdDir, 'feature-flags.yaml'),
340
- 'prompt:\n enabled: true\ncode_tool_hook:\n enabled: true\ndefer_archive:\n enabled: true\ngfi:\n enabled: false\n',
341
- 'utf8',
342
- );
343
251
  const output = await buildDoctorOutput({ workspaceDir: tmp });
344
- expect(output.featureFlags.source).toBe('workspace_file');
345
- expect(output.featureFlags.enabledMvpChannels).toEqual(
346
- expect.arrayContaining(['prompt', 'code_tool_hook', 'defer_archive']),
347
- );
348
- expect(output.featureFlags.disabledFlags).toContain('gfi');
252
+ expect(output.featureFlags.enabledMvpChannels).toContain('prompt');
253
+ expect(output.featureFlags.enabledMvpChannels).toContain('code_tool_hook');
254
+ expect(output.featureFlags.enabledMvpChannels).toContain('defer_archive');
349
255
  } finally { rmTmpDir(tmp); }
350
256
  });
351
257
  });
352
258
 
353
- describe('buildDoctorOutput paths', () => {
354
- it('reports existence of PD and OpenClaw config paths', async () => {
355
- const tmp = mkTmpDir();
356
- try {
357
- fs.mkdirSync(path.join(tmp, '.pd'), { recursive: true });
358
- const output = await buildDoctorOutput({ workspaceDir: tmp });
359
- expect(output.pdConfigPaths.workspaceDir.exists).toBe(true);
360
- expect(output.pdConfigPaths.pdDir.exists).toBe(true);
361
- expect(output.pdConfigPaths.featureFlags.exists).toBe(false);
362
- expect(output.openclawConfigPaths.openclawHome.path).toBe(getOpenClawHome());
363
- expect(output.openclawConfigPaths.openclawConfig.path).toBe(getOpenClawConfigPath());
364
- } finally { rmTmpDir(tmp); }
259
+ // ── CLI handler: --json stdout purity and exit code ──────────────────────────
260
+
261
+ describe('CLI handler: --json stdout purity and exit code', () => {
262
+ let stdoutSpy: ReturnType<typeof vi.spyOn>;
263
+ let stderrSpy: ReturnType<typeof vi.spyOn>;
264
+ let originalExitCode: number | undefined;
265
+
266
+ beforeEach(() => {
267
+ stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
268
+ stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
269
+ originalExitCode = process.exitCode;
270
+ process.exitCode = undefined;
365
271
  });
366
- });
367
272
 
368
- // ─── JSON shape contract ─────────────────────────────────────────────────────
273
+ afterEach(() => {
274
+ stdoutSpy.mockRestore();
275
+ stderrSpy.mockRestore();
276
+ process.exitCode = originalExitCode;
277
+ });
369
278
 
370
- describe('buildDoctorOutput JSON output contract', () => {
371
- it('JSON.stringify produces a single parseable object containing all required fields', async () => {
279
+ it('--json outputs exactly one parseable JSON object to stdout', async () => {
372
280
  const tmp = mkTmpDir();
373
281
  try {
374
- const output = await buildDoctorOutput({ workspaceDir: tmp });
375
- const json = JSON.stringify(output, null, 2);
376
- const parsed: unknown = JSON.parse(json);
282
+ await handleConfigDoctor({ workspace: tmp, json: true });
283
+ expect(stdoutSpy).toHaveBeenCalledTimes(1);
284
+ const output = stdoutSpy.mock.calls[0][0] as string;
285
+ const parsed = JSON.parse(output);
377
286
  expect(typeof parsed).toBe('object');
378
287
  expect(parsed).not.toBeNull();
379
- expect(parsed).not.toBeInstanceOf(Array);
380
- // Required top-level fields
288
+ expect(Array.isArray(parsed)).toBe(false);
381
289
  expect(parsed).toHaveProperty('status');
382
- expect(parsed).toHaveProperty('workspaceDir');
383
- expect(parsed).toHaveProperty('pdConfigPaths');
384
- expect(parsed).toHaveProperty('openclawConfigPaths');
385
290
  expect(parsed).toHaveProperty('featureFlags');
386
- expect(parsed).toHaveProperty('providerHealth');
387
- expect(parsed).toHaveProperty('warnings');
388
- expect(parsed).toHaveProperty('nextActions');
389
- // status is one of ok|degraded|failed
390
- expect(['ok', 'degraded', 'failed']).toContain(parsed.status);
291
+ expect(parsed).toHaveProperty('internalAgents');
391
292
  } finally { rmTmpDir(tmp); }
392
293
  });
393
294
 
394
- it('no env var value is present in the JSON output even when key is set', async () => {
395
- const tmp = mkTmpDir();
396
- const envName = 'PD_DOCTOR_TEST_JSON_REDACTION';
397
- const secret = 'super-secret-value-do-not-leak-12345';
398
- const previous = process.env[envName];
399
- process.env[envName] = secret;
400
- try {
401
- writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
402
- provider: 'openrouter',
403
- model: 'anthropic/claude-sonnet-4',
404
- apiKeyEnv: envName,
405
- }));
406
- const output = await buildDoctorOutput({ workspaceDir: tmp });
407
- const json = JSON.stringify(output, null, 2);
408
- expect(json).not.toContain(secret);
409
- } finally {
410
- if (previous === undefined) {
411
- delete process.env[envName];
412
- } else {
413
- process.env[envName] = previous;
414
- }
415
- rmTmpDir(tmp);
416
- }
417
- });
418
- });
419
-
420
- describe('buildDoctorOutput — feature flags failure resilience', () => {
421
- it('does not throw when feature-flags.yaml is a directory, outputs degraded status and flag warnings', async () => {
295
+ it('--json sets process.exitCode=1 on failed status', async () => {
422
296
  const tmp = mkTmpDir();
297
+ writeConfig(tmp, 'version: [unterminated');
423
298
  try {
424
- const pdDir = path.join(tmp, '.pd');
425
- fs.mkdirSync(pdDir, { recursive: true });
426
- fs.mkdirSync(path.join(pdDir, 'feature-flags.yaml'), { recursive: true });
427
-
428
- const output = await buildDoctorOutput({ workspaceDir: tmp });
429
- expect(['degraded', 'failed']).toContain(output.status);
430
- expect(output.featureFlags.source).toBe('unavailable');
431
- expect(output.featureFlags.warnings.some(w => w.includes('feature flags unavailable'))).toBe(true);
432
- expect(output.warnings.some(w => w.includes('feature flags unavailable'))).toBe(true);
433
- expect(output.nextActions.some(a => a.includes('readable file, not a directory'))).toBe(true);
299
+ await handleConfigDoctor({ workspace: tmp, json: true });
300
+ expect(process.exitCode).toBe(1);
301
+ const output = stdoutSpy.mock.calls[0][0] as string;
302
+ const parsed = JSON.parse(output);
303
+ expect(parsed.status).toBe('failed');
304
+ expect(parsed.reason).toBeTruthy();
434
305
  } finally { rmTmpDir(tmp); }
435
306
  });
436
- });
437
-
438
- describe('buildDoctorOutput — CorrectionObserver diagnostics', () => {
439
- it('reports auth_missing when ANTHROPIC_API_KEY is not set (default env fallback)', async () => {
440
- const tmp = mkTmpDir();
441
- const apiKeyEnv = 'ANTHROPIC_API_KEY';
442
- const previous = process.env[apiKeyEnv];
443
- delete process.env[apiKeyEnv];
444
- try {
445
- const output = await buildDoctorOutput({ workspaceDir: tmp });
446
- const co = output.internalAgents.correctionObserver;
447
- expect(co.enabled).toBe(true);
448
- expect(co.status).toBe('auth_missing');
449
- expect(co.configSource).toBe('env');
450
- expect(co.apiKeyEnv).toBe('ANTHROPIC_API_KEY');
451
- expect(co.apiKeyPresent).toBe(false);
452
- expect(co.nextAction).toMatch(/set the environment variable 'ANTHROPIC_API_KEY'/i);
453
- } finally {
454
- if (previous !== undefined) {
455
- process.env[apiKeyEnv] = previous;
456
- }
457
- rmTmpDir(tmp);
458
- }
459
- });
460
307
 
461
- it('reports disabled when correction_observer is disabled in feature-flags.yaml', async () => {
308
+ it('--json does not set exitCode=1 on ok status', async () => {
462
309
  const tmp = mkTmpDir();
463
310
  try {
464
- const pdDir = path.join(tmp, '.pd');
465
- fs.mkdirSync(pdDir, { recursive: true });
466
- fs.writeFileSync(
467
- path.join(pdDir, 'feature-flags.yaml'),
468
- 'correction_observer:\n enabled: false\n',
469
- 'utf8',
470
- );
471
- const output = await buildDoctorOutput({ workspaceDir: tmp });
472
- const co = output.internalAgents.correctionObserver;
473
- expect(co.enabled).toBe(false);
474
- expect(co.status).toBe('disabled');
475
- expect(co.reason).toContain('CorrectionObserver is disabled');
476
- expect(co.nextAction).toContain('correction_observer.enabled=true');
311
+ await handleConfigDoctor({ workspace: tmp, json: true });
312
+ expect(process.exitCode).toBeUndefined();
477
313
  } finally { rmTmpDir(tmp); }
478
314
  });
479
315
 
480
- it('reports configured when pd-correction-observer policy is present in workflows.yaml and key exists', async () => {
481
- const tmp = mkTmpDir();
482
- const customKeyEnv = 'PD_DOCTOR_TEST_CO_KEY';
483
- const previous = process.env[customKeyEnv];
484
- process.env[customKeyEnv] = 'dummy-key-value-not-leaked';
485
- try {
486
- writeWorkflowsYaml(path.join(tmp, '.state'), {
487
- version: '1',
488
- funnels: [
489
- {
490
- workflowId: 'pd-correction-observer',
491
- policy: {
492
- runtimeKind: 'pi-ai',
493
- provider: 'anthropic',
494
- model: 'anthropic/claude-3-5-sonnet',
495
- apiKeyEnv: customKeyEnv,
496
- },
497
- },
498
- ],
499
- });
500
- const output = await buildDoctorOutput({ workspaceDir: tmp });
501
- const co = output.internalAgents.correctionObserver;
502
- expect(co.enabled).toBe(true);
503
- expect(co.status).toBe('configured');
504
- expect(co.configSource).toBe('workflows.yaml');
505
- expect(co.provider).toBe('anthropic');
506
- expect(co.model).toBe('anthropic/claude-3-5-sonnet');
507
- expect(co.apiKeyEnv).toBe(customKeyEnv);
508
- expect(co.apiKeyPresent).toBe(true);
509
- const json = JSON.stringify(output);
510
- expect(json).not.toContain('dummy-key-value-not-leaked');
511
- } finally {
512
- if (previous === undefined) {
513
- delete process.env[customKeyEnv];
514
- } else {
515
- process.env[customKeyEnv] = previous;
516
- }
517
- rmTmpDir(tmp);
518
- }
519
- });
520
- });
521
-
522
- // ─── CLI command wiring ──────────────────────────────────────────────────────
523
-
524
- describe('CLI command wiring (pd config doctor)', () => {
525
- let cliPath: string;
526
- let workspaceRoot: string;
527
-
528
- beforeEach(() => {
529
- workspaceRoot = path.resolve(__dirname, '../../../..');
530
- cliPath = path.join(workspaceRoot, 'packages', 'pd-cli', 'dist', 'index.js');
531
- });
532
-
533
- it('config doctor is registered at pd config doctor --help', () => {
534
- const out = runPd(['config', 'doctor', '--help'], workspaceRoot);
535
- expect(out).toContain('PD');
536
- expect(out).toContain('--workspace');
537
- expect(out).toContain('--json');
538
- });
539
-
540
- it('config subcommand appears in pd --help', () => {
541
- const out = runPd(['--help'], workspaceRoot);
542
- expect(out).toMatch(/\bconfig\b/);
543
- });
544
-
545
- it('pd config doctor --json outputs a single parseable JSON object on stdout', () => {
316
+ it('--json output contains no extra stdout lines (no banners, headers)', async () => {
546
317
  const tmp = mkTmpDir();
547
318
  try {
548
- const out = runPd(['config', 'doctor', '--workspace', tmp, '--json'], workspaceRoot);
549
- expect(out).toBeDefined();
550
- const parsed = JSON.parse(out);
551
- expect(typeof parsed).toBe('object');
552
- expect(parsed).not.toBeNull();
553
- expect(parsed).toHaveProperty('status');
554
- expect(parsed).toHaveProperty('workspaceDir');
555
- expect(parsed).toHaveProperty('pdConfigPaths');
556
- expect(parsed).toHaveProperty('openclawConfigPaths');
557
- expect(parsed).toHaveProperty('featureFlags');
558
- expect(parsed).toHaveProperty('providerHealth');
559
- expect(parsed).toHaveProperty('warnings');
560
- expect(parsed).toHaveProperty('nextActions');
319
+ await handleConfigDoctor({ workspace: tmp, json: true });
320
+ expect(stdoutSpy).toHaveBeenCalledTimes(1);
321
+ const output = stdoutSpy.mock.calls[0][0] as string;
322
+ expect(() => JSON.parse(output)).not.toThrow();
561
323
  } finally { rmTmpDir(tmp); }
562
324
  });
563
-
564
- it('pd config doctor --workspace <missing> still emits structured JSON (no crash)', () => {
565
- const out = runPd(['config', 'doctor', '--workspace', '/nonexistent/workspace/path/x9z', '--json'], workspaceRoot);
566
- const parsed = JSON.parse(out);
567
- expect(parsed.status).toBe('failed');
568
- expect(parsed.providerHealth).toBeInstanceOf(Array);
569
- expect(parsed.nextActions).toBeInstanceOf(Array);
570
- });
571
325
  });
572
-
573
- // ─── Helpers for CLI tests ───────────────────────────────────────────────────
574
-
575
- function runPd(args: string[], cwd: string): string {
576
- try {
577
- return execFileSync('node', ['packages/pd-cli/dist/index.js', ...args], {
578
- encoding: 'utf8',
579
- cwd,
580
- });
581
- } catch (err: unknown) {
582
- if (err && typeof err === 'object' && 'stdout' in err) {
583
- return String((err as { stdout: unknown }).stdout);
584
- }
585
- throw err;
586
- }
587
- }
588
-
589
- // ─── Helpers for state.db planting ───────────────────────────────────────────
590
-
591
- async function plantStateDbWithMessage(workspaceDir: string, errorMessage: string): Promise<void> {
592
- let Database: typeof import('better-sqlite3');
593
- try {
594
- Database = (await import('better-sqlite3')).default;
595
- } catch {
596
- // better-sqlite3 not available; tests requiring this will be skipped.
597
- return;
598
- }
599
- const pdDir = path.join(workspaceDir, '.pd');
600
- fs.mkdirSync(pdDir, { recursive: true });
601
- const dbPath = path.join(pdDir, 'state.db');
602
- const db = new Database(dbPath);
603
- try {
604
- db.exec(`
605
- CREATE TABLE IF NOT EXISTS tasks (
606
- task_id TEXT,
607
- kind TEXT,
608
- status TEXT,
609
- error_message TEXT,
610
- error_category TEXT,
611
- updated_at INTEGER
612
- );
613
- `);
614
- const now = Date.now();
615
- const insert = db.prepare(
616
- `INSERT INTO tasks (task_id, kind, status, error_message, error_category, updated_at)
617
- VALUES (?, ?, ?, ?, ?, ?)`,
618
- );
619
- insert.run('test-task-1', 'diagnostician', 'failed', errorMessage, 'rate_limit', now);
620
- insert.run('test-task-2', 'diagnostician', 'failed', 'previous unrelated error', 'other', now - 60_000);
621
- } finally {
622
- db.close();
623
- }
624
- }