pi-extensible-workflows 1.0.0 → 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/dist/src/cli.js CHANGED
@@ -1,9 +1,506 @@
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 } from "@earendil-works/pi-coding-agent";
8
+ import { Value } from "typebox/value";
4
9
  import { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
5
- import { runSessionInspector } from "./session-inspector.js";
10
+ import workflowExtension, { formatWorkflowProgress, workflowCatalog } from "./index.js";
11
+ import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
12
+ function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
13
+ function has(value, key) { return Object.prototype.hasOwnProperty.call(value, key); }
14
+ function clone(value) { return structuredClone(value); }
15
+ function kebabCase(value) { 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(); }
16
+ function scalarType(schema) {
17
+ if (!object(schema) || typeof schema.type !== "string")
18
+ return undefined;
19
+ return ["string", "integer", "number", "boolean"].includes(schema.type) ? schema.type : undefined;
20
+ }
21
+ function schemaPlan(schema) {
22
+ if (!object(schema) || schema.type !== "object")
23
+ return { fields: [] };
24
+ const properties = object(schema.properties) ? schema.properties : {};
25
+ const required = new Set(Array.isArray(schema.required) ? schema.required.filter((name) => typeof name === "string") : []);
26
+ const fields = [];
27
+ for (const [name, property] of Object.entries(properties)) {
28
+ if (!object(property))
29
+ continue;
30
+ const directType = scalarType(property);
31
+ const itemType = object(property.items) ? scalarType(property.items) : undefined;
32
+ const type = directType ?? itemType ? directType ?? "array" : undefined;
33
+ if (!type)
34
+ continue;
35
+ fields.push({ name, option: `--${kebabCase(name)}`, schema: property, type, ...(itemType ? { itemType } : {}), required: required.has(name) });
36
+ }
37
+ const requiredScalars = fields.filter((field) => field.required && field.type !== "array");
38
+ return { fields, ...(requiredScalars.length === 1 ? { positional: requiredScalars[0] } : {}) };
39
+ }
40
+ function scalarLabel(type) { return type === "integer" ? "integer" : type; }
41
+ function scalarFieldType(field) { return field.type === "array" ? field.itemType : field.type; }
42
+ function fieldLabel(field) { return field.type === "array" ? `${field.option} <${scalarLabel(field.itemType)}>` : `${field.option}${field.type === "boolean" ? "" : ` <${scalarLabel(field.type)}>`}`; }
43
+ function fieldDescription(field) {
44
+ const description = typeof field.schema.description === "string" ? field.schema.description.trim() : "";
45
+ const required = field.required ? "required" : "optional";
46
+ const defaultValue = has(field.schema, "default") ? ` default=${JSON.stringify(field.schema.default)}` : "";
47
+ const enumSchema = field.type === "array" && object(field.schema.items) ? field.schema.items : field.schema;
48
+ const enumValue = Array.isArray(enumSchema.enum) ? ` enum=${enumSchema.enum.map((value) => JSON.stringify(value)).join(",")}` : "";
49
+ return [description, required, defaultValue, enumValue].filter(Boolean).join("; ");
50
+ }
51
+ export function formatWorkflowCliHelp(fn, command = "pi-extensible-workflows") {
52
+ const plan = schemaPlan(fn.input);
53
+ const lines = [`Usage: ${command} run ${fn.name}${plan.positional ? ` <${plan.positional.name}>` : ""} [options]`, "", fn.description];
54
+ if (plan.positional) {
55
+ lines.push("", "Arguments:", ` <${plan.positional.name}> ${scalarLabel(scalarFieldType(plan.positional))}; ${fieldDescription(plan.positional)}`);
56
+ }
57
+ lines.push("", "Options:");
58
+ for (const field of plan.fields) {
59
+ const label = field === plan.positional ? `${field.option} <${scalarLabel(scalarFieldType(field))}>` : fieldLabel(field);
60
+ lines.push(` ${label.padEnd(24)}${fieldDescription(field)}`);
61
+ }
62
+ lines.push(" --input <json>".padEnd(28) + "JSON input escape hatch for complex schemas", ...launcherHelpLines(), " -h, --help".padEnd(28) + "Show this help");
63
+ return `${lines.join("\n")}\n`;
64
+ }
65
+ function enumAllows(schema, value) {
66
+ return !Array.isArray(schema.enum) || schema.enum.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value));
67
+ }
68
+ function coerce(raw, type, schema) {
69
+ let value;
70
+ if (type === "string")
71
+ value = raw;
72
+ else if (type === "integer") {
73
+ if (!/^-?(?:0|[1-9]\d*)$/.test(raw))
74
+ throw new Error(`Invalid integer: ${raw}`);
75
+ value = Number(raw);
76
+ if (!Number.isSafeInteger(value))
77
+ throw new Error(`Invalid integer: ${raw}`);
78
+ }
79
+ else if (type === "number") {
80
+ value = Number(raw);
81
+ if (!Number.isFinite(value))
82
+ throw new Error(`Invalid number: ${raw}`);
83
+ }
84
+ else {
85
+ if (raw !== "true" && raw !== "false")
86
+ throw new Error(`Invalid boolean: ${raw}`);
87
+ value = raw === "true";
88
+ }
89
+ if (!enumAllows(schema, value))
90
+ throw new Error(`Invalid value for enum: ${raw}`);
91
+ return value;
92
+ }
93
+ function parseJsonInput(value) {
94
+ try {
95
+ return clone(JSON.parse(value));
96
+ }
97
+ catch {
98
+ throw new Error("Invalid JSON passed to --input");
99
+ }
100
+ }
101
+ export function parseWorkflowCliArgs(schema, rawArgs) {
102
+ const plan = schemaPlan(schema);
103
+ const fields = new Map(plan.fields.map((field) => [field.option, field]));
104
+ const result = {};
105
+ let input;
106
+ let positionalUsed = false;
107
+ let endOptions = false;
108
+ const assign = (field, raw) => {
109
+ if (field.type === "array") {
110
+ const values = Array.isArray(result[field.name]) ? result[field.name] : [];
111
+ values.push(coerce(raw, field.itemType, field.schema.items));
112
+ result[field.name] = values;
113
+ }
114
+ else
115
+ result[field.name] = coerce(raw, field.type, field.schema);
116
+ };
117
+ for (let index = 0; index < rawArgs.length; index += 1) {
118
+ const token = rawArgs[index];
119
+ if (token === "--") {
120
+ endOptions = true;
121
+ continue;
122
+ }
123
+ if (!endOptions && (token === "--input" || token.startsWith("--input="))) {
124
+ if (input !== undefined)
125
+ throw new Error("--input may only be provided once");
126
+ const raw = token.startsWith("--input=") ? token.slice("--input=".length) : rawArgs[++index];
127
+ if (raw === undefined)
128
+ throw new Error("Missing value for --input");
129
+ input = parseJsonInput(raw);
130
+ continue;
131
+ }
132
+ if (!endOptions && token.startsWith("--")) {
133
+ const equals = token.indexOf("=");
134
+ const option = equals >= 0 ? token.slice(0, equals) : token;
135
+ const negated = equals < 0 && option.startsWith("--no-");
136
+ const field = fields.get(negated ? `--${option.slice("--no-".length)}` : option);
137
+ if (!field)
138
+ throw new Error(`Unknown option: ${option}`);
139
+ if (negated) {
140
+ if (field.type !== "boolean")
141
+ throw new Error(`Invalid boolean option: ${option}`);
142
+ result[field.name] = false;
143
+ }
144
+ else if (field.type === "boolean") {
145
+ if (equals >= 0)
146
+ assign(field, token.slice(equals + 1));
147
+ else if (rawArgs[index + 1] === "true" || rawArgs[index + 1] === "false")
148
+ assign(field, rawArgs[++index]);
149
+ else
150
+ result[field.name] = true;
151
+ }
152
+ else {
153
+ const raw = equals >= 0 ? token.slice(equals + 1) : rawArgs[++index];
154
+ if (raw === undefined || raw.startsWith("--"))
155
+ throw new Error(`Missing value for ${option}`);
156
+ assign(field, raw);
157
+ }
158
+ continue;
159
+ }
160
+ const positional = plan.positional;
161
+ const numericNegative = positional && (positional.type === "integer" || positional.type === "number") && /^-\d/.test(token);
162
+ if (!endOptions && token.startsWith("-") && !numericNegative)
163
+ throw new Error(`Unknown option: ${token}`);
164
+ if (!positional || positionalUsed)
165
+ throw new Error(`Unexpected argument: ${token}`);
166
+ assign(positional, token);
167
+ positionalUsed = true;
168
+ }
169
+ if (input !== undefined) {
170
+ if (Object.keys(result).length || positionalUsed)
171
+ throw new Error("--input cannot be combined with CLI arguments");
172
+ if (!object(input))
173
+ throw new Error("Workflow input must be a JSON object");
174
+ for (const field of plan.fields)
175
+ if (!has(input, field.name) && has(field.schema, "default"))
176
+ input[field.name] = clone(field.schema.default);
177
+ return input;
178
+ }
179
+ for (const field of plan.fields)
180
+ if (!has(result, field.name) && has(field.schema, "default"))
181
+ result[field.name] = clone(field.schema.default);
182
+ for (const field of plan.fields)
183
+ if (field.required && !has(result, field.name))
184
+ throw new Error(`Missing required argument: ${field.name}`);
185
+ return result;
186
+ }
187
+ function launcherHelpLines() {
188
+ return [
189
+ " --approve".padEnd(28) + "Trust project resources for this launch",
190
+ " --no-approve".padEnd(28) + "Do not trust project resources for this launch",
191
+ " --".padEnd(28) + "End launcher option parsing; pass later tokens to workflow input",
192
+ ];
193
+ }
194
+ function workflowUsage() { return [`Usage: pi-extensible-workflows run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
195
+ function exportUsage() { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
196
+ function stripTrustOptions(rawArgs) {
197
+ const args = [];
198
+ let trustOverride;
199
+ let endOptions = false;
200
+ for (const arg of rawArgs) {
201
+ if (arg === "--") {
202
+ endOptions = true;
203
+ args.push(arg);
204
+ continue;
205
+ }
206
+ if (!endOptions && (arg === "--approve" || arg === "--no-approve")) {
207
+ const next = arg === "--approve";
208
+ if (trustOverride !== undefined && trustOverride !== next)
209
+ throw new Error("--approve and --no-approve cannot be combined");
210
+ trustOverride = next;
211
+ }
212
+ else
213
+ args.push(arg);
214
+ }
215
+ return { args, ...(trustOverride !== undefined ? { trustOverride } : {}) };
216
+ }
217
+ async function createWorkflowRuntime(options, shutdownHandlers = []) {
218
+ const cwd = options.cwd ?? process.cwd();
219
+ const agentDir = options.agentDir ?? getAgentDir();
220
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
221
+ const requiredTrust = hasTrustRequiringProjectResources(cwd);
222
+ const trustStore = new ProjectTrustStore(agentDir);
223
+ const defaultProjectTrust = settingsManager.getDefaultProjectTrust();
224
+ const resolveProjectTrust = async ({ extensionsResult }) => {
225
+ if (options.trustOverride !== undefined)
226
+ return options.trustOverride;
227
+ if (!requiredTrust)
228
+ return true;
229
+ const projectTrustContext = {
230
+ cwd,
231
+ mode: "print",
232
+ hasUI: false,
233
+ ui: { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => { } },
234
+ };
235
+ for (const extension of extensionsResult.extensions) {
236
+ for (const handler of extension.handlers.get("project_trust") ?? []) {
237
+ try {
238
+ const result = await handler({ type: "project_trust", cwd }, projectTrustContext);
239
+ if (result.trusted === "undecided")
240
+ continue;
241
+ if (result.trusted !== "yes" && result.trusted !== "no")
242
+ continue;
243
+ const trusted = result.trusted === "yes";
244
+ if (result.remember === true)
245
+ trustStore.set(cwd, trusted);
246
+ return trusted;
247
+ }
248
+ catch { /* Project trust extensions are best effort, as in Pi. */ }
249
+ }
250
+ }
251
+ const savedTrust = trustStore.get(cwd);
252
+ if (savedTrust !== null)
253
+ return savedTrust;
254
+ return defaultProjectTrust === "always";
255
+ };
256
+ const services = await createAgentSessionServices({
257
+ cwd,
258
+ agentDir,
259
+ settingsManager,
260
+ resourceLoaderOptions: {},
261
+ resourceLoaderReloadOptions: { resolveProjectTrust },
262
+ });
263
+ const extensions = services.resourceLoader.getExtensions();
264
+ const tools = [];
265
+ const activeTools = [...new Set(["read", "bash", "edit", "write"].concat(extensions.extensions.flatMap((extension) => [...extension.tools.keys()]), ["workflow"]))];
266
+ const headlessPi = {
267
+ registerTool(tool) { tools.push(tool); },
268
+ registerCommand() { },
269
+ getThinkingLevel: () => services.settingsManager.getDefaultThinkingLevel() ?? "medium",
270
+ getActiveTools: () => activeTools,
271
+ on(name, handler) { if (name === "session_shutdown" && typeof handler === "function")
272
+ shutdownHandlers.push(handler); },
273
+ appendEntry() { },
274
+ sendMessage() { },
275
+ events: { emit() { } },
276
+ };
277
+ workflowExtension(headlessPi, homedir(), undefined, undefined, agentDir);
278
+ const workflowTool = tools.find((tool) => object(tool) && tool.name === "workflow");
279
+ if (!workflowTool)
280
+ throw new Error("The workflow runtime could not be initialized");
281
+ return { catalog: workflowCatalog(), services, workflowTool, shutdownHandlers };
282
+ }
283
+ function availableModelInfo(services, available = false) {
284
+ const models = available ? services.modelRuntime.getAvailableSnapshot() : services.modelRuntime.getModels();
285
+ return models.map(({ provider, id }) => ({ provider, id }));
286
+ }
287
+ async function selectedModel(services) {
288
+ const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(), noTools: "all" });
289
+ try {
290
+ const model = session.model;
291
+ return model ? { provider: model.provider, id: model.id } : undefined;
292
+ }
293
+ finally {
294
+ session.dispose();
295
+ }
296
+ }
297
+ function shellQuote(value) { return `'${value.replace(/'/g, `'\\''`)}'`; }
298
+ function commandName(value) { return value.trim() && !value.includes("/") && !value.includes("\\") ? value.trim() : ""; }
299
+ function writeLauncher(destination, workflowName, force) {
300
+ const parent = dirname(destination);
301
+ mkdirSync(parent, { recursive: true });
302
+ const tempDir = mkdtempSync(join(parent, ".pi-extensible-workflows-"));
303
+ const tempPath = join(tempDir, "launcher");
304
+ try {
305
+ writeFileSync(tempPath, `#!/bin/sh\nexec node ${shellQuote(fileURLToPath(import.meta.url))} run ${shellQuote(workflowName)} "$@"\n`, { mode: 0o755 });
306
+ chmodSync(tempPath, 0o755);
307
+ if (force)
308
+ renameSync(tempPath, destination);
309
+ else {
310
+ try {
311
+ linkSync(tempPath, destination);
312
+ }
313
+ catch (error) {
314
+ if (error.code === "EEXIST")
315
+ throw new Error(`Destination already exists: ${destination}; use --force to replace it`, { cause: error });
316
+ throw error;
317
+ }
318
+ }
319
+ }
320
+ finally {
321
+ rmSync(tempDir, { recursive: true, force: true });
322
+ }
323
+ }
324
+ class CliProgress {
325
+ stderr;
326
+ tty;
327
+ #lastStable = "";
328
+ #lines = 0;
329
+ #frame = 0;
330
+ #run;
331
+ #timer;
332
+ constructor(stderr, tty) {
333
+ this.stderr = stderr;
334
+ this.tty = tty;
335
+ }
336
+ update(run) {
337
+ const stable = formatWorkflowProgress(run, "◇");
338
+ if (!this.tty) {
339
+ if (stable !== this.#lastStable) {
340
+ this.#lastStable = stable;
341
+ this.stderr(`${stable}\n`);
342
+ }
343
+ return;
344
+ }
345
+ this.#run = run;
346
+ this.#timer ??= setInterval(() => { this.render(); }, 80);
347
+ this.#timer.unref();
348
+ this.render();
349
+ }
350
+ render() {
351
+ if (!this.#run)
352
+ return;
353
+ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][this.#frame++ % 10] ?? "◇";
354
+ const width = process.stderr.columns || 80;
355
+ const text = formatWorkflowProgress(this.#run, spinner).split("\n").map((line) => line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`).join("\n");
356
+ this.stderr(`${this.#lines ? `\x1b[${String(this.#lines)}A` : ""}${this.#lines ? "" : "\x1b[?25l"}\x1b[0J${text}\n`);
357
+ this.#lines = text.split("\n").length;
358
+ }
359
+ finish() {
360
+ if (this.#timer) {
361
+ clearInterval(this.#timer);
362
+ this.#timer = undefined;
363
+ }
364
+ if (this.tty && this.#lines) {
365
+ this.stderr(`\x1b[${String(this.#lines)}A\x1b[0J\x1b[?25h`);
366
+ this.#lines = 0;
367
+ }
368
+ this.#run = undefined;
369
+ }
370
+ }
371
+ async function invokeWorkflow(fn, args, runtime, options, context) {
372
+ if (!Value.Check(fn.input, args))
373
+ throw new Error(`Invalid input for ${fn.name}`);
374
+ const progress = new CliProgress(options.stderr, options.isTTY ?? process.stderr.isTTY);
375
+ try {
376
+ const result = await runtime.workflowTool.execute(randomUUID(), { workflow: fn.name, args, foreground: true }, options.signal, (update) => { if (object(update) && object(update.details) && object(update.details.run))
377
+ progress.update(update.details.run); }, context);
378
+ const details = object(result.details) ? result.details : {};
379
+ if (has(details, "value"))
380
+ return details.value;
381
+ const first = result.content[0];
382
+ if (!first || first.type !== "text")
383
+ throw new Error("Workflow returned no result");
384
+ try {
385
+ return parseJsonInput(first.text);
386
+ }
387
+ catch {
388
+ throw new Error("Workflow returned invalid JSON");
389
+ }
390
+ }
391
+ finally {
392
+ progress.finish();
393
+ }
394
+ }
395
+ async function createWorkflowContext(runtime, options) {
396
+ const model = await selectedModel(runtime.services);
397
+ const sessionManager = SessionManager.inMemory();
398
+ const modelRegistry = { getAll: () => availableModelInfo(runtime.services), getAvailable: () => availableModelInfo(runtime.services, true) };
399
+ return { cwd: options.cwd ?? process.cwd(), mode: "print", 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 };
400
+ }
401
+ async function shutdownWorkflowRuntime(handlers, context) {
402
+ for (const handler of handlers) {
403
+ try {
404
+ await handler({ type: "session_shutdown", reason: "quit" }, context);
405
+ }
406
+ catch { /* Shutdown is best effort. */ }
407
+ }
408
+ }
409
+ async function withWorkflowRuntime(options, action) {
410
+ const shutdownHandlers = [];
411
+ let context = { cwd: options.cwd ?? process.cwd(), mode: "print", hasUI: false, headless: true };
412
+ try {
413
+ const runtime = await createWorkflowRuntime(options, shutdownHandlers);
414
+ context = await createWorkflowContext(runtime, options);
415
+ return await action(runtime, context);
416
+ }
417
+ finally {
418
+ await shutdownWorkflowRuntime(shutdownHandlers, context);
419
+ }
420
+ }
421
+ async function runWorkflowCli(rawArgs, options) {
422
+ const parsed = stripTrustOptions(rawArgs);
423
+ const args = parsed.args;
424
+ if (!args.length || args[0] === "--help" || args[0] === "-h") {
425
+ options.write(workflowUsage());
426
+ return args.length ? 0 : 1;
427
+ }
428
+ const name = args[0];
429
+ return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime, context) => {
430
+ const help = args.slice(1).some((arg) => arg === "--help" || arg === "-h");
431
+ const fn = runtime.catalog.functions.find((candidate) => candidate.name === name);
432
+ if (!fn)
433
+ throw new Error(`Unknown workflow function: ${name}`);
434
+ if (help) {
435
+ options.write(formatWorkflowCliHelp(fn));
436
+ return 0;
437
+ }
438
+ const input = parseWorkflowCliArgs(fn.input, args.slice(1));
439
+ const value = await invokeWorkflow(fn, input, runtime, options, context);
440
+ options.write(`${JSON.stringify(value)}\n`);
441
+ return 0;
442
+ });
443
+ }
444
+ async function exportWorkflowCli(rawArgs, options) {
445
+ const parsed = stripTrustOptions(rawArgs);
446
+ const args = parsed.args;
447
+ if (!args.length || args[0] === "--help" || args[0] === "-h") {
448
+ options.write(exportUsage());
449
+ return args.length ? 0 : 1;
450
+ }
451
+ const workflowName = args[0];
452
+ return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
453
+ let name;
454
+ let output;
455
+ let force = false;
456
+ for (let index = 1; index < args.length; index += 1) {
457
+ const arg = args[index];
458
+ if (arg === "--force") {
459
+ force = true;
460
+ continue;
461
+ }
462
+ const equals = arg.indexOf("=");
463
+ const option = equals >= 0 ? arg.slice(0, equals) : arg;
464
+ if (option === "--name" || option === "--output") {
465
+ const value = equals >= 0 ? arg.slice(equals + 1) : args[++index];
466
+ if (!value)
467
+ throw new Error(`Missing value for ${option}`);
468
+ if (option === "--name")
469
+ name = value;
470
+ else
471
+ output = value;
472
+ continue;
473
+ }
474
+ if (arg === "--help" || arg === "-h") {
475
+ options.write(exportUsage());
476
+ return 0;
477
+ }
478
+ throw new Error(`Unknown option: ${arg}`);
479
+ }
480
+ if (!runtime.catalog.functions.some((candidate) => candidate.name === workflowName))
481
+ throw new Error(`Unknown workflow function: ${workflowName}`);
482
+ const command = commandName(name ?? kebabCase(workflowName));
483
+ if (!command)
484
+ throw new Error("Command name must be a non-empty name without path separators");
485
+ const destination = output ? output : join(homedir(), ".local", "bin", command);
486
+ writeLauncher(destination, workflowName, force);
487
+ if (!output) {
488
+ const binDir = join(homedir(), ".local", "bin");
489
+ const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean).map((entry) => { try {
490
+ return realpathSync(entry);
491
+ }
492
+ catch {
493
+ return entry;
494
+ } });
495
+ if (!pathEntries.includes(binDir))
496
+ options.stderr(`Warning: ${binDir} is not in PATH\n`);
497
+ }
498
+ options.write(`Exported ${destination}\n`);
499
+ return 0;
500
+ });
501
+ }
6
502
  export async function runCli(args, options = {}, write = (text) => { process.stdout.write(text); }) {
503
+ const stderr = options.stderr ?? ((text) => { process.stderr.write(text); });
7
504
  if (args[0] === "doctor" && args.length === 1) {
8
505
  const report = await doctor(options);
9
506
  write(formatDoctorReport(report));
@@ -19,8 +516,35 @@ export async function runCli(args, options = {}, write = (text) => { process.std
19
516
  return 1;
20
517
  }
21
518
  }
22
- write("Usage: pi-extensible-workflows doctor | inspect [session-id]\n");
519
+ if (args[0] === "transcript" && args.length === 2) {
520
+ try {
521
+ if (options.transcript)
522
+ await options.transcript(args[1]);
523
+ else
524
+ write(`${transcriptFileLines(args[1]).join("\n")}\n`);
525
+ return 0;
526
+ }
527
+ catch (error) {
528
+ write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
529
+ return 1;
530
+ }
531
+ }
532
+ if (args[0] === "run" || args[0] === "export") {
533
+ try {
534
+ const workflowOptions = { 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 } : {}) };
535
+ return args[0] === "run" ? await runWorkflowCli(args.slice(1), workflowOptions) : await exportWorkflowCli(args.slice(1), workflowOptions);
536
+ }
537
+ catch (error) {
538
+ stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
539
+ return 1;
540
+ }
541
+ }
542
+ 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");
23
543
  return 1;
24
544
  }
25
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href)
26
- process.exitCode = await runCli(process.argv.slice(2));
545
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
546
+ const controller = new AbortController();
547
+ const onSignal = () => { controller.abort(); };
548
+ process.once("SIGINT", onSignal);
549
+ process.exitCode = await runCli(process.argv.slice(2), { signal: controller.signal });
550
+ }
@@ -1,4 +1,4 @@
1
- import { type AgentResourcePolicy, type WorkflowScriptDefinition, type WorkflowSettings } from "./index.js";
1
+ import { type AgentResourcePolicy, type WorkflowFunction, type WorkflowSettings } from "./index.js";
2
2
  export type DoctorSeverity = "error" | "warning";
3
3
  export interface DoctorDiagnostic {
4
4
  severity: DoctorSeverity;
@@ -15,7 +15,7 @@ export interface DoctorRole {
15
15
  overrides?: string;
16
16
  overriddenBy?: string;
17
17
  }
18
- export interface DoctorWorkflow {
18
+ export interface DoctorFunction {
19
19
  name: string;
20
20
  description: string;
21
21
  valid: boolean;
@@ -36,7 +36,7 @@ export interface DoctorPiState {
36
36
  }[];
37
37
  extensions?: readonly string[];
38
38
  skills?: readonly string[];
39
- workflows: Readonly<Record<string, WorkflowScriptDefinition>>;
39
+ functions: Readonly<Record<string, WorkflowFunction>>;
40
40
  }
41
41
  export interface DoctorReport {
42
42
  cwd: string;
@@ -46,7 +46,7 @@ export interface DoctorReport {
46
46
  trust: DoctorTrust;
47
47
  activeTools: readonly string[];
48
48
  roles: readonly DoctorRole[];
49
- workflows: readonly DoctorWorkflow[];
49
+ functions: readonly DoctorFunction[];
50
50
  resourcePolicy: AgentResourcePolicy;
51
51
  diagnostics: readonly DoctorDiagnostic[];
52
52
  }
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { basename, dirname, extname, join, resolve } from "node:path";
3
3
  import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
4
4
  import { ModelRuntime, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, hasTrustRequiringProjectResources, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
- import { DEFAULT_SETTINGS, loadSettings, parseModelReference, resolveAgentResourcePolicy, resolveModelReference, parseRoleMarkdown, preflight, registeredWorkflowDefinitions, workflowRoleDirectories, workflowProjectSettingsPath, workflowSettingsPath, WorkflowError, } from "./index.js";
5
+ import { DEFAULT_SETTINGS, loadSettings, resolveAgentResourcePolicy, resolveModelReference, parseRoleMarkdown, registeredWorkflowFunctions, workflowRoleDirectories, workflowProjectSettingsPath, workflowSettingsPath, } from "./index.js";
6
6
  const THINKING_HINT = "Use off, minimal, low, medium, high, xhigh, or max.";
7
7
  function canonical(path) {
8
8
  const absolute = resolve(path);
@@ -88,7 +88,7 @@ async function discoverPi(cwd, agentDir) {
88
88
  ...extensions.errors.map(({ path, error }) => ({ path, message: error })),
89
89
  ...services.diagnostics.filter(({ type }) => type === "error").map(({ message }) => ({ message })),
90
90
  ],
91
- workflows: registeredWorkflowDefinitions(),
91
+ functions: registeredWorkflowFunctions(),
92
92
  };
93
93
  }
94
94
  finally {
@@ -153,9 +153,6 @@ function inspectRole(path, activeTools, knownModels, availableModels, diagnostic
153
153
  diagnostics.push(diagnostic("error", "ROLE_TOOL_INACTIVE", `Tool is unknown or inactive: ${tool}`, path, "Use a tool listed under Active tools or enable its Pi extension."));
154
154
  return definition;
155
155
  }
156
- class DoctorModelSet extends Set {
157
- has(value) { parseModelReference(value); return true; }
158
- }
159
156
  function matchResourcePolicy(policy, pi) {
160
157
  const extensions = new Set((pi.extensions ?? []).map(canonical));
161
158
  const skills = new Set(pi.skills ?? []);
@@ -182,7 +179,7 @@ export async function doctor(options = {}) {
182
179
  }
183
180
  catch (error) {
184
181
  diagnostics.push(diagnostic("error", "PI_DISCOVERY", `Pi headless discovery failed: ${error.message}`, undefined, "Open and trust the project in Pi, fix extension errors, then rerun doctor."));
185
- pi = { trust: { required: false, trusted: false, source: "discovery failed" }, activeTools: [], knownModels: [], availableModels: [], extensionErrors: [], workflows: {} };
182
+ pi = { trust: { required: false, trusted: false, source: "discovery failed" }, activeTools: [], knownModels: [], availableModels: [], extensionErrors: [], functions: {} };
186
183
  }
187
184
  if (options.activeTools)
188
185
  pi = { ...pi, activeTools: options.activeTools.filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_catalog") };
@@ -240,33 +237,14 @@ export async function doctor(options = {}) {
240
237
  else
241
238
  definitions.delete(name);
242
239
  }
243
- const workflows = [];
244
- for (const [name, workflow] of Object.entries(pi.workflows).sort(([left], [right]) => left.localeCompare(right))) {
245
- let valid = true;
246
- try {
247
- const checked = preflight(workflow.script, {
248
- models: new DoctorModelSet(pi.knownModels),
249
- tools: activeTools,
250
- agentTypes: new Set(definitions.keys()),
251
- modelAliases: aliases,
252
- knownModels,
253
- settingsPath,
254
- }, [], { name, description: workflow.description });
255
- for (const model of checked.referenced.models)
256
- if (!knownModels.has(model) || !availableModels.has(model))
257
- diagnostics.push(diagnostic("warning", "MODEL_UNAVAILABLE", `Model is valid-shaped but unavailable: ${model}`, name));
258
- }
259
- catch (error) {
260
- valid = false;
261
- const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
262
- diagnostics.push(diagnostic("error", `WORKFLOW_${typed.code}`, typed.message, name, "Fix the registered workflow source or its referenced role/tool/model/extension."));
263
- }
264
- workflows.push({ name, description: workflow.description, valid });
240
+ const functions = [];
241
+ for (const [name, fn] of Object.entries(pi.functions).sort(([left], [right]) => left.localeCompare(right))) {
242
+ functions.push({ name, description: fn.description, valid: true });
265
243
  }
266
244
  const severityOrder = { error: 0, warning: 1 };
267
245
  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));
268
246
  roles.sort((left, right) => left.name.localeCompare(right.name) || left.scope.localeCompare(right.scope));
269
- return { cwd, agentDir, settingsPath, settings, trust: pi.trust, activeTools: [...activeTools].sort(), roles, workflows, resourcePolicy, diagnostics };
247
+ return { cwd, agentDir, settingsPath, settings, trust: pi.trust, activeTools: [...activeTools].sort(), roles, functions, resourcePolicy, diagnostics };
270
248
  }
271
249
  function count(report, severity) { return report.diagnostics.filter((item) => item.severity === severity).length; }
272
250
  export function doctorExitCode(report) { return count(report, "error") > 0 ? 1 : 0; }
@@ -301,8 +279,8 @@ export function formatDoctorReport(report) {
301
279
  "## Roles",
302
280
  ...(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"]),
303
281
  "",
304
- "## Reusable workflows",
305
- ...(report.workflows.length ? report.workflows.map((workflow) => `- [${workflow.valid ? "ok" : "error"}] \`${workflow.name}\` - ${workflow.description}`) : ["- None registered"]),
282
+ "## Reusable functions",
283
+ ...(report.functions.length ? report.functions.map((fn) => `- [${fn.valid ? "ok" : "error"}] \`${fn.name}\` - ${fn.description}`) : ["- None registered"]),
306
284
  "",
307
285
  "## Diagnostics",
308
286
  ...(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"]),