@principles/pd-cli 1.108.0 → 1.109.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.
- package/dist/commands/__tests__/runtime-probe-config.test.d.ts +20 -0
- package/dist/commands/__tests__/runtime-probe-config.test.d.ts.map +1 -0
- package/dist/commands/__tests__/runtime-probe-config.test.js +388 -0
- package/dist/commands/__tests__/runtime-probe-config.test.js.map +1 -0
- package/dist/commands/config-doctor.d.ts.map +1 -1
- package/dist/commands/config-doctor.js +4 -0
- package/dist/commands/config-doctor.js.map +1 -1
- package/dist/commands/runtime.d.ts +6 -0
- package/dist/commands/runtime.d.ts.map +1 -1
- package/dist/commands/runtime.js +139 -13
- package/dist/commands/runtime.js.map +1 -1
- package/dist/index.js +2 -19
- package/dist/index.js.map +1 -1
- package/dist/services/config-doctor.d.ts +2 -0
- package/dist/services/config-doctor.d.ts.map +1 -1
- package/dist/services/config-doctor.js +3 -0
- package/dist/services/config-doctor.js.map +1 -1
- package/dist/services/mainline-snapshot-assembler.d.ts.map +1 -1
- package/dist/services/mainline-snapshot-assembler.js +22 -4
- package/dist/services/mainline-snapshot-assembler.js.map +1 -1
- package/dist/services/pd-config-loader.d.ts +4 -0
- package/dist/services/pd-config-loader.d.ts.map +1 -1
- package/dist/services/pd-config-loader.js +12 -0
- package/dist/services/pd-config-loader.js.map +1 -1
- package/dist/services/resolve-runtime-from-pd-config.d.ts +11 -0
- package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -1
- package/dist/services/resolve-runtime-from-pd-config.js +31 -1
- package/dist/services/resolve-runtime-from-pd-config.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/__tests__/runtime-probe-config.test.ts +431 -0
- package/src/commands/config-doctor.ts +4 -0
- package/src/commands/runtime.ts +133 -13
- package/src/index.ts +2 -19
- package/src/services/config-doctor.ts +6 -0
- package/src/services/mainline-snapshot-assembler.ts +23 -3
- package/src/services/pd-config-loader.ts +17 -0
- package/src/services/resolve-runtime-from-pd-config.ts +41 -0
- package/tests/commands/config-doctor.test.ts +15 -0
- package/tests/services/pd-config-loader.test.ts +21 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRI-402: pd runtime probe reads .pd/config.yaml for pi-ai config.
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* - probe reads config from .pd/config.yaml when --workspace is provided
|
|
6
|
+
* - JSON output includes configSource, runtimeProfileId, runtimeProfileLabel
|
|
7
|
+
* - explicit --provider overrides config.yaml
|
|
8
|
+
* - fail-loud JSON when config.yaml is missing or incomplete
|
|
9
|
+
* - program.parseAsync against real Commander registration (EP-04)
|
|
10
|
+
*
|
|
11
|
+
* ERR refs:
|
|
12
|
+
* - EP-04 (CLI gate): --json stdout single object, process.exit(1) + return
|
|
13
|
+
* - EP-03 (fail loud): structured JSON with reason + nextAction on failure
|
|
14
|
+
* - EP-07 (source alignment): probe and doctor read same config source
|
|
15
|
+
* - ERR-004 (source alignment): profileId/label must match doctor output
|
|
16
|
+
* - ERR-021 (handler-only tests): add program.parseAsync tests
|
|
17
|
+
* - ERR-029 (fail-loud JSON): config missing → structured JSON, not bare error
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
21
|
+
import { Command } from 'commander';
|
|
22
|
+
import * as fs from 'fs';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
import * as os from 'os';
|
|
25
|
+
|
|
26
|
+
// ─── Mocks ─────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
// Mock only probeRuntime so we don't need a real LLM provider.
|
|
29
|
+
// All other core functions (validatePdConfig, computeEffectivePdConfig,
|
|
30
|
+
// resolveAgentRuntimeBinding, etc.) use their real implementations.
|
|
31
|
+
const mockProbeRuntime = vi.fn();
|
|
32
|
+
vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
|
|
33
|
+
const actual = await importOriginal<Record<string, unknown>>();
|
|
34
|
+
return {
|
|
35
|
+
...actual,
|
|
36
|
+
probeRuntime: mockProbeRuntime,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const { handleRuntimeProbe } = await import('../runtime.js');
|
|
41
|
+
|
|
42
|
+
// ─── Test Setup ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const capturedStdout: string[] = [];
|
|
45
|
+
const capturedStderr: string[] = [];
|
|
46
|
+
let capturedExitCode: number | null = null;
|
|
47
|
+
|
|
48
|
+
const originalExit = process.exit;
|
|
49
|
+
const originalLog = console.log;
|
|
50
|
+
const originalError = console.error;
|
|
51
|
+
const originalWarn = console.warn;
|
|
52
|
+
const originalEnv = { ...process.env };
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
capturedExitCode = null;
|
|
56
|
+
capturedStdout.length = 0;
|
|
57
|
+
capturedStderr.length = 0;
|
|
58
|
+
process.exit = vi.fn(((code?: number) => {
|
|
59
|
+
capturedExitCode = code ?? 0;
|
|
60
|
+
}) as typeof process.exit);
|
|
61
|
+
console.log = vi.fn((...args: unknown[]) => { capturedStdout.push(args.join(' ')); });
|
|
62
|
+
console.error = vi.fn((...args: unknown[]) => { capturedStderr.push(args.join(' ')); });
|
|
63
|
+
console.warn = vi.fn(() => { /* capture warnings */ });
|
|
64
|
+
mockProbeRuntime.mockReset();
|
|
65
|
+
process.env = { ...originalEnv, LMSTUDIO_API_KEY: 'test-key-for-pri-402' };
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
process.exit = originalExit;
|
|
70
|
+
console.log = originalLog;
|
|
71
|
+
console.error = originalError;
|
|
72
|
+
console.warn = originalWarn;
|
|
73
|
+
process.env = originalEnv;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ─── Helper: create temp workspace with .pd/config.yaml ────────────────────
|
|
77
|
+
|
|
78
|
+
function createTempWorkspace(configYaml: string): string {
|
|
79
|
+
const tmpDir = path.join(os.tmpdir(), `pd-probe-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
80
|
+
const pdDir = path.join(tmpDir, '.pd');
|
|
81
|
+
fs.mkdirSync(pdDir, { recursive: true });
|
|
82
|
+
// Replace __WORKSPACE_DIR__ placeholder with actual path
|
|
83
|
+
const resolvedYaml = configYaml.replace(/__WORKSPACE_DIR__/g, tmpDir.replace(/\\/g, '/'));
|
|
84
|
+
fs.writeFileSync(path.join(pdDir, 'config.yaml'), resolvedYaml, 'utf-8');
|
|
85
|
+
return tmpDir;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function cleanupWorkspace(dir: string): void {
|
|
89
|
+
try {
|
|
90
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
91
|
+
} catch {
|
|
92
|
+
// best effort
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const PI_AI_CONFIG_YAML = `
|
|
97
|
+
version: 1
|
|
98
|
+
features:
|
|
99
|
+
prompt: { category: core, enabled: true }
|
|
100
|
+
code_tool_hook: { category: core, enabled: true }
|
|
101
|
+
defer_archive: { category: core, enabled: true }
|
|
102
|
+
workspace:
|
|
103
|
+
default: __WORKSPACE_DIR__
|
|
104
|
+
internalAgents:
|
|
105
|
+
defaultRuntime: pi-ai.lmstudio
|
|
106
|
+
agents:
|
|
107
|
+
diagnostician:
|
|
108
|
+
enabled: true
|
|
109
|
+
runtimeProfile: pi-ai.lmstudio
|
|
110
|
+
dreamer:
|
|
111
|
+
enabled: true
|
|
112
|
+
philosopher:
|
|
113
|
+
enabled: true
|
|
114
|
+
scribe:
|
|
115
|
+
enabled: true
|
|
116
|
+
artificer:
|
|
117
|
+
enabled: true
|
|
118
|
+
runtimeProfiles:
|
|
119
|
+
pi-ai.lmstudio:
|
|
120
|
+
type: pi-ai
|
|
121
|
+
provider: lmstudio
|
|
122
|
+
model: qwen3.6-27b-mtp
|
|
123
|
+
apiKeyEnv: LMSTUDIO_API_KEY
|
|
124
|
+
baseUrl: http://localhost:1234/v1
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
// ─── Tests: probe reads config from .pd/config.yaml ────────────────────────
|
|
128
|
+
|
|
129
|
+
describe('PRI-402: probe reads .pd/config.yaml for pi-ai config', () => {
|
|
130
|
+
it('reads provider/model from config.yaml when --workspace provided without --provider', async () => {
|
|
131
|
+
const workspace = createTempWorkspace(PI_AI_CONFIG_YAML);
|
|
132
|
+
try {
|
|
133
|
+
mockProbeRuntime.mockResolvedValue({
|
|
134
|
+
runtimeKind: 'pi-ai',
|
|
135
|
+
provider: 'lmstudio',
|
|
136
|
+
model: 'qwen3.6-27b-mtp',
|
|
137
|
+
health: { healthy: true, degraded: false, warnings: [], lastCheckedAt: '2026-01-01T00:00:00Z' },
|
|
138
|
+
capabilities: { streaming: true },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await handleRuntimeProbe({
|
|
142
|
+
runtime: 'pi-ai',
|
|
143
|
+
workspace,
|
|
144
|
+
json: true,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// probeRuntime should be called with config.yaml values
|
|
148
|
+
expect(mockProbeRuntime).toHaveBeenCalled();
|
|
149
|
+
const callArgs = mockProbeRuntime.mock.calls[0]?.[0];
|
|
150
|
+
expect(callArgs?.provider).toBe('lmstudio');
|
|
151
|
+
expect(callArgs?.model).toBe('qwen3.6-27b-mtp');
|
|
152
|
+
expect(callArgs?.apiKeyEnv).toBe('LMSTUDIO_API_KEY');
|
|
153
|
+
|
|
154
|
+
// JSON output should contain configSource, runtimeProfileId, runtimeProfileLabel
|
|
155
|
+
const output = JSON.parse(capturedStdout.join(''));
|
|
156
|
+
expect(output.configSource).toBe('.pd/config.yaml');
|
|
157
|
+
expect(output.runtimeProfileId).toBe('pi-ai.lmstudio');
|
|
158
|
+
expect(output.runtimeProfileLabel).toBe('pi-ai: lmstudio/qwen3.6-27b-mtp');
|
|
159
|
+
expect(output.ok).toBe(true);
|
|
160
|
+
} finally {
|
|
161
|
+
cleanupWorkspace(workspace);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('explicit --provider overrides config.yaml value', async () => {
|
|
166
|
+
const workspace = createTempWorkspace(PI_AI_CONFIG_YAML);
|
|
167
|
+
try {
|
|
168
|
+
mockProbeRuntime.mockResolvedValue({
|
|
169
|
+
runtimeKind: 'pi-ai',
|
|
170
|
+
provider: 'openrouter',
|
|
171
|
+
model: 'qwen3.6-27b-mtp',
|
|
172
|
+
health: { healthy: true, degraded: false, warnings: [], lastCheckedAt: '2026-01-01T00:00:00Z' },
|
|
173
|
+
capabilities: { streaming: true },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await handleRuntimeProbe({
|
|
177
|
+
runtime: 'pi-ai',
|
|
178
|
+
workspace,
|
|
179
|
+
provider: 'openrouter',
|
|
180
|
+
model: 'anthropic/claude-sonnet-4',
|
|
181
|
+
apiKeyEnv: 'OPENROUTER_API_KEY',
|
|
182
|
+
json: true,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const callArgs = mockProbeRuntime.mock.calls[0]?.[0];
|
|
186
|
+
// CLI flags override config.yaml
|
|
187
|
+
expect(callArgs?.provider).toBe('openrouter');
|
|
188
|
+
expect(callArgs?.model).toBe('anthropic/claude-sonnet-4');
|
|
189
|
+
expect(callArgs?.apiKeyEnv).toBe('OPENROUTER_API_KEY');
|
|
190
|
+
|
|
191
|
+
// Profile info still comes from config.yaml (EP-07: source alignment)
|
|
192
|
+
const output = JSON.parse(capturedStdout.join(''));
|
|
193
|
+
expect(output.configSource).toBe('.pd/config.yaml');
|
|
194
|
+
expect(output.runtimeProfileId).toBe('pi-ai.lmstudio');
|
|
195
|
+
} finally {
|
|
196
|
+
cleanupWorkspace(workspace);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('fail-loud JSON when config.yaml is missing and no --provider', async () => {
|
|
201
|
+
const workspace = path.join(os.tmpdir(), `pd-probe-test-missing-${Date.now()}`);
|
|
202
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
203
|
+
// No .pd/config.yaml created
|
|
204
|
+
try {
|
|
205
|
+
await handleRuntimeProbe({
|
|
206
|
+
runtime: 'pi-ai',
|
|
207
|
+
workspace,
|
|
208
|
+
json: true,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(capturedExitCode).toBe(1);
|
|
212
|
+
const output = JSON.parse(capturedStdout.join(''));
|
|
213
|
+
expect(output.ok).toBe(false);
|
|
214
|
+
expect(output.status).toBe('failed');
|
|
215
|
+
expect(typeof output.reason).toBe('string');
|
|
216
|
+
expect(typeof output.nextAction).toBe('string');
|
|
217
|
+
// Single parseable JSON object (EP-04 Rule 1)
|
|
218
|
+
expect(Array.isArray(output)).toBe(false);
|
|
219
|
+
} finally {
|
|
220
|
+
cleanupWorkspace(workspace);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('fail-loud JSON when provider missing from config.yaml', async () => {
|
|
225
|
+
const workspace = createTempWorkspace(`
|
|
226
|
+
version: 1
|
|
227
|
+
features:
|
|
228
|
+
prompt: { category: core, enabled: true }
|
|
229
|
+
code_tool_hook: { category: core, enabled: true }
|
|
230
|
+
defer_archive: { category: core, enabled: true }
|
|
231
|
+
workspace:
|
|
232
|
+
default: __WORKSPACE_DIR__
|
|
233
|
+
internalAgents:
|
|
234
|
+
defaultRuntime: pi-ai.broken
|
|
235
|
+
agents:
|
|
236
|
+
diagnostician:
|
|
237
|
+
enabled: true
|
|
238
|
+
runtimeProfile: pi-ai.broken
|
|
239
|
+
runtimeProfiles:
|
|
240
|
+
pi-ai.broken:
|
|
241
|
+
type: pi-ai
|
|
242
|
+
# Missing provider, model, apiKeyEnv
|
|
243
|
+
`);
|
|
244
|
+
try {
|
|
245
|
+
await handleRuntimeProbe({
|
|
246
|
+
runtime: 'pi-ai',
|
|
247
|
+
workspace,
|
|
248
|
+
json: true,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(capturedExitCode).toBe(1);
|
|
252
|
+
const output = JSON.parse(capturedStdout.join(''));
|
|
253
|
+
expect(output.ok).toBe(false);
|
|
254
|
+
expect(output.status).toBe('failed');
|
|
255
|
+
expect(typeof output.reason).toBe('string');
|
|
256
|
+
expect(typeof output.nextAction).toBe('string');
|
|
257
|
+
} finally {
|
|
258
|
+
cleanupWorkspace(workspace);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('fail-loud JSON when apiKeyEnv env var is not set', async () => {
|
|
263
|
+
const workspace = createTempWorkspace(PI_AI_CONFIG_YAML);
|
|
264
|
+
try {
|
|
265
|
+
// Remove the API key env var
|
|
266
|
+
delete process.env.LMSTUDIO_API_KEY;
|
|
267
|
+
|
|
268
|
+
await handleRuntimeProbe({
|
|
269
|
+
runtime: 'pi-ai',
|
|
270
|
+
workspace,
|
|
271
|
+
json: true,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(capturedExitCode).toBe(1);
|
|
275
|
+
const output = JSON.parse(capturedStdout.join(''));
|
|
276
|
+
expect(output.ok).toBe(false);
|
|
277
|
+
// When apiKeyEnv is not set, resolveRuntimeConfigFromPdConfig returns
|
|
278
|
+
// a config error (not_ready), so the error comes from the config resolution path
|
|
279
|
+
expect(typeof output.reason).toBe('string');
|
|
280
|
+
expect(typeof output.nextAction).toBe('string');
|
|
281
|
+
} finally {
|
|
282
|
+
cleanupWorkspace(workspace);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('human-readable output includes Profile and Config lines', async () => {
|
|
287
|
+
const workspace = createTempWorkspace(PI_AI_CONFIG_YAML);
|
|
288
|
+
try {
|
|
289
|
+
mockProbeRuntime.mockResolvedValue({
|
|
290
|
+
runtimeKind: 'pi-ai',
|
|
291
|
+
provider: 'lmstudio',
|
|
292
|
+
model: 'qwen3.6-27b-mtp',
|
|
293
|
+
health: { healthy: true, degraded: false, warnings: [], lastCheckedAt: '2026-01-01T00:00:00Z' },
|
|
294
|
+
capabilities: { streaming: true },
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await handleRuntimeProbe({
|
|
298
|
+
runtime: 'pi-ai',
|
|
299
|
+
workspace,
|
|
300
|
+
json: false,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const output = capturedStdout.join('\n');
|
|
304
|
+
expect(output).toContain('Profile:');
|
|
305
|
+
expect(output).toContain('pi-ai: lmstudio/qwen3.6-27b-mtp');
|
|
306
|
+
expect(output).toContain('Config:');
|
|
307
|
+
expect(output).toContain('.pd/config.yaml');
|
|
308
|
+
} finally {
|
|
309
|
+
cleanupWorkspace(workspace);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ─── Tests: program.parseAsync against real Commander registration (EP-04) ──
|
|
315
|
+
|
|
316
|
+
// Import the real command registration function to test actual production wiring
|
|
317
|
+
const { registerRuntimeProbeCommand } = await import('../runtime.js');
|
|
318
|
+
|
|
319
|
+
interface CapturedAction {
|
|
320
|
+
opts: Record<string, unknown> | null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function attachCapture(cmd: Command, state: CapturedAction): void {
|
|
324
|
+
// Override the action handler to capture opts without calling the real handler
|
|
325
|
+
cmd.action((...args: unknown[]) => {
|
|
326
|
+
// Find the opts object (last non-Command, non-null object arg)
|
|
327
|
+
let optsArg: Record<string, unknown> | null = null;
|
|
328
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
329
|
+
const arg: unknown = args[i];
|
|
330
|
+
if (arg !== null && typeof arg === 'object' && !(arg instanceof Command)) {
|
|
331
|
+
optsArg = arg as Record<string, unknown>;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
state.opts = optsArg ?? {};
|
|
336
|
+
// Do NOT call original action (would call handleRuntimeProbe which needs real runtime)
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function freshProgram(): Command {
|
|
341
|
+
const program = new Command();
|
|
342
|
+
program.name('pd').exitOverride();
|
|
343
|
+
return program;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
describe('PRI-402: probe command flag wiring (EP-04 real registration)', () => {
|
|
347
|
+
it('registers --runtime as required option via real registration', () => {
|
|
348
|
+
const program = freshProgram();
|
|
349
|
+
const runtimeCmd = program.command('runtime');
|
|
350
|
+
const probeCmd = registerRuntimeProbeCommand(runtimeCmd);
|
|
351
|
+
|
|
352
|
+
const runtimeOpt = probeCmd.options.find((o) => o.long === '--runtime');
|
|
353
|
+
expect(runtimeOpt).toBeDefined();
|
|
354
|
+
expect(runtimeOpt?.required).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('registers --workspace with -w shorthand via real registration', () => {
|
|
358
|
+
const program = freshProgram();
|
|
359
|
+
const runtimeCmd = program.command('runtime');
|
|
360
|
+
const probeCmd = registerRuntimeProbeCommand(runtimeCmd);
|
|
361
|
+
|
|
362
|
+
const wsOpt = probeCmd.options.find((o) => o.short === '-w');
|
|
363
|
+
expect(wsOpt).toBeDefined();
|
|
364
|
+
expect(wsOpt?.long).toBe('--workspace');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('parses --runtime pi-ai --workspace <dir> --json correctly via real registration', async () => {
|
|
368
|
+
const program = freshProgram();
|
|
369
|
+
const runtimeCmd = program.command('runtime');
|
|
370
|
+
const probeCmd = registerRuntimeProbeCommand(runtimeCmd);
|
|
371
|
+
const captured: CapturedAction = { opts: null };
|
|
372
|
+
attachCapture(probeCmd, captured);
|
|
373
|
+
|
|
374
|
+
await program.parseAsync(['node', 'pd', 'runtime', 'probe', '--runtime', 'pi-ai', '--workspace', '/tmp/test', '--json']);
|
|
375
|
+
|
|
376
|
+
expect(captured.opts).not.toBeNull();
|
|
377
|
+
if (!captured.opts) throw new Error('captured.opts is null');
|
|
378
|
+
expect(captured.opts.runtime).toBe('pi-ai');
|
|
379
|
+
expect(captured.opts.workspace).toBe('/tmp/test');
|
|
380
|
+
expect(captured.opts.json).toBe(true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('parses --runtime config --workspace <dir> correctly via real registration', async () => {
|
|
384
|
+
const program = freshProgram();
|
|
385
|
+
const runtimeCmd = program.command('runtime');
|
|
386
|
+
const probeCmd = registerRuntimeProbeCommand(runtimeCmd);
|
|
387
|
+
const captured: CapturedAction = { opts: null };
|
|
388
|
+
attachCapture(probeCmd, captured);
|
|
389
|
+
|
|
390
|
+
await program.parseAsync(['node', 'pd', 'runtime', 'probe', '--runtime', 'config', '--workspace', '/tmp/test']);
|
|
391
|
+
|
|
392
|
+
expect(captured.opts).not.toBeNull();
|
|
393
|
+
if (!captured.opts) throw new Error('captured.opts is null');
|
|
394
|
+
expect(captured.opts.runtime).toBe('config');
|
|
395
|
+
expect(captured.opts.workspace).toBe('/tmp/test');
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ─── Tests: resolve-runtime-from-pd-config profile extraction ───────────────
|
|
400
|
+
|
|
401
|
+
describe('PRI-402: resolveRuntimeWithOverrides returns profile info', () => {
|
|
402
|
+
it('returns runtimeProfileId and runtimeProfileLabel from config.yaml', async () => {
|
|
403
|
+
const workspace = createTempWorkspace(PI_AI_CONFIG_YAML);
|
|
404
|
+
try {
|
|
405
|
+
const { resolveRuntimeWithOverrides } = await import('../../services/resolve-runtime-from-pd-config.js');
|
|
406
|
+
const result = resolveRuntimeWithOverrides(workspace, {});
|
|
407
|
+
|
|
408
|
+
expect(result.configSource).toBe('.pd/config.yaml');
|
|
409
|
+
expect(result.runtimeProfileId).toBe('pi-ai.lmstudio');
|
|
410
|
+
expect(result.runtimeProfileLabel).toBe('pi-ai: lmstudio/qwen3.6-27b-mtp');
|
|
411
|
+
} finally {
|
|
412
|
+
cleanupWorkspace(workspace);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('returns default profile when config.yaml is missing', async () => {
|
|
417
|
+
const workspace = path.join(os.tmpdir(), `pd-probe-test-noprofile-${Date.now()}`);
|
|
418
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
419
|
+
try {
|
|
420
|
+
const { resolveRuntimeWithOverrides } = await import('../../services/resolve-runtime-from-pd-config.js');
|
|
421
|
+
const result = resolveRuntimeWithOverrides(workspace, {});
|
|
422
|
+
|
|
423
|
+
// Missing config → defaults, which use openclaw.default as defaultRuntime
|
|
424
|
+
expect(result.configSource).toBe('.pd/config.yaml');
|
|
425
|
+
expect(result.runtimeProfileId).toBe('openclaw.default');
|
|
426
|
+
expect(typeof result.runtimeProfileLabel).toBe('string');
|
|
427
|
+
} finally {
|
|
428
|
+
cleanupWorkspace(workspace);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
});
|
|
@@ -109,6 +109,10 @@ function formatTextOutput(output: DoctorOutput): string {
|
|
|
109
109
|
for (const f of output.legacyFilesDetected) {
|
|
110
110
|
lines.push(` [!] ${f}`);
|
|
111
111
|
}
|
|
112
|
+
// PRI-404: Show nextAction for each legacy file
|
|
113
|
+
for (const na of output.legacyFileNextActions) {
|
|
114
|
+
lines.push(` → ${na}`);
|
|
115
|
+
}
|
|
112
116
|
lines.push('');
|
|
113
117
|
}
|
|
114
118
|
|
package/src/commands/runtime.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* HG-01 HARD GATE: This command must deliver.
|
|
9
9
|
*/
|
|
10
10
|
import * as path from 'path';
|
|
11
|
-
import {
|
|
12
|
-
import { PDRuntimeError, isRuntimeConfigError } from '@principles/core/runtime-v2';
|
|
11
|
+
import type { Command } from 'commander';
|
|
12
|
+
import { probeRuntime, PDRuntimeError, isRuntimeConfigError } from '@principles/core/runtime-v2';
|
|
13
13
|
import { resolveRuntimeFromPdConfig, resolveRuntimeWithOverrides } from '../services/resolve-runtime-from-pd-config.js';
|
|
14
14
|
|
|
15
15
|
interface RuntimeProbeOptions {
|
|
@@ -132,6 +132,11 @@ async function handleOpenClawProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
134
|
* pi-ai probe branch — validates flags, calls probeRuntime, formats output.
|
|
135
|
+
*
|
|
136
|
+
* PRI-402: When --workspace is provided without explicit --provider,
|
|
137
|
+
* reads pi-ai config from .pd/config.yaml via resolveRuntimeWithOverrides.
|
|
138
|
+
* JSON output includes configSource, runtimeProfileId, runtimeProfileLabel
|
|
139
|
+
* for alignment with `pd config doctor`.
|
|
135
140
|
*/
|
|
136
141
|
async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
137
142
|
// D-01: flags are required for pi-ai probe unless --workspace is provided (policy fallback)
|
|
@@ -142,6 +147,11 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
|
142
147
|
let baseUrl = opts.baseUrl ?? '';
|
|
143
148
|
let { timeoutMs, maxRetries } = opts;
|
|
144
149
|
|
|
150
|
+
// PRI-402: Track config source and profile info for JSON output
|
|
151
|
+
let configSource: string | null = null;
|
|
152
|
+
let runtimeProfileId: string | null = null;
|
|
153
|
+
let runtimeProfileLabel: string | null = null;
|
|
154
|
+
|
|
145
155
|
// PRI-393: always load workspace policy from .pd/config.yaml (not .state/workflows.yaml)
|
|
146
156
|
if (workspaceDir) {
|
|
147
157
|
const resolved = resolveRuntimeWithOverrides(workspaceDir, {
|
|
@@ -153,6 +163,10 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
|
153
163
|
timeoutMs: opts.timeoutMs,
|
|
154
164
|
});
|
|
155
165
|
for (const w of resolved.legacyWarnings) console.warn(`Warning: ${w}`);
|
|
166
|
+
|
|
167
|
+
// PRI-402: capture profile info regardless of merge result
|
|
168
|
+
({ configSource, runtimeProfileId, runtimeProfileLabel } = resolved);
|
|
169
|
+
|
|
156
170
|
if (resolved.mergedConfig) {
|
|
157
171
|
provider = provider || resolved.mergedConfig.provider || '';
|
|
158
172
|
model = model || resolved.mergedConfig.model || '';
|
|
@@ -161,32 +175,92 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
|
161
175
|
timeoutMs = timeoutMs ?? resolved.mergedConfig.timeoutMs;
|
|
162
176
|
maxRetries = maxRetries ?? resolved.mergedConfig.maxRetries;
|
|
163
177
|
} else if (isRuntimeConfigError(resolved.result)) {
|
|
164
|
-
|
|
178
|
+
// PRI-402: fail-loud JSON when config.yaml is broken (EP-03, EP-04)
|
|
179
|
+
if (opts.json) {
|
|
180
|
+
console.log(JSON.stringify({
|
|
181
|
+
ok: false,
|
|
182
|
+
status: 'failed',
|
|
183
|
+
reason: resolved.result.reason,
|
|
184
|
+
message: resolved.result.message,
|
|
185
|
+
nextAction: resolved.result.nextAction,
|
|
186
|
+
configSource: resolved.configSource,
|
|
187
|
+
}, null, 2));
|
|
188
|
+
} else {
|
|
189
|
+
console.error(`error: could not resolve runtime from .pd/config.yaml — ${resolved.result.message}`);
|
|
190
|
+
console.error(`nextAction: ${resolved.result.nextAction}`);
|
|
191
|
+
}
|
|
192
|
+
process.exit(1);
|
|
193
|
+
return;
|
|
165
194
|
}
|
|
166
195
|
}
|
|
167
196
|
|
|
168
197
|
if (!provider) {
|
|
169
|
-
|
|
170
|
-
|
|
198
|
+
// PRI-402: fail-loud JSON when provider is missing (EP-03, EP-04)
|
|
199
|
+
if (opts.json) {
|
|
200
|
+
console.log(JSON.stringify({
|
|
201
|
+
ok: false,
|
|
202
|
+
status: 'failed',
|
|
203
|
+
reason: 'provider_missing',
|
|
204
|
+
message: '--provider is required for --runtime pi-ai (or set in .pd/config.yaml)',
|
|
205
|
+
nextAction: 'Set provider in .pd/config.yaml runtimeProfiles, or pass --provider explicitly',
|
|
206
|
+
configSource,
|
|
207
|
+
}, null, 2));
|
|
208
|
+
} else {
|
|
209
|
+
console.error("error: --provider is required for --runtime pi-ai (or set in .pd/config.yaml)");
|
|
210
|
+
console.error(" e.g.: pd runtime probe --runtime pi-ai --provider openrouter --model anthropic/claude-sonnet-4 --apiKeyEnv OPENROUTER_API_KEY");
|
|
211
|
+
}
|
|
171
212
|
process.exit(1);
|
|
172
213
|
return;
|
|
173
214
|
}
|
|
174
215
|
if (!model) {
|
|
175
|
-
|
|
176
|
-
|
|
216
|
+
if (opts.json) {
|
|
217
|
+
console.log(JSON.stringify({
|
|
218
|
+
ok: false,
|
|
219
|
+
status: 'failed',
|
|
220
|
+
reason: 'model_missing',
|
|
221
|
+
message: '--model is required for --runtime pi-ai (or set in .pd/config.yaml)',
|
|
222
|
+
nextAction: 'Set model in .pd/config.yaml runtimeProfiles, or pass --model explicitly',
|
|
223
|
+
configSource,
|
|
224
|
+
}, null, 2));
|
|
225
|
+
} else {
|
|
226
|
+
console.error("error: --model is required for --runtime pi-ai (or set in .pd/config.yaml)");
|
|
227
|
+
console.error(" e.g.: pd runtime probe --runtime pi-ai --provider openrouter --model anthropic/claude-sonnet-4 --apiKeyEnv OPENROUTER_API_KEY");
|
|
228
|
+
}
|
|
177
229
|
process.exit(1);
|
|
178
230
|
return;
|
|
179
231
|
}
|
|
180
232
|
if (!apiKeyEnv) {
|
|
181
|
-
|
|
182
|
-
|
|
233
|
+
if (opts.json) {
|
|
234
|
+
console.log(JSON.stringify({
|
|
235
|
+
ok: false,
|
|
236
|
+
status: 'failed',
|
|
237
|
+
reason: 'apiKeyEnv_missing',
|
|
238
|
+
message: '--apiKeyEnv is required for --runtime pi-ai (or set in .pd/config.yaml)',
|
|
239
|
+
nextAction: 'Set apiKeyEnv in .pd/config.yaml runtimeProfiles, or pass --apiKeyEnv explicitly',
|
|
240
|
+
configSource,
|
|
241
|
+
}, null, 2));
|
|
242
|
+
} else {
|
|
243
|
+
console.error("error: --apiKeyEnv is required for --runtime pi-ai (or set in .pd/config.yaml)");
|
|
244
|
+
console.error(" e.g.: pd runtime probe --runtime pi-ai --provider openrouter --model anthropic/claude-sonnet-4 --apiKeyEnv OPENROUTER_API_KEY");
|
|
245
|
+
}
|
|
183
246
|
process.exit(1);
|
|
184
247
|
return;
|
|
185
248
|
}
|
|
186
249
|
|
|
187
250
|
// D-09: check env var exists before calling probeRuntime
|
|
188
251
|
if (!process.env[apiKeyEnv]) {
|
|
189
|
-
|
|
252
|
+
if (opts.json) {
|
|
253
|
+
console.log(JSON.stringify({
|
|
254
|
+
ok: false,
|
|
255
|
+
status: 'failed',
|
|
256
|
+
reason: 'api_key_not_set',
|
|
257
|
+
message: `Environment variable '${apiKeyEnv}' is not set`,
|
|
258
|
+
nextAction: `Set the environment variable '${apiKeyEnv}' with a valid API key`,
|
|
259
|
+
configSource,
|
|
260
|
+
}, null, 2));
|
|
261
|
+
} else {
|
|
262
|
+
console.error(`error: environment variable '${apiKeyEnv}' is not set`);
|
|
263
|
+
}
|
|
190
264
|
process.exit(1);
|
|
191
265
|
return;
|
|
192
266
|
}
|
|
@@ -215,7 +289,9 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
|
215
289
|
if (!result.health.healthy) exitCode = 1;
|
|
216
290
|
|
|
217
291
|
if (opts.json) {
|
|
218
|
-
|
|
292
|
+
// PRI-402: include configSource, runtimeProfileId, runtimeProfileLabel in JSON output
|
|
293
|
+
const jsonOutput: Record<string, unknown> = {
|
|
294
|
+
ok: result.health.healthy,
|
|
219
295
|
status,
|
|
220
296
|
runtimeKind: result.runtimeKind,
|
|
221
297
|
provider: result.provider,
|
|
@@ -223,7 +299,11 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
|
223
299
|
baseUrlPresent: !!baseUrl,
|
|
224
300
|
health: result.health,
|
|
225
301
|
capabilities: result.capabilities,
|
|
226
|
-
}
|
|
302
|
+
};
|
|
303
|
+
if (configSource) jsonOutput.configSource = configSource;
|
|
304
|
+
if (runtimeProfileId) jsonOutput.runtimeProfileId = runtimeProfileId;
|
|
305
|
+
if (runtimeProfileLabel) jsonOutput.runtimeProfileLabel = runtimeProfileLabel;
|
|
306
|
+
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
227
307
|
if (exitCode !== 0) process.exit(exitCode);
|
|
228
308
|
return;
|
|
229
309
|
}
|
|
@@ -233,6 +313,8 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
|
233
313
|
console.log(`Provider: ${result.provider}`);
|
|
234
314
|
console.log(`Model: ${result.model}`);
|
|
235
315
|
if (baseUrl) console.log(`BaseUrl: ${baseUrl}`);
|
|
316
|
+
if (runtimeProfileLabel) console.log(`Profile: ${runtimeProfileLabel}`);
|
|
317
|
+
if (configSource) console.log(`Config: ${configSource}`);
|
|
236
318
|
console.log(`Status: ${status}`);
|
|
237
319
|
console.log('');
|
|
238
320
|
console.log('Health:');
|
|
@@ -260,10 +342,12 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
|
260
342
|
}
|
|
261
343
|
if (opts.json) {
|
|
262
344
|
console.log(JSON.stringify({
|
|
345
|
+
ok: false,
|
|
263
346
|
status: 'failed',
|
|
264
347
|
errorCategory,
|
|
265
348
|
message,
|
|
266
349
|
runtimeKind: 'pi-ai',
|
|
350
|
+
configSource,
|
|
267
351
|
}, null, 2));
|
|
268
352
|
} else {
|
|
269
353
|
console.error(`error: ${message} (${errorCategory})`);
|
|
@@ -279,7 +363,17 @@ async function handlePiAiProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
|
279
363
|
async function handleConfigProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
280
364
|
const workspaceDir = opts.workspace ? path.resolve(opts.workspace) : undefined;
|
|
281
365
|
if (!workspaceDir) {
|
|
282
|
-
|
|
366
|
+
if (opts.json) {
|
|
367
|
+
console.log(JSON.stringify({
|
|
368
|
+
ok: false,
|
|
369
|
+
status: 'failed',
|
|
370
|
+
reason: 'workspace_missing',
|
|
371
|
+
message: '--workspace is required for --runtime config',
|
|
372
|
+
nextAction: 'Provide --workspace <path> pointing to a PD workspace directory',
|
|
373
|
+
}, null, 2));
|
|
374
|
+
} else {
|
|
375
|
+
console.error('error: --workspace is required for --runtime config');
|
|
376
|
+
}
|
|
283
377
|
process.exit(1);
|
|
284
378
|
return;
|
|
285
379
|
}
|
|
@@ -290,6 +384,7 @@ async function handleConfigProbe(opts: RuntimeProbeOptions): Promise<void> {
|
|
|
290
384
|
if (isRuntimeConfigError(resolved.result)) {
|
|
291
385
|
if (opts.json) {
|
|
292
386
|
console.log(JSON.stringify({
|
|
387
|
+
ok: false,
|
|
293
388
|
status: 'failed',
|
|
294
389
|
errorCategory: 'config_error',
|
|
295
390
|
message: resolved.result.message,
|
|
@@ -351,3 +446,28 @@ export async function handleRuntimeProbe(opts: RuntimeProbeOptions): Promise<voi
|
|
|
351
446
|
process.exit(1);
|
|
352
447
|
return;
|
|
353
448
|
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Register the `pd runtime probe` command on a Commander instance.
|
|
452
|
+
* Used by index.ts and tests to ensure real command wiring is verified (EP-04).
|
|
453
|
+
*/
|
|
454
|
+
export function registerRuntimeProbeCommand(runtimeCmd: Command): Command {
|
|
455
|
+
return runtimeCmd
|
|
456
|
+
.command('probe')
|
|
457
|
+
.description('Probe runtime health and capabilities (HG-01 HARD GATE)')
|
|
458
|
+
.requiredOption('-r, --runtime <kind>', "Runtime kind: 'openclaw-cli', 'pi-ai', or 'config'")
|
|
459
|
+
.option('--openclaw-local', 'Use local OpenClaw (mutually exclusive with --openclaw-gateway)')
|
|
460
|
+
.option('--openclaw-gateway', 'Use gateway OpenClaw (mutually exclusive with --openclaw-local)')
|
|
461
|
+
.option('-a, --agent <agentId>', 'Agent ID to probe')
|
|
462
|
+
.option('--provider <name>', 'LLM provider (e.g., openrouter) \u2014 for pi-ai, falls back to .pd/config.yaml')
|
|
463
|
+
.option('--model <id>', 'Model ID (e.g., anthropic/claude-sonnet-4) \u2014 for pi-ai, falls back to .pd/config.yaml')
|
|
464
|
+
.option('--apiKeyEnv <name>', 'Env var name for API key (e.g., OPENROUTER_API_KEY) \u2014 for pi-ai, falls back to .pd/config.yaml')
|
|
465
|
+
.option('--baseUrl <url>', 'Custom base URL for OpenAI-compatible providers \u2014 for pi-ai, falls back to .pd/config.yaml')
|
|
466
|
+
.option('--maxRetries <n>', 'Max retry attempts for LLM failures', parseInt)
|
|
467
|
+
.option('--timeoutMs <ms>', 'Timeout in milliseconds for probe', parseInt)
|
|
468
|
+
.option('-w, --workspace <path>', 'Workspace directory \u2014 loads pi-ai config from .pd/config.yaml')
|
|
469
|
+
.option('--json', 'Output raw JSON')
|
|
470
|
+
.action(async (opts) => {
|
|
471
|
+
await handleRuntimeProbe(opts);
|
|
472
|
+
});
|
|
473
|
+
}
|