@xzzpig/pi-openspec-x 0.1.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/src/cli.ts ADDED
@@ -0,0 +1,442 @@
1
+ /**
2
+ * OpenSpec CLI integration for pi-openspec-x.
3
+ *
4
+ * Responsibilities:
5
+ * - resolve the `openspec` binary on PATH (self-implemented `which`)
6
+ * - probe `--version` at most once per binary per session (module-level cache)
7
+ * - run `--json` subcommands through one execution/parse/validation layer with
8
+ * typed errors: CLI missing (`OpenspecCliMissingError`), non-zero exit or
9
+ * spawn failure (`OpenspecCliExitError`), and malformed output
10
+ * (`OpenspecJsonParseError`)
11
+ *
12
+ * The generic `runOpenspecJson` channel is deliberately reusable: later groups
13
+ * (e.g. the /opsx:plan instructions wiring) add more subcommands on top of it
14
+ * without touching the execution plumbing.
15
+ */
16
+ import { spawnSync } from "node:child_process";
17
+ import type {
18
+ SpawnSyncOptionsWithStringEncoding,
19
+ SpawnSyncReturns,
20
+ } from "node:child_process";
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+
24
+ /** The CLI binary pi-openspec-x drives. */
25
+ const OPENSPEC_COMMAND = "openspec";
26
+
27
+ /** Minimal spawnSync shape this module relies on; injectable for tests. */
28
+ export type SpawnSyncLike = (
29
+ file: string,
30
+ args: readonly string[],
31
+ options: SpawnSyncOptionsWithStringEncoding,
32
+ ) => SpawnSyncReturns<string>;
33
+
34
+ // ── typed errors ────────────────────────────────────────────────────────────
35
+
36
+ /** The openspec binary could not be found on PATH. */
37
+ export class OpenspecCliMissingError extends Error {
38
+ readonly command = OPENSPEC_COMMAND;
39
+
40
+ constructor() {
41
+ super(
42
+ `OpenSpec CLI '${OPENSPEC_COMMAND}' was not found on PATH. Install openspec so pi-openspec-x can provide the official workflow skills.`,
43
+ );
44
+ this.name = "OpenspecCliMissingError";
45
+ }
46
+ }
47
+
48
+ /** openspec ran but exited non-zero, or the process could not be executed. */
49
+ export class OpenspecCliExitError extends Error {
50
+ readonly args: readonly string[];
51
+ readonly exitCode: number | null;
52
+ readonly signal: NodeJS.Signals | null;
53
+ readonly stderr: string;
54
+
55
+ constructor(
56
+ args: readonly string[],
57
+ exitCode: number | null,
58
+ signal: NodeJS.Signals | null,
59
+ stderr: string,
60
+ ) {
61
+ const detail = stderr.trim();
62
+ super(
63
+ `openspec ${args.join(" ")} ${exitCode === null ? `failed (${signal ?? "unknown signal"})` : `exited with code ${exitCode}`}${detail ? `: ${detail}` : ""}`,
64
+ );
65
+ this.name = "OpenspecCliExitError";
66
+ this.args = [...args];
67
+ this.exitCode = exitCode;
68
+ this.signal = signal;
69
+ this.stderr = detail;
70
+ }
71
+ }
72
+
73
+ /** openspec ran successfully but its stdout was not the expected JSON. */
74
+ export class OpenspecJsonParseError extends Error {
75
+ readonly args: readonly string[];
76
+ readonly stdout: string;
77
+
78
+ constructor(
79
+ args: readonly string[],
80
+ message: string,
81
+ options?: { stdout?: string; cause?: unknown },
82
+ ) {
83
+ super(`openspec ${args.join(" ")} produced invalid JSON: ${message}`);
84
+ this.name = "OpenspecJsonParseError";
85
+ this.args = [...args];
86
+ this.stdout = options?.stdout ?? "";
87
+ if (options?.cause !== undefined) this.cause = options.cause;
88
+ }
89
+ }
90
+
91
+ export function isOpenspecCliMissingError(
92
+ error: unknown,
93
+ ): error is OpenspecCliMissingError {
94
+ return error instanceof OpenspecCliMissingError;
95
+ }
96
+
97
+ // ── PATH resolution ─────────────────────────────────────────────────────────
98
+
99
+ function isExecutable(candidate: string): boolean {
100
+ try {
101
+ fs.accessSync(candidate, fs.constants.X_OK);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Self-implemented `which` for the openspec binary. Honors absolute commands,
110
+ * Windows PATHEXT, and every directory in `env.PATH`. Returns the real path of
111
+ * the first executable candidate, or undefined when nothing matches.
112
+ */
113
+ export function resolveOpenspecBinary(
114
+ env: NodeJS.ProcessEnv = process.env,
115
+ ): string | undefined {
116
+ if (
117
+ path.isAbsolute(OPENSPEC_COMMAND) ||
118
+ OPENSPEC_COMMAND.includes(path.sep)
119
+ ) {
120
+ const resolved = path.resolve(OPENSPEC_COMMAND);
121
+ return isExecutable(resolved) ? resolved : undefined;
122
+ }
123
+ const extensions =
124
+ process.platform === "win32"
125
+ ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
126
+ : [""];
127
+ for (const directory of (env.PATH ?? "").split(path.delimiter)) {
128
+ if (!directory) continue;
129
+ for (const extension of extensions) {
130
+ const candidate = path.join(directory, `${OPENSPEC_COMMAND}${extension}`);
131
+ if (!isExecutable(candidate)) continue;
132
+ try {
133
+ return fs.realpathSync(candidate);
134
+ } catch {
135
+ return candidate;
136
+ }
137
+ }
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ // ── command execution ───────────────────────────────────────────────────────
143
+
144
+ const DEFAULT_TIMEOUT_MS = 30_000;
145
+ const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
146
+
147
+ export interface OpenspecCliOptions {
148
+ /** Environment used for PATH resolution and the child process. Defaults to process.env. */
149
+ env?: NodeJS.ProcessEnv;
150
+ /** Working directory for the child process. */
151
+ cwd?: string;
152
+ /** Child-process timeout in milliseconds. */
153
+ timeoutMs?: number;
154
+ /** spawnSync replacement for tests. */
155
+ spawnImpl?: SpawnSyncLike;
156
+ }
157
+
158
+ function executeOpenspec(
159
+ args: readonly string[],
160
+ options: OpenspecCliOptions,
161
+ ): string {
162
+ const env = options.env ?? process.env;
163
+ const binaryPath = resolveOpenspecBinary(env);
164
+ if (!binaryPath) throw new OpenspecCliMissingError();
165
+ const spawnImpl = options.spawnImpl ?? spawnSync;
166
+ const spawnOptions: SpawnSyncOptionsWithStringEncoding = {
167
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
168
+ env,
169
+ encoding: "utf-8",
170
+ maxBuffer: MAX_OUTPUT_BYTES,
171
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
172
+ windowsHide: true,
173
+ };
174
+ const result = spawnImpl(binaryPath, [...args], spawnOptions);
175
+ if (result.error) {
176
+ throw new OpenspecCliExitError(
177
+ args,
178
+ null,
179
+ result.signal ?? null,
180
+ result.error.message,
181
+ );
182
+ }
183
+ if (result.status !== 0) {
184
+ throw new OpenspecCliExitError(
185
+ args,
186
+ result.status,
187
+ result.signal ?? null,
188
+ result.stderr || result.stdout,
189
+ );
190
+ }
191
+ return result.stdout;
192
+ }
193
+
194
+ /**
195
+ * Generic JSON channel for openspec `--json` subcommands. Callers pass the
196
+ * full argument list, e.g. `["instructions", "proposal", "--change", id,
197
+ * "--json"]`.
198
+ */
199
+ export function runOpenspecJson<T>(
200
+ args: readonly string[],
201
+ options: OpenspecCliOptions = {},
202
+ ): T {
203
+ const stdout = executeOpenspec(args, options);
204
+ try {
205
+ return JSON.parse(stdout) as T;
206
+ } catch (cause) {
207
+ throw new OpenspecJsonParseError(
208
+ args,
209
+ cause instanceof Error ? cause.message : String(cause),
210
+ {
211
+ stdout,
212
+ cause,
213
+ },
214
+ );
215
+ }
216
+ }
217
+
218
+ // ── version probe (once per session) ────────────────────────────────────────
219
+
220
+ const versionCache = new Map<string, string>();
221
+
222
+ /** Drop the per-session version cache. Test-only. */
223
+ export function resetOpenspecVersionCacheForTests(): void {
224
+ versionCache.clear();
225
+ }
226
+
227
+ /**
228
+ * Probe `openspec --version`. The resolved binary path is the cache key, so
229
+ * the child process runs at most once per binary per session; later calls
230
+ * return the cached version without spawning.
231
+ */
232
+ export function probeOpenspecVersion(options: OpenspecCliOptions = {}): string {
233
+ const env = options.env ?? process.env;
234
+ const binaryPath = resolveOpenspecBinary(env);
235
+ if (!binaryPath) throw new OpenspecCliMissingError();
236
+ const cached = versionCache.get(binaryPath);
237
+ if (cached !== undefined) return cached;
238
+ const version = executeOpenspec(["--version"], { ...options, env }).trim();
239
+ if (!version) {
240
+ throw new OpenspecCliExitError(
241
+ ["--version"],
242
+ 0,
243
+ null,
244
+ "openspec --version produced empty output",
245
+ );
246
+ }
247
+ versionCache.set(binaryPath, version);
248
+ return version;
249
+ }
250
+
251
+ // ── schemas & templates ─────────────────────────────────────────────────────
252
+
253
+ /** One entry of `openspec schemas --json`. */
254
+ export interface OpenspecSchemaInfo {
255
+ name: string;
256
+ description: string;
257
+ artifacts: string[];
258
+ source: string;
259
+ }
260
+
261
+ /** One value of the `openspec templates --json` object. */
262
+ interface OpenspecTemplateInfo {
263
+ path: string;
264
+ source: string;
265
+ }
266
+
267
+ function isRecord(value: unknown): value is Record<string, unknown> {
268
+ return typeof value === "object" && value !== null && !Array.isArray(value);
269
+ }
270
+
271
+ function typeOf(value: unknown): string {
272
+ return Array.isArray(value)
273
+ ? "array"
274
+ : value === null
275
+ ? "null"
276
+ : typeof value;
277
+ }
278
+
279
+ function requireString(
280
+ entry: Record<string, unknown>,
281
+ field: string,
282
+ args: readonly string[],
283
+ where: string,
284
+ ): string {
285
+ const value = entry[field];
286
+ if (typeof value !== "string") {
287
+ throw new OpenspecJsonParseError(
288
+ args,
289
+ `${where}.${field} should be a string, got ${typeOf(value)}`,
290
+ );
291
+ }
292
+ return value;
293
+ }
294
+
295
+ function parseSchemasOutput(
296
+ parsed: unknown,
297
+ args: readonly string[],
298
+ ): OpenspecSchemaInfo[] {
299
+ if (!Array.isArray(parsed)) {
300
+ throw new OpenspecJsonParseError(
301
+ args,
302
+ `expected a JSON array, got ${typeOf(parsed)}`,
303
+ );
304
+ }
305
+ return parsed.map((entry, index) => {
306
+ const where = `schemas[${index}]`;
307
+ if (!isRecord(entry)) {
308
+ throw new OpenspecJsonParseError(
309
+ args,
310
+ `${where} should be an object, got ${typeOf(entry)}`,
311
+ );
312
+ }
313
+ const artifacts = entry.artifacts;
314
+ if (
315
+ !Array.isArray(artifacts) ||
316
+ !artifacts.every((item) => typeof item === "string")
317
+ ) {
318
+ throw new OpenspecJsonParseError(
319
+ args,
320
+ `${where}.artifacts should be an array of strings`,
321
+ );
322
+ }
323
+ return {
324
+ name: requireString(entry, "name", args, where),
325
+ description: requireString(entry, "description", args, where),
326
+ artifacts: [...artifacts],
327
+ source: requireString(entry, "source", args, where),
328
+ };
329
+ });
330
+ }
331
+
332
+ /** Run `openspec schemas --json` and validate its output. */
333
+ export function listOpenspecSchemas(
334
+ options: OpenspecCliOptions = {},
335
+ ): OpenspecSchemaInfo[] {
336
+ const args = ["schemas", "--json"] as const;
337
+ return parseSchemasOutput(runOpenspecJson<unknown>(args, options), args);
338
+ }
339
+
340
+ function parseTemplatesOutput(
341
+ parsed: unknown,
342
+ args: readonly string[],
343
+ ): Record<string, OpenspecTemplateInfo> {
344
+ if (!isRecord(parsed)) {
345
+ throw new OpenspecJsonParseError(
346
+ args,
347
+ `expected a JSON object, got ${typeOf(parsed)}`,
348
+ );
349
+ }
350
+ const templates: Record<string, OpenspecTemplateInfo> = {};
351
+ for (const [artifact, value] of Object.entries(parsed)) {
352
+ const where = `templates.${artifact}`;
353
+ if (!isRecord(value)) {
354
+ throw new OpenspecJsonParseError(
355
+ args,
356
+ `${where} should be an object, got ${typeOf(value)}`,
357
+ );
358
+ }
359
+ templates[artifact] = {
360
+ path: requireString(value, "path", args, where),
361
+ source: requireString(value, "source", args, where),
362
+ };
363
+ }
364
+ return templates;
365
+ }
366
+
367
+ /** Run `openspec templates --json` and validate its output. */
368
+ export function listOpenspecTemplates(
369
+ options: OpenspecCliOptions = {},
370
+ ): Record<string, OpenspecTemplateInfo> {
371
+ const args = ["templates", "--json"] as const;
372
+ return parseTemplatesOutput(runOpenspecJson<unknown>(args, options), args);
373
+ }
374
+
375
+ // ── per-artifact instructions ───────────────────────────────────────────────
376
+
377
+ /**
378
+ * The validated slice of `openspec instructions <artifact> --change <id> --json`
379
+ * the plan flow consumes. The CLI is the source of truth for what to write;
380
+ * these fields are only what the injection block renders.
381
+ */
382
+ export interface OpenspecArtifactInstructions {
383
+ artifactId: string;
384
+ changeName: string;
385
+ instruction: string;
386
+ /** The artifact template the CLI wants written to `resolvedOutputPath`. */
387
+ template?: string;
388
+ /** Project context the CLI returns (openspec/config.yaml context). */
389
+ context?: string;
390
+ rules?: string[];
391
+ resolvedOutputPath?: string;
392
+ }
393
+
394
+ function optionalString(value: unknown): string | undefined {
395
+ return typeof value === "string" && value.trim() ? value : undefined;
396
+ }
397
+
398
+ function optionalStringArray(value: unknown): string[] | undefined {
399
+ if (!Array.isArray(value)) return undefined;
400
+ return value.every((item) => typeof item === "string")
401
+ ? [...value]
402
+ : undefined;
403
+ }
404
+
405
+ /**
406
+ * Run `openspec instructions <artifact> --change <id> --json` and validate the
407
+ * fields the plan flow uses. A missing `instruction` is a parse error: the
408
+ * caller must degrade rather than inject an empty phase block.
409
+ */
410
+ export function getOpenspecArtifactInstructions(
411
+ artifact: string,
412
+ changeId: string,
413
+ options: OpenspecCliOptions = {},
414
+ ): OpenspecArtifactInstructions {
415
+ const args = [
416
+ "instructions",
417
+ artifact,
418
+ "--change",
419
+ changeId,
420
+ "--json",
421
+ ] as const;
422
+ const parsed = runOpenspecJson<unknown>(args, options);
423
+ if (!isRecord(parsed)) {
424
+ throw new OpenspecJsonParseError(
425
+ args,
426
+ `expected a JSON object, got ${typeOf(parsed)}`,
427
+ );
428
+ }
429
+ const template = optionalString(parsed.template);
430
+ const context = optionalString(parsed.context);
431
+ const rules = optionalStringArray(parsed.rules);
432
+ const resolvedOutputPath = optionalString(parsed.resolvedOutputPath);
433
+ return {
434
+ artifactId: requireString(parsed, "artifactId", args, "instructions"),
435
+ changeName: requireString(parsed, "changeName", args, "instructions"),
436
+ instruction: requireString(parsed, "instruction", args, "instructions"),
437
+ ...(template ? { template } : {}),
438
+ ...(context ? { context } : {}),
439
+ ...(rules ? { rules } : {}),
440
+ ...(resolvedOutputPath ? { resolvedOutputPath } : {}),
441
+ };
442
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Small shared helpers for the `/opsx:*` command layer (openspec change
3
+ * add-pi-openspec-x): the per-session key and the best-effort notice.
4
+ */
5
+ import type {
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+
10
+ /** The per-session key used by all flows (session id, or "unknown"). */
11
+ export function sessionKey(ctx: ExtensionContext): string {
12
+ try {
13
+ return ctx.sessionManager?.getSessionId() ?? "unknown";
14
+ } catch {
15
+ return "unknown";
16
+ }
17
+ }
18
+
19
+ /** A best-effort follow-up notice; a delivery failure never breaks the flow. */
20
+ export function sendNotice(
21
+ pi: Pick<ExtensionAPI, "sendMessage">,
22
+ customType: string,
23
+ content: string,
24
+ ): void {
25
+ try {
26
+ pi.sendMessage(
27
+ { customType, content, display: true },
28
+ { deliverAs: "followUp" },
29
+ );
30
+ } catch {
31
+ // Best-effort.
32
+ }
33
+ }
package/src/config.ts ADDED
@@ -0,0 +1,89 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ import { resolveAgentDir } from "./agent-dir.ts";
5
+
6
+ /**
7
+ * Configuration surface for pi-openspec-x.
8
+ *
9
+ * The three allow-write lists map to the sandbox profiles used by the
10
+ * openspec workflow subagents (see design D4). Each list defaults to the
11
+ * corresponding constant below when the field is omitted.
12
+ */
13
+ export interface OpsxConfig {
14
+ /** Write allowlist for the agent subagent's sandbox profile. */
15
+ agentAllowWrite?: string[];
16
+ /** Write allowlist for the planner subagent's sandbox profile. */
17
+ plannerAllowWrite?: string[];
18
+ /** Write allowlist for the reviewer subagent's sandbox profile. */
19
+ reviewerAllowWrite?: string[];
20
+ }
21
+
22
+ /** Default write allowlist for the agent subagent. */
23
+ export const DEFAULT_AGENT_ALLOW_WRITE: string[] = [
24
+ "openspec/**",
25
+ "node_modules/**",
26
+ "dist/**",
27
+ "coverage/**",
28
+ ".cache/**",
29
+ ];
30
+
31
+ /** Default write allowlist for the planner subagent. */
32
+ export const DEFAULT_PLANNER_ALLOW_WRITE: string[] = ["openspec/**"];
33
+
34
+ /** Default write allowlist for the reviewer subagent. */
35
+ export const DEFAULT_REVIEWER_ALLOW_WRITE: string[] = [];
36
+
37
+ /** Config file name, read from the agent dir and from the project. */
38
+ export const OPSX_CONFIG_FILE = "pi-openspec-x.json";
39
+
40
+ function stringArray(value: unknown): string[] | undefined {
41
+ if (!Array.isArray(value)) return undefined;
42
+ return value.filter(
43
+ (entry): entry is string =>
44
+ typeof entry === "string" && entry.trim() !== "",
45
+ );
46
+ }
47
+
48
+ function readConfigFile(filePath: string): OpsxConfig {
49
+ let parsed: unknown;
50
+ try {
51
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
52
+ } catch {
53
+ return {};
54
+ }
55
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
56
+ return {};
57
+ }
58
+ const record = parsed as Record<string, unknown>;
59
+ const config: OpsxConfig = {};
60
+ const agent = stringArray(record.agentAllowWrite);
61
+ if (agent) config.agentAllowWrite = agent;
62
+ const planner = stringArray(record.plannerAllowWrite);
63
+ if (planner) config.plannerAllowWrite = planner;
64
+ const reviewer = stringArray(record.reviewerAllowWrite);
65
+ if (reviewer) config.reviewerAllowWrite = reviewer;
66
+ return config;
67
+ }
68
+
69
+ /**
70
+ * Load the opsx configuration. `<agentDir>/pi-openspec-x.json` is the global
71
+ * layer and `<cwd>/.pi/pi-openspec-x.json` overrides it field by field. Any
72
+ * read or parse failure is ignored: a missing or malformed file can never stop
73
+ * the extension from loading, it only leaves that field at its default.
74
+ */
75
+ export function loadOpsxConfig(
76
+ options: {
77
+ agentDir?: string;
78
+ cwd?: string;
79
+ env?: NodeJS.ProcessEnv;
80
+ } = {},
81
+ ): OpsxConfig {
82
+ const agentDir =
83
+ options.agentDir ?? resolveAgentDir(options.env ?? process.env);
84
+ const cwd = options.cwd ?? process.cwd();
85
+ return {
86
+ ...readConfigFile(path.join(agentDir, OPSX_CONFIG_FILE)),
87
+ ...readConfigFile(path.join(cwd, ".pi", OPSX_CONFIG_FILE)),
88
+ };
89
+ }