pi-extensible-workflows 1.0.1 → 2.0.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 CHANGED
@@ -1,12 +1,406 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
3
- import { pathToFileURL } from "node:url";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, hasTrustRequiringProjectResources, type LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
8
+ import { Value } from "typebox/value";
4
9
  import { doctor, doctorExitCode, formatDoctorReport, type DoctorOptions } from "./doctor.js";
5
- import { runSessionInspector } from "./session-inspector.js";
10
+ import workflowExtension, { formatWorkflowProgress, workflowCatalog, type JsonSchema, type JsonValue } from "./index.js";
11
+ import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
12
+ import type { PersistedRun } from "./persistence.js";
13
+ import type { WorkflowCatalogFunction } from "./index.js";
6
14
 
7
- export interface CliOptions extends DoctorOptions { inspect?: (sessionId?: string) => Promise<void> }
15
+ export interface CliOptions extends DoctorOptions { inspect?: (sessionId?: string) => Promise<void>; transcript?: (sessionFile: string) => Promise<void>; stderr?: (text: string) => void; signal?: AbortSignal; trustOverride?: boolean; isTTY?: boolean }
16
+
17
+ type CliScalar = "string" | "integer" | "number" | "boolean";
18
+ type CliField = { name: string; option: string; schema: Record<string, unknown>; type: CliScalar | "array"; itemType?: CliScalar; required: boolean };
19
+ type CliSchemaPlan = { fields: readonly CliField[]; positional?: CliField };
20
+
21
+ function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
22
+ function has(value: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); }
23
+ function clone(value: unknown): JsonValue { return structuredClone(value) as JsonValue; }
24
+ function kebabCase(value: string): string { return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase(); }
25
+
26
+ function scalarType(schema: unknown): CliScalar | undefined {
27
+ if (!object(schema) || typeof schema.type !== "string") return undefined;
28
+ return ["string", "integer", "number", "boolean"].includes(schema.type) ? schema.type as CliScalar : undefined;
29
+ }
30
+
31
+ function schemaPlan(schema: JsonSchema): CliSchemaPlan {
32
+ if (!object(schema) || schema.type !== "object") return { fields: [] };
33
+ const properties = object(schema.properties) ? schema.properties : {};
34
+ const required = new Set(Array.isArray(schema.required) ? schema.required.filter((name): name is string => typeof name === "string") : []);
35
+ const fields: CliField[] = [];
36
+ for (const [name, property] of Object.entries(properties)) {
37
+ if (!object(property)) continue;
38
+ const directType = scalarType(property);
39
+ const itemType = object(property.items) ? scalarType(property.items) : undefined;
40
+ const type = directType ?? itemType ? directType ?? "array" : undefined;
41
+ if (!type) continue;
42
+ fields.push({ name, option: `--${kebabCase(name)}`, schema: property, type, ...(itemType ? { itemType } : {}), required: required.has(name) });
43
+ }
44
+ const requiredScalars = fields.filter((field) => field.required && field.type !== "array");
45
+ return { fields, ...(requiredScalars.length === 1 ? { positional: requiredScalars[0] } : {}) };
46
+ }
47
+
48
+ function scalarLabel(type: CliScalar): string { return type === "integer" ? "integer" : type; }
49
+ function scalarFieldType(field: CliField): CliScalar { return field.type === "array" ? field.itemType as CliScalar : field.type; }
50
+ function fieldLabel(field: CliField): string { return field.type === "array" ? `${field.option} <${scalarLabel(field.itemType as CliScalar)}>` : `${field.option}${field.type === "boolean" ? "" : ` <${scalarLabel(field.type)}>`}`; }
51
+ function fieldDescription(field: CliField): string {
52
+ const description = typeof field.schema.description === "string" ? field.schema.description.trim() : "";
53
+ const required = field.required ? "required" : "optional";
54
+ const defaultValue = has(field.schema, "default") ? ` default=${JSON.stringify(field.schema.default)}` : "";
55
+ const enumSchema = field.type === "array" && object(field.schema.items) ? field.schema.items : field.schema;
56
+ const enumValue = Array.isArray(enumSchema.enum) ? ` enum=${enumSchema.enum.map((value) => JSON.stringify(value)).join(",")}` : "";
57
+ return [description, required, defaultValue, enumValue].filter(Boolean).join("; ");
58
+ }
59
+
60
+ export function formatWorkflowCliHelp(fn: WorkflowCatalogFunction, command = "pi-extensible-workflows"): string {
61
+ const plan = schemaPlan(fn.input);
62
+ const lines = [`Usage: ${command} run ${fn.name}${plan.positional ? ` <${plan.positional.name}>` : ""} [options]`, "", fn.description];
63
+ if (plan.positional) {
64
+ lines.push("", "Arguments:", ` <${plan.positional.name}> ${scalarLabel(scalarFieldType(plan.positional))}; ${fieldDescription(plan.positional)}`);
65
+ }
66
+ lines.push("", "Options:");
67
+ for (const field of plan.fields) {
68
+ const label = field === plan.positional ? `${field.option} <${scalarLabel(scalarFieldType(field))}>` : fieldLabel(field);
69
+ lines.push(` ${label.padEnd(24)}${fieldDescription(field)}`);
70
+ }
71
+ lines.push(" --input <json>".padEnd(28) + "JSON input escape hatch for complex schemas", ...launcherHelpLines(), " -h, --help".padEnd(28) + "Show this help");
72
+ return `${lines.join("\n")}\n`;
73
+ }
74
+
75
+ function enumAllows(schema: Record<string, unknown>, value: unknown): boolean {
76
+ return !Array.isArray(schema.enum) || schema.enum.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value));
77
+ }
78
+
79
+ function coerce(raw: string, type: CliScalar, schema: Record<string, unknown>): JsonValue {
80
+ let value: JsonValue;
81
+ if (type === "string") value = raw;
82
+ else if (type === "integer") { if (!/^-?(?:0|[1-9]\d*)$/.test(raw)) throw new Error(`Invalid integer: ${raw}`); value = Number(raw); if (!Number.isSafeInteger(value)) throw new Error(`Invalid integer: ${raw}`); }
83
+ else if (type === "number") { value = Number(raw); if (!Number.isFinite(value)) throw new Error(`Invalid number: ${raw}`); }
84
+ else { if (raw !== "true" && raw !== "false") throw new Error(`Invalid boolean: ${raw}`); value = raw === "true"; }
85
+ if (!enumAllows(schema, value)) throw new Error(`Invalid value for enum: ${raw}`);
86
+ return value;
87
+ }
88
+
89
+ function parseJsonInput(value: string): JsonValue {
90
+ try { return clone(JSON.parse(value)); } catch { throw new Error("Invalid JSON passed to --input"); }
91
+ }
92
+
93
+ export function parseWorkflowCliArgs(schema: JsonSchema, rawArgs: readonly string[]): Record<string, JsonValue> {
94
+ const plan = schemaPlan(schema);
95
+ const fields = new Map(plan.fields.map((field) => [field.option, field]));
96
+ const result: Record<string, JsonValue> = {};
97
+ let input: JsonValue | undefined;
98
+ let positionalUsed = false;
99
+ let endOptions = false;
100
+ const assign = (field: CliField, raw: string) => {
101
+ if (field.type === "array") { const values = Array.isArray(result[field.name]) ? result[field.name] as JsonValue[] : []; values.push(coerce(raw, field.itemType as CliScalar, field.schema.items as Record<string, unknown>)); result[field.name] = values; }
102
+ else result[field.name] = coerce(raw, field.type, field.schema);
103
+ };
104
+ for (let index = 0; index < rawArgs.length; index += 1) {
105
+ const token = rawArgs[index] as string;
106
+ if (token === "--") { endOptions = true; continue; }
107
+ if (!endOptions && (token === "--input" || token.startsWith("--input="))) {
108
+ if (input !== undefined) throw new Error("--input may only be provided once");
109
+ const raw = token.startsWith("--input=") ? token.slice("--input=".length) : rawArgs[++index];
110
+ if (raw === undefined) throw new Error("Missing value for --input");
111
+ input = parseJsonInput(raw);
112
+ continue;
113
+ }
114
+ if (!endOptions && token.startsWith("--")) {
115
+ const equals = token.indexOf("=");
116
+ const option = equals >= 0 ? token.slice(0, equals) : token;
117
+ const negated = equals < 0 && option.startsWith("--no-");
118
+ const field = fields.get(negated ? `--${option.slice("--no-".length)}` : option);
119
+ if (!field) throw new Error(`Unknown option: ${option}`);
120
+ if (negated) {
121
+ if (field.type !== "boolean") throw new Error(`Invalid boolean option: ${option}`);
122
+ result[field.name] = false;
123
+ } else if (field.type === "boolean") {
124
+ if (equals >= 0) assign(field, token.slice(equals + 1));
125
+ else if (rawArgs[index + 1] === "true" || rawArgs[index + 1] === "false") assign(field, rawArgs[++index] as string);
126
+ else result[field.name] = true;
127
+ } else {
128
+ const raw = equals >= 0 ? token.slice(equals + 1) : rawArgs[++index];
129
+ if (raw === undefined || raw.startsWith("--")) throw new Error(`Missing value for ${option}`);
130
+ assign(field, raw);
131
+ }
132
+ continue;
133
+ }
134
+ const positional = plan.positional;
135
+ const numericNegative = positional && (positional.type === "integer" || positional.type === "number") && /^-\d/.test(token);
136
+ if (!endOptions && token.startsWith("-") && !numericNegative) throw new Error(`Unknown option: ${token}`);
137
+ if (!positional || positionalUsed) throw new Error(`Unexpected argument: ${token}`);
138
+ assign(positional, token);
139
+ positionalUsed = true;
140
+ }
141
+ if (input !== undefined) {
142
+ if (Object.keys(result).length || positionalUsed) throw new Error("--input cannot be combined with CLI arguments");
143
+ if (!object(input)) throw new Error("Workflow input must be a JSON object");
144
+ for (const field of plan.fields) if (!has(input, field.name) && has(field.schema, "default")) input[field.name] = clone(field.schema.default);
145
+ return input;
146
+ }
147
+ for (const field of plan.fields) if (!has(result, field.name) && has(field.schema, "default")) result[field.name] = clone(field.schema.default);
148
+ for (const field of plan.fields) if (field.required && !has(result, field.name)) throw new Error(`Missing required argument: ${field.name}`);
149
+ return result;
150
+ }
151
+
152
+ function launcherHelpLines(): string[] {
153
+ return [
154
+ " --approve".padEnd(28) + "Trust project resources for this launch",
155
+ " --no-approve".padEnd(28) + "Do not trust project resources for this launch",
156
+ " --".padEnd(28) + "End launcher option parsing; pass later tokens to workflow input",
157
+ ];
158
+ }
159
+ function workflowUsage(): string { return [`Usage: pi-extensible-workflows run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
160
+ function exportUsage(): string { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
161
+ function stripTrustOptions(rawArgs: readonly string[]): { args: string[]; trustOverride?: boolean } {
162
+ const args: string[] = [];
163
+ let trustOverride: boolean | undefined;
164
+ let endOptions = false;
165
+ for (const arg of rawArgs) {
166
+ if (arg === "--") { endOptions = true; args.push(arg); continue; }
167
+ if (!endOptions && (arg === "--approve" || arg === "--no-approve")) {
168
+ const next = arg === "--approve";
169
+ if (trustOverride !== undefined && trustOverride !== next) throw new Error("--approve and --no-approve cannot be combined");
170
+ trustOverride = next;
171
+ } else args.push(arg);
172
+ }
173
+ return { args, ...(trustOverride !== undefined ? { trustOverride } : {}) };
174
+ }
175
+ type WorkflowIo = { write: (text: string) => void; stderr: (text: string) => void; cwd?: string; agentDir?: string; trustOverride?: boolean; isTTY?: boolean; signal?: AbortSignal };
176
+
177
+ type HeadlessWorkflowTool = { execute: (toolCallId: string, params: Record<string, JsonValue>, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, context: unknown) => Promise<{ content: Array<{ type: string; text: string }>; details?: unknown }> };
178
+ type ShutdownHandler = (event: unknown, context: unknown) => Promise<void> | void;
179
+ type WorkflowRuntime = { catalog: ReturnType<typeof workflowCatalog>; services: Awaited<ReturnType<typeof createAgentSessionServices>>; workflowTool: HeadlessWorkflowTool; shutdownHandlers: ShutdownHandler[] };
180
+
181
+ async function createWorkflowRuntime(options: WorkflowIo, shutdownHandlers: ShutdownHandler[] = []): Promise<WorkflowRuntime> {
182
+ const cwd = options.cwd ?? process.cwd();
183
+ const agentDir = options.agentDir ?? getAgentDir();
184
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
185
+ const requiredTrust = hasTrustRequiringProjectResources(cwd);
186
+ const trustStore = new ProjectTrustStore(agentDir);
187
+ const defaultProjectTrust = settingsManager.getDefaultProjectTrust();
188
+ const resolveProjectTrust = async ({ extensionsResult }: { extensionsResult: LoadExtensionsResult }): Promise<boolean> => {
189
+ if (options.trustOverride !== undefined) return options.trustOverride;
190
+ if (!requiredTrust) return true;
191
+ const projectTrustContext = {
192
+ cwd,
193
+ mode: "print" as const,
194
+ hasUI: false,
195
+ ui: { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {} },
196
+ };
197
+ for (const extension of extensionsResult.extensions) {
198
+ for (const handler of extension.handlers.get("project_trust") ?? []) {
199
+ try {
200
+ const result = await handler({ type: "project_trust", cwd }, projectTrustContext) as { trusted?: unknown; remember?: unknown };
201
+ if (result.trusted === "undecided") continue;
202
+ if (result.trusted !== "yes" && result.trusted !== "no") continue;
203
+ const trusted = result.trusted === "yes";
204
+ if (result.remember === true) trustStore.set(cwd, trusted);
205
+ return trusted;
206
+ } catch { /* Project trust extensions are best effort, as in Pi. */ }
207
+ }
208
+ }
209
+ const savedTrust = trustStore.get(cwd);
210
+ if (savedTrust !== null) return savedTrust;
211
+ return defaultProjectTrust === "always";
212
+ };
213
+ const services = await createAgentSessionServices({
214
+ cwd,
215
+ agentDir,
216
+ settingsManager,
217
+ resourceLoaderOptions: {},
218
+ resourceLoaderReloadOptions: { resolveProjectTrust },
219
+ });
220
+ const extensions = services.resourceLoader.getExtensions();
221
+ const tools: unknown[] = [];
222
+ const activeTools = [...new Set(["read", "bash", "edit", "write"].concat(extensions.extensions.flatMap((extension) => [...extension.tools.keys()]), ["workflow"]))];
223
+ const headlessPi = {
224
+ registerTool(tool: unknown) { tools.push(tool); },
225
+ registerCommand() {},
226
+ getThinkingLevel: () => services.settingsManager.getDefaultThinkingLevel() ?? "medium",
227
+ getActiveTools: () => activeTools,
228
+ on(name: string, handler: unknown) { if (name === "session_shutdown" && typeof handler === "function") shutdownHandlers.push(handler as ShutdownHandler); },
229
+ appendEntry() {},
230
+ sendMessage() {},
231
+ events: { emit() {} },
232
+ };
233
+ workflowExtension(headlessPi as never, homedir(), undefined, undefined, agentDir);
234
+ const workflowTool = tools.find((tool) => object(tool) && tool.name === "workflow") as HeadlessWorkflowTool | undefined;
235
+ if (!workflowTool) throw new Error("The workflow runtime could not be initialized");
236
+ return { catalog: workflowCatalog(), services, workflowTool, shutdownHandlers };
237
+ }
238
+
239
+ function availableModelInfo(services: WorkflowRuntime["services"], available = false): { provider: string; id: string }[] {
240
+ const models = available ? services.modelRuntime.getAvailableSnapshot() : services.modelRuntime.getModels();
241
+ return models.map(({ provider, id }) => ({ provider, id }));
242
+ }
243
+
244
+ async function selectedModel(services: WorkflowRuntime["services"]): Promise<{ provider: string; id: string } | undefined> {
245
+ const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(), noTools: "all" });
246
+ try {
247
+ const model = session.model;
248
+ return model ? { provider: model.provider, id: model.id } : undefined;
249
+ } finally {
250
+ session.dispose();
251
+ }
252
+ }
253
+
254
+ function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; }
255
+ function commandName(value: string): string { return value.trim() && !value.includes("/") && !value.includes("\\") ? value.trim() : ""; }
256
+
257
+ function writeLauncher(destination: string, workflowName: string, force: boolean): void {
258
+ const parent = dirname(destination);
259
+ mkdirSync(parent, { recursive: true });
260
+ const tempDir = mkdtempSync(join(parent, ".pi-extensible-workflows-"));
261
+ const tempPath = join(tempDir, "launcher");
262
+ try {
263
+ writeFileSync(tempPath, `#!/bin/sh\nexec node ${shellQuote(fileURLToPath(import.meta.url))} run ${shellQuote(workflowName)} "$@"\n`, { mode: 0o755 });
264
+ chmodSync(tempPath, 0o755);
265
+ if (force) renameSync(tempPath, destination);
266
+ else {
267
+ try { linkSync(tempPath, destination); }
268
+ catch (error) {
269
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Destination already exists: ${destination}; use --force to replace it`, { cause: error });
270
+ throw error;
271
+ }
272
+ }
273
+ } finally { rmSync(tempDir, { recursive: true, force: true }); }
274
+ }
275
+
276
+
277
+ class CliProgress {
278
+ #lastStable = "";
279
+ #lines = 0;
280
+ #frame = 0;
281
+ #run: PersistedRun | undefined;
282
+ #timer: ReturnType<typeof setInterval> | undefined;
283
+ constructor(private readonly stderr: (text: string) => void, private readonly tty: boolean) {}
284
+ update(run: PersistedRun): void {
285
+ const stable = formatWorkflowProgress(run, "◇");
286
+ if (!this.tty) { if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); } return; }
287
+ this.#run = run;
288
+ this.#timer ??= setInterval(() => { this.render(); }, 80);
289
+ this.#timer.unref();
290
+ this.render();
291
+ }
292
+ render(): void {
293
+ if (!this.#run) return;
294
+ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][this.#frame++ % 10] ?? "◇";
295
+ const width = process.stderr.columns || 80;
296
+ const text = formatWorkflowProgress(this.#run, spinner).split("\n").map((line) => line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`).join("\n");
297
+ this.stderr(`${this.#lines ? `\x1b[${String(this.#lines)}A` : ""}${this.#lines ? "" : "\x1b[?25l"}\x1b[0J${text}\n`);
298
+ this.#lines = text.split("\n").length;
299
+ }
300
+ finish(): void {
301
+ if (this.#timer) { clearInterval(this.#timer); this.#timer = undefined; }
302
+ if (this.tty && this.#lines) { this.stderr(`\x1b[${String(this.#lines)}A\x1b[0J\x1b[?25h`); this.#lines = 0; }
303
+ this.#run = undefined;
304
+ }
305
+ }
306
+
307
+ async function invokeWorkflow(fn: WorkflowCatalogFunction, args: Record<string, JsonValue>, runtime: WorkflowRuntime, options: WorkflowIo, context: unknown): Promise<JsonValue> {
308
+ if (!Value.Check(fn.input, args)) throw new Error(`Invalid input for ${fn.name}`);
309
+ const progress = new CliProgress(options.stderr, options.isTTY ?? process.stderr.isTTY);
310
+ try {
311
+ const result = await runtime.workflowTool.execute(randomUUID(), { workflow: fn.name, args, foreground: true }, options.signal, (update: unknown) => { if (object(update) && object(update.details) && object(update.details.run)) progress.update(update.details.run as unknown as PersistedRun); }, context);
312
+ const details = object(result.details) ? result.details : {};
313
+ if (has(details, "value")) return details.value as JsonValue;
314
+ const first = result.content[0];
315
+ if (!first || first.type !== "text") throw new Error("Workflow returned no result");
316
+ try { return parseJsonInput(first.text); } catch { throw new Error("Workflow returned invalid JSON"); }
317
+ } finally {
318
+ progress.finish();
319
+ }
320
+ }
321
+
322
+ async function createWorkflowContext(runtime: WorkflowRuntime, options: WorkflowIo): Promise<unknown> {
323
+ const model = await selectedModel(runtime.services);
324
+ const sessionManager = SessionManager.inMemory();
325
+ const modelRegistry = { getAll: () => availableModelInfo(runtime.services), getAvailable: () => availableModelInfo(runtime.services, true) };
326
+ return { cwd: options.cwd ?? process.cwd(), mode: "print" as const, hasUI: false, ...(model ? { model } : {}), modelRegistry, sessionManager, isProjectTrusted: () => runtime.services.settingsManager.isProjectTrusted(), ui: { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {}, onTerminalInput: () => () => {}, setStatus: () => {}, setWorkingMessage: () => {}, setWorkingVisible: () => {}, setWorkingIndicator: () => {}, setHiddenThinkingLabel: () => {}, setWidget: () => {}, setFooter: () => {}, setHeader: () => {}, setTitle: () => {}, custom: async () => undefined, pasteToEditor: () => {}, setEditorText: () => {}, getEditorText: () => "", editor: async () => undefined, addAutocompleteProvider: () => {} }, headless: true };
327
+ }
328
+
329
+ async function shutdownWorkflowRuntime(handlers: readonly ShutdownHandler[], context: unknown): Promise<void> {
330
+ for (const handler of handlers) {
331
+ try { await handler({ type: "session_shutdown", reason: "quit" }, context); } catch { /* Shutdown is best effort. */ }
332
+ }
333
+ }
334
+
335
+ async function withWorkflowRuntime<T>(options: WorkflowIo, action: (runtime: WorkflowRuntime, context: unknown) => Promise<T>): Promise<T> {
336
+ const shutdownHandlers: ShutdownHandler[] = [];
337
+ let context: unknown = { cwd: options.cwd ?? process.cwd(), mode: "print", hasUI: false, headless: true };
338
+ try {
339
+ const runtime = await createWorkflowRuntime(options, shutdownHandlers);
340
+ context = await createWorkflowContext(runtime, options);
341
+ return await action(runtime, context);
342
+ } finally {
343
+ await shutdownWorkflowRuntime(shutdownHandlers, context);
344
+ }
345
+ }
346
+
347
+ async function runWorkflowCli(rawArgs: readonly string[], options: WorkflowIo): Promise<number> {
348
+ const parsed = stripTrustOptions(rawArgs);
349
+ const args = parsed.args;
350
+ if (!args.length || args[0] === "--help" || args[0] === "-h") { options.write(workflowUsage()); return args.length ? 0 : 1; }
351
+ const name = args[0] as string;
352
+ return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime, context) => {
353
+ const help = args.slice(1).some((arg) => arg === "--help" || arg === "-h");
354
+ const fn = runtime.catalog.functions.find((candidate) => candidate.name === name);
355
+ if (!fn) throw new Error(`Unknown workflow function: ${name}`);
356
+ if (help) { options.write(formatWorkflowCliHelp(fn)); return 0; }
357
+ const input = parseWorkflowCliArgs(fn.input, args.slice(1));
358
+ const value = await invokeWorkflow(fn, input, runtime, options, context);
359
+ options.write(`${JSON.stringify(value)}\n`);
360
+ return 0;
361
+ });
362
+ }
363
+
364
+ async function exportWorkflowCli(rawArgs: readonly string[], options: WorkflowIo): Promise<number> {
365
+ const parsed = stripTrustOptions(rawArgs);
366
+ const args = parsed.args;
367
+ if (!args.length || args[0] === "--help" || args[0] === "-h") { options.write(exportUsage()); return args.length ? 0 : 1; }
368
+ const workflowName = args[0] as string;
369
+ return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
370
+ let name: string | undefined;
371
+ let output: string | undefined;
372
+ let force = false;
373
+ for (let index = 1; index < args.length; index += 1) {
374
+ const arg = args[index] as string;
375
+ if (arg === "--force") { force = true; continue; }
376
+ const equals = arg.indexOf("=");
377
+ const option = equals >= 0 ? arg.slice(0, equals) : arg;
378
+ if (option === "--name" || option === "--output") {
379
+ const value = equals >= 0 ? arg.slice(equals + 1) : args[++index];
380
+ if (!value) throw new Error(`Missing value for ${option}`);
381
+ if (option === "--name") name = value; else output = value;
382
+ continue;
383
+ }
384
+ if (arg === "--help" || arg === "-h") { options.write(exportUsage()); return 0; }
385
+ throw new Error(`Unknown option: ${arg}`);
386
+ }
387
+ if (!runtime.catalog.functions.some((candidate) => candidate.name === workflowName)) throw new Error(`Unknown workflow function: ${workflowName}`);
388
+ const command = commandName(name ?? kebabCase(workflowName));
389
+ if (!command) throw new Error("Command name must be a non-empty name without path separators");
390
+ const destination = output ? output : join(homedir(), ".local", "bin", command);
391
+ writeLauncher(destination, workflowName, force);
392
+ if (!output) {
393
+ const binDir = join(homedir(), ".local", "bin");
394
+ const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean).map((entry) => { try { return realpathSync(entry); } catch { return entry; } });
395
+ if (!pathEntries.includes(binDir)) options.stderr(`Warning: ${binDir} is not in PATH\n`);
396
+ }
397
+ options.write(`Exported ${destination}\n`);
398
+ return 0;
399
+ });
400
+ }
8
401
 
9
402
  export async function runCli(args: readonly string[], options: CliOptions = {}, write: (text: string) => void = (text) => { process.stdout.write(text); }): Promise<number> {
403
+ const stderr = options.stderr ?? ((text: string) => { process.stderr.write(text); });
10
404
  if (args[0] === "doctor" && args.length === 1) {
11
405
  const report = await doctor(options);
12
406
  write(formatDoctorReport(report));
@@ -16,8 +410,26 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
16
410
  try { await (options.inspect ?? runSessionInspector)(args[1]); return 0; }
17
411
  catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
18
412
  }
19
- write("Usage: pi-extensible-workflows doctor | inspect [session-id]\n");
413
+ if (args[0] === "transcript" && args.length === 2) {
414
+ try {
415
+ if (options.transcript) await options.transcript(args[1] as string);
416
+ else write(`${transcriptFileLines(args[1] as string).join("\n")}\n`);
417
+ return 0;
418
+ } catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
419
+ }
420
+ if (args[0] === "run" || args[0] === "export") {
421
+ try {
422
+ const workflowOptions: WorkflowIo = { write, stderr, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.agentDir !== undefined ? { agentDir: options.agentDir } : {}), ...(options.signal ? { signal: options.signal } : {}), ...(options.trustOverride !== undefined ? { trustOverride: options.trustOverride } : {}), ...(options.isTTY !== undefined ? { isTTY: options.isTTY } : {}) };
423
+ return args[0] === "run" ? await runWorkflowCli(args.slice(1), workflowOptions) : await exportWorkflowCli(args.slice(1), workflowOptions);
424
+ } catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
425
+ }
426
+ write("Usage: pi-extensible-workflows doctor | inspect [session-id] | transcript <session-file> | run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]\n");
20
427
  return 1;
21
428
  }
22
429
 
23
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exitCode = await runCli(process.argv.slice(2));
430
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
431
+ const controller = new AbortController();
432
+ const onSignal = () => { controller.abort(); };
433
+ process.once("SIGINT", onSignal);
434
+ process.exitCode = await runCli(process.argv.slice(2), { signal: controller.signal });
435
+ }
package/src/doctor.ts CHANGED
@@ -16,15 +16,13 @@ import {
16
16
  resolveAgentResourcePolicy,
17
17
  resolveModelReference,
18
18
  parseRoleMarkdown,
19
- preflight,
20
- registeredWorkflowDefinitions,
19
+ registeredWorkflowFunctions,
21
20
  workflowRoleDirectories,
22
21
  workflowProjectSettingsPath,
23
22
  workflowSettingsPath,
24
- WorkflowError,
25
23
  type AgentResourceExclusions,
26
24
  type AgentResourcePolicy,
27
- type WorkflowScriptDefinition,
25
+ type WorkflowFunction,
28
26
  type WorkflowSettings,
29
27
  } from "./index.js";
30
28
  import type { AgentDefinition } from "./agent-execution.js";
@@ -32,7 +30,7 @@ import type { AgentDefinition } from "./agent-execution.js";
32
30
  export type DoctorSeverity = "error" | "warning";
33
31
  export interface DoctorDiagnostic { severity: DoctorSeverity; code: string; message: string; source?: string; hint?: string }
34
32
  export interface DoctorRole { name: string; path: string; scope: "global" | "project"; active: boolean; overrides?: string; overriddenBy?: string }
35
- export interface DoctorWorkflow { name: string; description: string; valid: boolean }
33
+ export interface DoctorFunction { name: string; description: string; valid: boolean }
36
34
  export interface DoctorTrust { required: boolean; trusted: boolean; source: string }
37
35
  export interface DoctorPiState {
38
36
  trust: DoctorTrust;
@@ -42,7 +40,7 @@ export interface DoctorPiState {
42
40
  extensionErrors: readonly { path?: string; message: string }[];
43
41
  extensions?: readonly string[];
44
42
  skills?: readonly string[];
45
- workflows: Readonly<Record<string, WorkflowScriptDefinition>>;
43
+ functions: Readonly<Record<string, WorkflowFunction>>;
46
44
  }
47
45
  export interface DoctorReport {
48
46
  cwd: string;
@@ -52,7 +50,7 @@ export interface DoctorReport {
52
50
  trust: DoctorTrust;
53
51
  activeTools: readonly string[];
54
52
  roles: readonly DoctorRole[];
55
- workflows: readonly DoctorWorkflow[];
53
+ functions: readonly DoctorFunction[];
56
54
  resourcePolicy: AgentResourcePolicy;
57
55
  diagnostics: readonly DoctorDiagnostic[];
58
56
  }
@@ -136,7 +134,7 @@ async function discoverPi(cwd: string, agentDir: string): Promise<DoctorPiState>
136
134
  ...extensions.errors.map(({ path, error }) => ({ path, message: error })),
137
135
  ...services.diagnostics.filter(({ type }) => type === "error").map(({ message }) => ({ message })),
138
136
  ],
139
- workflows: registeredWorkflowDefinitions(),
137
+ functions: registeredWorkflowFunctions(),
140
138
  };
141
139
  } finally {
142
140
  if (previousOffline === undefined) delete process.env.PI_OFFLINE;
@@ -208,7 +206,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
208
206
  try { pi = await (options.discoverPi ?? discoverPi)(cwd, agentDir); }
209
207
  catch (error) {
210
208
  diagnostics.push(diagnostic("error", "PI_DISCOVERY", `Pi headless discovery failed: ${(error as Error).message}`, undefined, "Open and trust the project in Pi, fix extension errors, then rerun doctor."));
211
- pi = { trust: { required: false, trusted: false, source: "discovery failed" }, activeTools: [], knownModels: [], availableModels: [], extensionErrors: [], workflows: {} };
209
+ pi = { trust: { required: false, trusted: false, source: "discovery failed" }, activeTools: [], knownModels: [], availableModels: [], extensionErrors: [], functions: {} };
212
210
  }
213
211
  if (options.activeTools) pi = { ...pi, activeTools: options.activeTools.filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_catalog") };
214
212
  if (pi.trust.required && !pi.trust.trusted) diagnostics.push(diagnostic("warning", "PROJECT_UNTRUSTED", "Pi project resources are inactive because the project is not trusted", cwd, "Open this project in Pi, choose Trust, then rerun doctor."));
@@ -253,32 +251,15 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
253
251
  if (definition) definitions.set(name, definition); else definitions.delete(name);
254
252
  }
255
253
 
256
- const workflows: DoctorWorkflow[] = [];
257
- for (const [name, workflow] of Object.entries(pi.workflows).sort(([left], [right]) => left.localeCompare(right))) {
258
- let valid = true;
259
- try {
260
- const checked = preflight(workflow.script, {
261
- models: new Set(pi.knownModels),
262
- tools: activeTools,
263
- agentTypes: new Set(definitions.keys()),
264
- modelAliases: aliases,
265
- knownModels,
266
- settingsPath,
267
- skipModelAvailability: true,
268
- }, [], { name, description: workflow.description });
269
- for (const model of checked.referenced.models) if (!knownModels.has(model) || !availableModels.has(model)) diagnostics.push(diagnostic("warning", "MODEL_UNAVAILABLE", `Model is valid-shaped but unavailable: ${model}`, name));
270
- } catch (error) {
271
- valid = false;
272
- const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
273
- diagnostics.push(diagnostic("error", `WORKFLOW_${typed.code}`, typed.message, name, "Fix the registered workflow source or its referenced role/tool/model/extension."));
274
- }
275
- workflows.push({ name, description: workflow.description, valid });
254
+ const functions: DoctorFunction[] = [];
255
+ for (const [name, fn] of Object.entries(pi.functions).sort(([left], [right]) => left.localeCompare(right))) {
256
+ functions.push({ name, description: fn.description, valid: true });
276
257
  }
277
258
 
278
259
  const severityOrder: Record<DoctorSeverity, number> = { error: 0, warning: 1 };
279
260
  diagnostics.sort((left, right) => severityOrder[left.severity] - severityOrder[right.severity] || (left.source ?? "").localeCompare(right.source ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message));
280
261
  roles.sort((left, right) => left.name.localeCompare(right.name) || left.scope.localeCompare(right.scope));
281
- return { cwd, agentDir, settingsPath, settings, trust: pi.trust, activeTools: [...activeTools].sort(), roles, workflows, resourcePolicy, diagnostics };
262
+ return { cwd, agentDir, settingsPath, settings, trust: pi.trust, activeTools: [...activeTools].sort(), roles, functions, resourcePolicy, diagnostics };
282
263
  }
283
264
 
284
265
  function count(report: DoctorReport, severity: DoctorSeverity): number { return report.diagnostics.filter((item) => item.severity === severity).length; }
@@ -316,8 +297,8 @@ export function formatDoctorReport(report: DoctorReport): string {
316
297
  "## Roles",
317
298
  ...(report.roles.length ? report.roles.map((role) => `- \`${role.name}\` (${role.scope}, ${role.active ? "active" : role.overriddenBy ? `overridden by ${role.overriddenBy}` : "inactive: project untrusted"}) - \`${role.path}\`${role.overrides ? `; overrides \`${role.overrides}\`` : ""}`) : ["- None found"]),
318
299
  "",
319
- "## Reusable workflows",
320
- ...(report.workflows.length ? report.workflows.map((workflow) => `- [${workflow.valid ? "ok" : "error"}] \`${workflow.name}\` - ${workflow.description}`) : ["- None registered"]),
300
+ "## Reusable functions",
301
+ ...(report.functions.length ? report.functions.map((fn) => `- [${fn.valid ? "ok" : "error"}] \`${fn.name}\` - ${fn.description}`) : ["- None registered"]),
321
302
  "",
322
303
  "## Diagnostics",
323
304
  ...(report.diagnostics.length ? report.diagnostics.map((item) => `- [${item.severity}] ${item.code}${item.source ? ` \`${item.source}\`` : ""}: ${item.message}${item.hint ? ` Fix: ${item.hint}` : ""}`) : ["- [ok] No diagnostics"]),
package/src/herdr.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { execFile } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ export type HerdrPaneAction = "inspect" | "transcript" | "fork";
5
+ export interface HerdrPaneRequest { action: HerdrPaneAction; cwd: string; sessionId?: string; original?: string; readOnly?: boolean }
6
+ export type HerdrCommandRunner = (args: readonly string[]) => Promise<string>;
7
+
8
+ const runHerdr: HerdrCommandRunner = (args) => new Promise<string>((resolve, reject) => {
9
+ execFile("herdr", [...args], { encoding: "utf8", maxBuffer: 1024 * 1024 }, (error, stdout) => {
10
+ if (error) { reject(new Error(error.message)); return; }
11
+ resolve(stdout);
12
+ });
13
+ });
14
+
15
+ export function herdrPaneId(env: NodeJS.ProcessEnv = process.env): string | undefined {
16
+ if (env.HERDR_ENV !== "1") return undefined;
17
+ const paneId = env.HERDR_PANE_ID?.trim();
18
+ return paneId || undefined;
19
+ }
20
+
21
+ function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; }
22
+ function json(value: string): unknown { return JSON.parse(value) as unknown; }
23
+ function record(value: unknown): Record<string, unknown> | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined; }
24
+ function paneLayout(value: unknown, targetPane: string): { width: number; height: number } {
25
+ const root = record(value);
26
+ const result = record(root?.result);
27
+ const layout = record(result?.layout);
28
+ const rawPanes = layout?.panes;
29
+ if (!Array.isArray(rawPanes)) throw new Error("Herdr returned an invalid pane layout.");
30
+ const panes: unknown[] = rawPanes;
31
+ const pane = panes.find((candidate: unknown) => record(candidate)?.pane_id === targetPane);
32
+ const rect = record(record(pane)?.rect);
33
+ const width = rect?.width;
34
+ const height = rect?.height;
35
+ if (width === undefined || height === undefined || typeof width !== "number" || typeof height !== "number") throw new Error("Herdr returned an invalid target pane geometry.");
36
+ return { width, height };
37
+ }
38
+ function splitPaneId(value: unknown): string {
39
+ const pane = record(record(record(value)?.result)?.pane);
40
+ const paneId = pane?.pane_id;
41
+ if (typeof paneId !== "string" || !paneId) throw new Error("Herdr returned an invalid created pane ID.");
42
+ return paneId;
43
+ }
44
+
45
+ function commandFor(request: HerdrPaneRequest): string {
46
+ const cliPath = fileURLToPath(new URL("./cli.js", import.meta.resolve("pi-extensible-workflows")));
47
+ const environment = ["PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR"].flatMap((name) => process.env[name] === undefined ? [] : [`${name}=${shellQuote(process.env[name] ?? "")}`]);
48
+ const command = request.action === "inspect"
49
+ ? [shellQuote(process.execPath), shellQuote(cliPath), "inspect", shellQuote(request.sessionId ?? "")]
50
+ : request.action === "transcript"
51
+ ? [shellQuote(process.execPath), shellQuote(cliPath), "transcript", shellQuote(request.original ?? "")]
52
+ : ["pi", "--fork", shellQuote(request.original ?? ""), ...(request.readOnly ? ["--tools", shellQuote("read,grep,find,ls")] : [])];
53
+ return `cd ${shellQuote(request.cwd)} && ${environment.length ? `${environment.join(" ")} ` : ""}${command.join(" ")}`;
54
+ }
55
+
56
+ export function herdrPaneCommand(request: HerdrPaneRequest): string { return commandFor(request); }
57
+
58
+ export async function openHerdrPane(request: HerdrPaneRequest, runner: HerdrCommandRunner = runHerdr): Promise<string> {
59
+ const targetPane = herdrPaneId();
60
+ if (!targetPane) throw new Error("Pane actions require a Herdr-managed session with HERDR_PANE_ID.");
61
+ if (!request.cwd) throw new Error("Pane actions require a working directory.");
62
+ if ((request.action === "inspect" && !request.sessionId) || (request.action !== "inspect" && !request.original)) throw new Error("Pane action is missing its session source.");
63
+ const layout = paneLayout(json(await runner(["pane", "layout", "--pane", targetPane])), targetPane);
64
+ const direction = layout.width > layout.height ? "right" : "down";
65
+ const paneId = splitPaneId(json(await runner(["pane", "split", targetPane, "--direction", direction, "--no-focus"])));
66
+ try {
67
+ await runner(["pane", "run", paneId, commandFor(request)]);
68
+ return paneId;
69
+ } catch (error) {
70
+ await runner(["pane", "close", paneId]).catch(() => undefined);
71
+ throw error;
72
+ }
73
+ }