factoryhub 0.0.0-stage → 0.1.1

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.
Files changed (156) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/README.md +195 -2
  4. package/factories/README.md +17 -0
  5. package/factories/client-file-readiness/README.md +3 -0
  6. package/factories/client-file-readiness/agents/analyst/agent.md +10 -0
  7. package/factories/client-file-readiness/agents/reviewer/agent.md +10 -0
  8. package/factories/client-file-readiness/agents/writer/agent.md +11 -0
  9. package/factories/client-file-readiness/factory.yaml +12 -0
  10. package/factories/client-file-readiness/flow.yaml +56 -0
  11. package/factories/competitor-teardown/README.md +3 -0
  12. package/factories/competitor-teardown/agents/analyst/agent.md +10 -0
  13. package/factories/competitor-teardown/agents/researcher/agent.md +11 -0
  14. package/factories/competitor-teardown/agents/reviewer/agent.md +10 -0
  15. package/factories/competitor-teardown/agents/writer/agent.md +11 -0
  16. package/factories/competitor-teardown/factory.yaml +11 -0
  17. package/factories/competitor-teardown/flow.yaml +75 -0
  18. package/factories/homepage-audit/README.md +3 -0
  19. package/factories/homepage-audit/agents/analyst/agent.md +10 -0
  20. package/factories/homepage-audit/agents/researcher/agent.md +11 -0
  21. package/factories/homepage-audit/agents/reviewer/agent.md +10 -0
  22. package/factories/homepage-audit/agents/writer/agent.md +11 -0
  23. package/factories/homepage-audit/factory.yaml +11 -0
  24. package/factories/homepage-audit/flow.yaml +77 -0
  25. package/factories/inbox-triage/README.md +3 -0
  26. package/factories/inbox-triage/agents/analyst/agent.md +10 -0
  27. package/factories/inbox-triage/agents/reviewer/agent.md +10 -0
  28. package/factories/inbox-triage/agents/writer/agent.md +11 -0
  29. package/factories/inbox-triage/factory.yaml +11 -0
  30. package/factories/inbox-triage/flow.yaml +57 -0
  31. package/factories/index.json +587 -0
  32. package/factories/launch-kit/README.md +3 -0
  33. package/factories/launch-kit/agents/reviewer/agent.md +10 -0
  34. package/factories/launch-kit/agents/strategist/agent.md +10 -0
  35. package/factories/launch-kit/agents/writer/agent.md +11 -0
  36. package/factories/launch-kit/factory.yaml +12 -0
  37. package/factories/launch-kit/flow.yaml +75 -0
  38. package/factories/meeting-to-actions/CHANGELOG.md +7 -0
  39. package/factories/meeting-to-actions/README.md +15 -0
  40. package/factories/meeting-to-actions/agents/analyst/agent.md +10 -0
  41. package/factories/meeting-to-actions/agents/reviewer/agent.md +10 -0
  42. package/factories/meeting-to-actions/agents/writer/agent.md +11 -0
  43. package/factories/meeting-to-actions/factory.yaml +11 -0
  44. package/factories/meeting-to-actions/flow.yaml +53 -0
  45. package/factories/meeting-to-actions/mindburn.requirements.yaml +8 -0
  46. package/factories/meeting-to-actions/mindburn.tests.yaml +16 -0
  47. package/factories/newsletter-note/README.md +11 -0
  48. package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
  49. package/factories/newsletter-note/agents/writer/agent.md +11 -0
  50. package/factories/newsletter-note/factory.yaml +12 -0
  51. package/factories/newsletter-note/flow.yaml +45 -0
  52. package/factories/newsletter-note/mindburn.tests.yaml +80 -0
  53. package/factories/outreach-pack/README.md +3 -0
  54. package/factories/outreach-pack/agents/reviewer/agent.md +5 -0
  55. package/factories/outreach-pack/agents/writer/agent.md +5 -0
  56. package/factories/outreach-pack/factory.yaml +9 -0
  57. package/factories/outreach-pack/flow.yaml +42 -0
  58. package/factories/prospect-brief/README.md +3 -0
  59. package/factories/prospect-brief/agents/researcher/agent.md +11 -0
  60. package/factories/prospect-brief/agents/reviewer/agent.md +10 -0
  61. package/factories/prospect-brief/agents/strategist/agent.md +10 -0
  62. package/factories/prospect-brief/agents/writer/agent.md +11 -0
  63. package/factories/prospect-brief/factory.yaml +11 -0
  64. package/factories/prospect-brief/flow.yaml +79 -0
  65. package/factories/release-desk/README.md +13 -0
  66. package/factories/release-desk/agents/editor/agent.md +10 -0
  67. package/factories/release-desk/agents/reviewer/agent.md +10 -0
  68. package/factories/release-desk/factory.yaml +13 -0
  69. package/factories/release-desk/flow.yaml +67 -0
  70. package/factories/release-desk/mindburn.tests.yaml +163 -0
  71. package/factories/short-video-cut/README.md +11 -0
  72. package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
  73. package/factories/short-video-cut/agents/writer/agent.md +11 -0
  74. package/factories/short-video-cut/factory.yaml +13 -0
  75. package/factories/short-video-cut/flow.yaml +46 -0
  76. package/factories/short-video-cut/mindburn.tests.yaml +80 -0
  77. package/factories/video-facts/README.md +11 -0
  78. package/factories/video-facts/agents/analyst/agent.md +10 -0
  79. package/factories/video-facts/agents/reviewer/agent.md +10 -0
  80. package/factories/video-facts/factory.yaml +13 -0
  81. package/factories/video-facts/flow.yaml +39 -0
  82. package/factories/video-facts/mindburn.tests.yaml +34 -0
  83. package/factories/video-to-posts/README.md +35 -0
  84. package/factories/video-to-posts/agents/analyst/agent.md +10 -0
  85. package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
  86. package/factories/video-to-posts/agents/writer/agent.md +11 -0
  87. package/factories/video-to-posts/factory.yaml +12 -0
  88. package/factories/video-to-posts/flow.yaml +62 -0
  89. package/factories/video-to-posts/mindburn.tests.yaml +34 -0
  90. package/factories/week-of-posts/README.md +11 -0
  91. package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
  92. package/factories/week-of-posts/agents/writer/agent.md +11 -0
  93. package/factories/week-of-posts/factory.yaml +12 -0
  94. package/factories/week-of-posts/flow.yaml +46 -0
  95. package/factories/week-of-posts/mindburn.tests.yaml +80 -0
  96. package/package.json +92 -4
  97. package/packages/cli/bin/factoryhub.mjs +8 -0
  98. package/packages/cli/package.json +29 -0
  99. package/packages/cli/src/args.mjs +47 -0
  100. package/packages/cli/src/commands/export.mjs +54 -0
  101. package/packages/cli/src/commands/get.mjs +37 -0
  102. package/packages/cli/src/commands/inspect.mjs +65 -0
  103. package/packages/cli/src/commands/list.mjs +24 -0
  104. package/packages/cli/src/commands/login.mjs +161 -0
  105. package/packages/cli/src/commands/logout.mjs +43 -0
  106. package/packages/cli/src/commands/mcp.mjs +25 -0
  107. package/packages/cli/src/commands/new.mjs +147 -0
  108. package/packages/cli/src/commands/pack.mjs +96 -0
  109. package/packages/cli/src/commands/publish.mjs +62 -0
  110. package/packages/cli/src/commands/pull.mjs +59 -0
  111. package/packages/cli/src/commands/push.mjs +115 -0
  112. package/packages/cli/src/commands/resume.mjs +66 -0
  113. package/packages/cli/src/commands/run.mjs +352 -0
  114. package/packages/cli/src/commands/runs.mjs +52 -0
  115. package/packages/cli/src/commands/search.mjs +30 -0
  116. package/packages/cli/src/commands/validate.mjs +79 -0
  117. package/packages/cli/src/commands/whoami.mjs +34 -0
  118. package/packages/cli/src/hosted.mjs +159 -0
  119. package/packages/cli/src/main.mjs +134 -0
  120. package/packages/cli/src/tool-sources.mjs +272 -0
  121. package/packages/cli/src/util.mjs +58 -0
  122. package/packages/engine/lib/catalog.mjs +50 -0
  123. package/packages/engine/lib/credentials.mjs +121 -0
  124. package/packages/engine/lib/hosted.mjs +119 -0
  125. package/packages/engine/lib/hub.mjs +423 -0
  126. package/packages/engine/lib/nango.mjs +157 -0
  127. package/packages/engine/lib/node.mjs +242 -0
  128. package/packages/engine/lib/providers.mjs +57 -0
  129. package/packages/engine/lib/run.mjs +192 -0
  130. package/packages/engine/lib/skill.mjs +229 -0
  131. package/packages/engine/lib/text.mjs +5 -0
  132. package/packages/engine/package.json +45 -0
  133. package/packages/engine/src/author-tests.mjs +78 -0
  134. package/packages/engine/src/compose.mjs +150 -0
  135. package/packages/engine/src/cost.mjs +41 -0
  136. package/packages/engine/src/diagnostics.mjs +21 -0
  137. package/packages/engine/src/factory-engine.mjs +2109 -0
  138. package/packages/engine/src/flow-sources.mjs +135 -0
  139. package/packages/engine/src/index.mjs +9 -0
  140. package/packages/engine/src/markdown.mjs +97 -0
  141. package/packages/engine/src/mcp-client.mjs +429 -0
  142. package/packages/engine/src/package.mjs +116 -0
  143. package/packages/engine/src/run.mjs +308 -0
  144. package/packages/engine/src/tool-sources.mjs +566 -0
  145. package/packages/mcp/bin/factoryhub-mcp.mjs +6 -0
  146. package/packages/mcp/package.json +36 -0
  147. package/packages/mcp/src/server.mjs +310 -0
  148. package/packages/sdk/package.json +28 -0
  149. package/packages/sdk/src/index.d.ts +163 -0
  150. package/packages/sdk/src/index.mjs +266 -0
  151. package/spec/README.md +42 -0
  152. package/spec/factory-format.md +866 -0
  153. package/spec/schema/factory.schema.json +43 -0
  154. package/spec/schema/factory.yaml.json +815 -0
  155. package/spec/schema/flow.schema.json +191 -0
  156. package/spec/skill-export.md +129 -0
@@ -0,0 +1,59 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve, relative } from 'node:path';
3
+ import { engine, out, err, json, styles, usage, connect, plural, BASE_URL_HELP } from '../util.mjs';
4
+ import { writePulled, PULL_MANIFEST } from '../../../engine/lib/node.mjs';
5
+
6
+ export default {
7
+ name: 'pull',
8
+ usage: `Usage: factoryhub pull <id> [--out dir] [--force] [--partial] [--base-url url] [--json]
9
+
10
+ Downloads the factory's transfer bundle, checks every file against its
11
+ SHA-256 and the files together against the bundle hash in the same answer,
12
+ and writes them to <dir>/<id>/. A bundle that fails a check writes nothing.
13
+ The hashes prove the files arrived as the hub sent them; they are not a
14
+ signature, so they do not prove who wrote the factory.
15
+
16
+ A bundle that cannot be checked whole, because it has no bundle hash or
17
+ leaves out files it names, is refused unless you pass --partial.
18
+
19
+ Logged in with the factories:read scope, it also pulls your private
20
+ factories. Without a login it pulls published and unlisted ones.
21
+
22
+ Options:
23
+ --out the parent folder (default: the current folder)
24
+ --force replace an earlier pull in that folder: files the earlier pull
25
+ wrote (listed in its ${PULL_MANIFEST}) that the new version
26
+ no longer has are removed; no other file is touched
27
+ --partial accept a bundle that cannot be checked whole, and say so
28
+ --base-url ${BASE_URL_HELP}
29
+ --json print what was written as JSON`,
30
+ options: { boolean: ['force', 'partial', 'json'], string: ['out', 'base-url'] },
31
+ async run({ positional, flags }, io) {
32
+ const [id, ...rest] = positional;
33
+ if (!id || rest.length) throw usage('Name the factory to pull: factoryhub pull <id>');
34
+ const folder = resolve(io.cwd, flags.out || '.', id);
35
+ if (existsSync(folder) && !flags.force) throw usage(`${relative(io.cwd, folder) || folder} already exists. Pass --force to write into it.`);
36
+ const { hub } = await connect(flags, io);
37
+ const factory = await hub.pull(id, { partial: Boolean(flags.partial) });
38
+ const { removed } = await writePulled(folder, factory.files, { replace: Boolean(flags.force), record: { id, sha256: factory.sha256, source: factory.source, partial: factory.partial } });
39
+ const parsed = engine.parseBundle(factory.files);
40
+ const where = relative(io.cwd, folder) || folder;
41
+ if (flags.json) {
42
+ json(io, { id, folder: where, files: Object.keys(factory.files), removed, sha256: factory.sha256, partial: factory.partial, unchecked: factory.unchecked, valid: parsed.ok, runnable: parsed.ok && parsed.factory.runnable, source: factory.source, leftOut: factory.leftOut });
43
+ return parsed.ok ? 0 : 1;
44
+ }
45
+ const s = styles(io);
46
+ const state = !parsed.ok ? s.red('invalid') : parsed.factory.runnable ? s.green('runs here') : s.yellow('does not run here');
47
+ out(io, `${s.green('saved')} ${where}/ ${parsed.factory.title} v${parsed.factory.version}, ${plural(Object.keys(factory.files).length, 'file')}, ${state}`);
48
+ if (factory.partial) {
49
+ err(io, s.yellow(`Partial, as --partial allowed: every file written matched its SHA-256, but the files were not checked as a whole.\n${factory.unchecked.map((item) => ` - ${item}`).join('\n')}`));
50
+ } else {
51
+ out(io, s.dim(`Every file matched its SHA-256, and together they match the bundle hash ${factory.sha256.slice(0, 12)} in the hub's answer.`));
52
+ }
53
+ if (removed.length) out(io, `Removed, from the earlier pull: ${removed.join(', ')}`);
54
+ if (factory.leftOut.length) out(io, `Not part of the factory, so not written: ${factory.leftOut.join(', ')}`);
55
+ out(io, `Source: ${factory.source}`);
56
+ if (parsed.ok && parsed.factory.runnable) out(io, `\nNext: ${s.bold(`factoryhub run ${where}`)} runs it here with your own model.`);
57
+ return parsed.ok ? 0 : 1;
58
+ },
59
+ };
@@ -0,0 +1,115 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { resolve, relative, join, dirname, basename } from 'node:path';
5
+ import { engine, readFolder, canonicalFiles, out, err, json, styles, usage, connect, plural, BASE_URL_HELP } from '../util.mjs';
6
+
7
+ /**
8
+ * The files git ignores in `folder`, by `git check-ignore`. Returns
9
+ * { ignored, checked }: `checked` is false when git could not say (no git, or
10
+ * not a repository), and then `gitignore` tells whether the folder has a
11
+ * .gitignore that was therefore not applied.
12
+ */
13
+ export function gitIgnored(folder, paths) {
14
+ if (!paths.length) return { ignored: [], checked: true };
15
+ const answer = spawnSync('git', ['-C', folder, 'check-ignore', '--stdin', '-z'], { input: `${paths.join('\0')}\0`, encoding: 'utf8', timeout: 10000 });
16
+ // 0: some are ignored, 1: none are, anything else: git could not tell.
17
+ if (answer.error || ![0, 1].includes(answer.status)) return { ignored: [], checked: false, gitignore: existsSync(join(folder, '.gitignore')) };
18
+ const ignored = answer.stdout.split('\0').filter(Boolean).map((path) => path.replace(/\\/g, '/'));
19
+ return { ignored: paths.filter((path) => ignored.includes(path)), checked: true };
20
+ }
21
+
22
+ /**
23
+ * The files a push sends. A folder, or the factory.yaml inside one. A
24
+ * factory.yaml with no folder around it is a single-file factory: that
25
+ * format is read here once the engine has its parser.
26
+ */
27
+ export async function readSource(target, io) {
28
+ const path = resolve(io.cwd, target || '.');
29
+ if (existsSync(path) && (await stat(path)).isFile()) {
30
+ if (!/\.ya?ml$/.test(path)) throw usage(`${target} is a file. Push a factory folder: factoryhub push <folder>`);
31
+ const folder = dirname(path);
32
+ if (basename(path) === 'factory.yaml' && existsSync(join(folder, 'flow.yaml'))) return { folder, files: (await readFolder(folder)).files };
33
+ throw usage(`${target} looks like a single-file factory. This version of factoryhub pushes factory folders; push the folder that holds it.`);
34
+ }
35
+ if (!existsSync(join(path, 'factory.yaml'))) throw usage(`${relative(io.cwd, path) || '.'} has no factory.yaml, so it is not a factory folder. Name the folder: factoryhub push <folder>`);
36
+ return { folder: path, files: (await readFolder(path)).files };
37
+ }
38
+
39
+ /**
40
+ * Reads a factory and refuses one the hub would refuse, before any request.
41
+ * Files git ignores in that folder are left out, and listed in `ignored`.
42
+ */
43
+ export async function readForPush(target, io) {
44
+ const { folder, files: all } = await readSource(target, io);
45
+ const git = gitIgnored(folder, Object.keys(all));
46
+ const files = Object.fromEntries(Object.entries(all).filter(([path]) => !git.ignored.includes(path)));
47
+ if (git.ignored.includes('factory.yaml')) throw usage(`git ignores ${relative(io.cwd, join(folder, 'factory.yaml')) || 'factory.yaml'}, so this folder is not pushed. Push a folder git does not ignore.`);
48
+ if (!git.checked && git.gitignore) err(io, styles(io).yellow(`git could not check ${relative(io.cwd, folder) || '.'}/.gitignore, so it was not applied: look at the file list below before you push.`));
49
+ const parsed = engine.parseBundle(canonicalFiles(files));
50
+ if (!parsed.ok) throw new Error(`Not pushed: this is not a valid factory yet. Fix these, then push again:\n${parsed.errors.map((error) => ` - ${error}`).join('\n')}\nCheck with: factoryhub validate ${target || '.'}`);
51
+ return { folder, files, factory: parsed.factory, ignored: git.ignored };
52
+ }
53
+
54
+ const size = (text) => {
55
+ const bytes = new TextEncoder().encode(text).byteLength;
56
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
57
+ };
58
+
59
+ export default {
60
+ name: 'push',
61
+ usage: `Usage: factoryhub push [folder | folder/factory.yaml] [--parent id] [--dry-run] [--base-url url] [--json]
62
+
63
+ Checks the folder with the same rules the site uses, lists the files it
64
+ will send, then saves them to your account as a private factory and prints
65
+ its id and address. Nobody else can see it until you run
66
+ "factoryhub publish <id>". Only factory files are sent: files outside the
67
+ format, files whose names start with a dot, and files git ignores in that
68
+ folder are left out. With --dry-run, nothing is sent.
69
+
70
+ With --parent, the push is a new revision of your own factory, or a private
71
+ copy of another factory you can open, that names it as its parent. The same
72
+ files as the parent save nothing new.
73
+
74
+ Needs a login with the factories:write scope ("factoryhub login").
75
+
76
+ Options:
77
+ --parent the factory this revises or copies
78
+ --dry-run list what would be sent, and send nothing
79
+ --base-url ${BASE_URL_HELP}
80
+ --json print the hub's answer as JSON`,
81
+ options: { boolean: ['json', 'dry-run'], string: ['parent', 'base-url'] },
82
+ async run({ positional, flags }, io) {
83
+ if (positional.length > 1) throw usage('Push one folder at a time: factoryhub push <folder>');
84
+ const { files, factory, ignored } = await readForPush(positional[0], io);
85
+ const s = styles(io);
86
+ const list = Object.keys(files);
87
+ if (flags['dry-run']) {
88
+ if (flags.json) { json(io, { dryRun: true, files: list, ignored }); return 0; }
89
+ out(io, `Would send ${plural(list.length, 'file')} for ${factory.title} v${factory.version}:`);
90
+ for (const path of list) out(io, ` ${path} ${s.dim(size(files[path]))}`);
91
+ if (ignored.length) out(io, s.dim(`Left out, ignored by git: ${ignored.join(', ')}`));
92
+ out(io, s.dim('Nothing was sent (--dry-run).'));
93
+ return 0;
94
+ }
95
+ const { hub } = await connect(flags, io, { needToken: true });
96
+ if (!flags.json) {
97
+ err(io, `Sending ${plural(list.length, 'file')}: ${list.join(', ')}`);
98
+ if (ignored.length) err(io, s.dim(`Left out, ignored by git: ${ignored.join(', ')}`));
99
+ }
100
+ const answer = await hub.push(files, { parent: flags.parent });
101
+ if (flags.json) { json(io, answer); return 0; }
102
+ const saved = answer.factory || {};
103
+ if (answer.unchanged) {
104
+ out(io, `Nothing new: these files are exactly ${s.bold(saved.id)}.\n Page ${saved.url}`);
105
+ return 0;
106
+ }
107
+ out(io, `${s.green('Saved privately')} to your account: ${factory.title} v${factory.version}, ${plural(Object.keys(files).length, 'file')}.`);
108
+ out(io, ` ID ${s.bold(saved.id)}`);
109
+ out(io, ` Page ${saved.url}`);
110
+ if (answer.parent) out(io, ` From ${answer.parent}`);
111
+ if (!factory.runnable) err(io, s.yellow(`It does not run yet: ${factory.blockers.join(' ')}`));
112
+ out(io, `\nOnly you can see it. Next: ${s.bold(`factoryhub publish ${saved.id}`)} makes it public.`);
113
+ return 0;
114
+ },
115
+ };
@@ -0,0 +1,66 @@
1
+ import { readFile, rm } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { usage } from '../util.mjs';
4
+ import { restoreRun, decide, pendingDecision } from '../../../engine/lib/run.mjs';
5
+ import { drive, lineReader, providerFor } from './run.mjs';
6
+
7
+ const REASONS = {
8
+ not_waiting: 'This run is not waiting for a decision.',
9
+ bad_action: 'The decision must be approve, revise or reject.',
10
+ unseen: 'Pass --seen with the SHA-256 printed with the decision text.',
11
+ stale: '--seen does not match the text this run is waiting on. Show the person the current text (factoryhub resume <file> --show) and use its hash.',
12
+ no_revisions: 'This step has been sent back as often as the factory allows: approve or reject it.',
13
+ };
14
+
15
+ export default {
16
+ name: 'resume',
17
+ usage: `Usage: factoryhub resume <run.json> --decision approve|revise|reject --seen <sha256> [--note text]
18
+ factoryhub resume <run.json> --show
19
+
20
+ Continues a run that "factoryhub run --save <run.json>" saved at a decision.
21
+ The decision must be the person's own. --seen is the SHA-256 of the exact
22
+ text they were shown, printed with it, so a decision cannot land on other
23
+ text. The run continues with the same provider and model, using the key in
24
+ this environment, until it is done or reaches the next decision, where it is
25
+ saved to the same file again.
26
+
27
+ Exit codes: as for "factoryhub run".
28
+
29
+ Options:
30
+ --decision approve, revise (with --note saying what to change) or reject
31
+ --seen the SHA-256 of the text the person saw
32
+ --note what to change, for revise
33
+ --show print the waiting decision and its hash, change nothing
34
+ --out where to save the deliverable
35
+ --yes, -y approve every later decision without asking (recorded as
36
+ runner mode "cli --yes" in the receipt)
37
+ --json print the outcome as JSON`,
38
+ options: { boolean: ['show', 'yes', 'json'], string: ['decision', 'seen', 'note', 'out'], alias: { y: 'yes' } },
39
+ async run({ positional, flags }, io) {
40
+ const [file] = positional;
41
+ if (!file) throw usage('Name the saved run: factoryhub resume run.json --decision approve --seen <sha256>');
42
+ const path = resolve(io.cwd, file);
43
+ const state = restoreRun(JSON.parse(await readFile(path, 'utf8')));
44
+ const pending = await pendingDecision(state);
45
+ if (flags.show) {
46
+ if (!pending) throw usage('This run is not waiting for a decision.');
47
+ io.stdout.write(flags.json ? `${JSON.stringify({ step: pending.step, title: pending.title, ask: pending.ask, text: pending.text, seen_sha256: pending.seen_sha256 }, null, 2)}\n` : `${pending.title}\n\n${pending.text}\n\n${pending.ask}\n\nseen: ${pending.seen_sha256}\n`);
48
+ return 0;
49
+ }
50
+ if (!['approve', 'revise', 'reject'].includes(flags.decision)) throw usage('Give the person\'s decision: --decision approve, revise or reject.');
51
+ if (flags.decision === 'revise' && !String(flags.note || '').trim()) throw usage('A revise decision needs --note saying what to change.');
52
+ const why = decide(state, { action: flags.decision, note: flags.note || '', seen: flags.seen, approvalId: state.approval?.id });
53
+ if (why) throw usage(REASONS[why] || why);
54
+ if (flags.yes) state.runner = { ...state.runner, mode: 'cli --yes' };
55
+ const provider = providerFor(state.cli || {}, io);
56
+ const reader = lineReader(io);
57
+ try {
58
+ const code = await drive(state, { provider, flags, io, reader, saveTo: path });
59
+ // A finished or stopped run has nothing left to resume.
60
+ if (code !== 3) await rm(path, { force: true });
61
+ return code;
62
+ } finally {
63
+ reader.close();
64
+ }
65
+ },
66
+ };
@@ -0,0 +1,352 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { resolve, relative, dirname } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { engine, readFactory, canonicalFiles, out, json, styles, usage } from '../util.mjs';
5
+ import { chooseProvider } from '../../../engine/lib/providers.mjs';
6
+ import { nodeHost, prepareRun, continueRun, pendingDecision } from '../../../engine/lib/run.mjs';
7
+ import { admitFactory, plannedCatalog, cleanTerminalText, displayLine, WRITE_REFUSED } from '../../../engine/src/tool-sources.mjs';
8
+ import { toolSourcePlan, openToolSources, openThings, declaredPlan, consentText, checkDeclaredAddress } from '../tool-sources.mjs';
9
+ import { declaredSources } from '../../../engine/src/flow-sources.mjs';
10
+ import { DEFAULT_HUB } from '../../../engine/lib/hub.mjs';
11
+ import { hostedRun } from '../hosted.mjs';
12
+
13
+ // How much of a tool's input schema is printed before the first model call; the rest is counted.
14
+ const SCHEMA_SHOWN = 1000;
15
+ // What a model provider's failure is called when tool sources are on: its
16
+ // own words could quote text from a tool's server, so they are not shown.
17
+ const PROVIDER_FAILURES = { provider_request: 'refused the request', provider_auth: 'refused the request', provider_timeout: 'did not answer in time', provider_network: 'could not be reached', provider_unavailable: 'was unavailable', provider_response: 'returned an answer that could not be used' };
18
+ const providerSentence = (failure) => `The model provider ${PROVIDER_FAILURES[failure.code]} (${failure.code}${failure.httpStatus ? `, HTTP ${failure.httpStatus}` : ''}). Its own words are not shown while tool sources are on, as they could quote text from a tool's server.`;
19
+
20
+ /** Reads typed lines one at a time, keeping answers typed ahead. */
21
+ export function lineReader(io) {
22
+ let reader = null;
23
+ return {
24
+ async ask(prompt) {
25
+ if (!io.interactive) return null;
26
+ reader ||= createInterface({ input: io.stdin, terminal: false });
27
+ reader.iterator ||= reader[Symbol.asyncIterator]();
28
+ io.stderr.write(prompt);
29
+ const { value, done } = await reader.iterator.next();
30
+ return done ? null : value;
31
+ },
32
+ close() { reader?.close(); },
33
+ };
34
+ }
35
+
36
+ /** The provider a saved run continues with: the same settings, the key from this environment. */
37
+ export function providerFor(settings, io) {
38
+ const number = (value, name) => {
39
+ if (value == null) return undefined;
40
+ const parsed = Number(value);
41
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw usage(`--${name} needs a positive whole number, not "${value}".`);
42
+ return parsed;
43
+ };
44
+ const timeout = number(settings.timeout, 'timeout');
45
+ return chooseProvider({ provider: settings.provider || undefined, model: settings.model, baseUrl: settings.baseUrl || undefined, apiKeyEnv: settings.apiKeyEnv || undefined,
46
+ effort: settings.effort || undefined, maxOutput: number(settings.maxOutput, 'max-output'), timeoutMs: timeout ? timeout * 1000 : undefined, env: io.env, fetchImpl: io.fetch });
47
+ }
48
+
49
+ /**
50
+ * Runs a prepared or restored run to its end or to a decision nobody answered,
51
+ * then reports it. Shared by `run` and `resume`. Returns the exit code.
52
+ */
53
+ export async function drive(state, { provider, flags, io, reader, saveTo, toolSources = null }) {
54
+ const s = styles(io);
55
+ const say = (text = '') => io.stderr.write(`${toolSources ? cleanTerminalText(text) : text}\n`);
56
+ const { factory } = state;
57
+ const titleOf = (id) => factory.steps.find((step) => step.id === id)?.title || id;
58
+ // With tool sources, a model provider's failure is shown by its class only.
59
+ const errorText = () => (toolSources && PROVIDER_FAILURES[state.failure?.code] ? providerSentence(state.failure) : state.error);
60
+ const host = nodeHost({
61
+ provider,
62
+ fetchImpl: io.fetch,
63
+ emit(event) {
64
+ if (event.type === 'step_started') say(`\n${s.bold(event.title)} ${s.dim(event.agent)}${event.redo ? s.dim(' (redoing)') : ''}`);
65
+ else if (event.type === 'tool') say(s.dim(` ${titleOf(event.step)}: ${event.tool === 'web_search' ? `searching ${event.target}` : event.tool === 'web_fetch' ? `reading ${event.target}` : `calling ${event.tool}`}`));
66
+ else if (event.type === 'step_done') say(s.dim(` done, ${event.chars} characters`));
67
+ else if (event.type === 'check') say(` ${event.pass ? s.green('passed') : event.status === 'sent_back' ? s.yellow('sent back') : s.red(event.status === 'unreviewed' ? 'unreviewed' : 'flagged')} ${s.dim(event.note)}${event.pass ? '' : event.issues.map((issue) => `\n - ${issue}`).join('')}`);
68
+ else if (event.type === 'run_failed') say(`\n${s.red('Stopped')}: ${errorText()}`);
69
+ },
70
+ });
71
+ // A check can use a different model from the work it checks.
72
+ const cli = state.cli || {};
73
+ if (cli.checkModel) host.checkProvider = providerFor({ ...cli, model: cli.checkModel, effort: cli.checkEffort || cli.effort }, io);
74
+ if (toolSources) host.toolSources = toolSources;
75
+
76
+ await continueRun(state, host, async (approval, text, problem) => {
77
+ const seen = await engine.sha256(text);
78
+ if (problem === 'no_revisions') say(s.dim('This step has been sent back as often as the factory allows: approve it or reject it.'));
79
+ else say(`\n${s.bold(approval.title)} ${s.yellow('needs your decision')}\n\n${text}\n\n${s.dim(approval.ask)}`);
80
+ if (flags.yes) { say(s.dim('Approved by --yes.')); return { action: 'approve', seen, approvalId: approval.id }; }
81
+ if (!io.interactive) return null;
82
+ let answer = '';
83
+ // An empty line is not a decision. Closing the input leaves the run waiting.
84
+ while (!answer) {
85
+ const line = await reader.ask(`\nType ${s.bold('approve')} to continue, ${s.bold('reject')} to stop the run, or what to change:\n> `);
86
+ if (line === null) return null;
87
+ answer = line.trim();
88
+ }
89
+ if (/^(approve|a|yes|y)$/i.test(answer)) return { action: 'approve', seen, approvalId: approval.id };
90
+ if (/^(reject|stop|no|n)$/i.test(answer)) return { action: 'reject', seen, approvalId: approval.id };
91
+ return { action: 'revise', note: answer, seen, approvalId: approval.id };
92
+ });
93
+
94
+ if (state.status === 'waiting') {
95
+ const decision = await pendingDecision(state);
96
+ let saved = null;
97
+ if (saveTo) {
98
+ await mkdir(dirname(saveTo), { recursive: true });
99
+ await writeFile(saveTo, `${JSON.stringify(state)}\n`);
100
+ saved = relative(io.cwd, saveTo) || saveTo;
101
+ }
102
+ if (flags.json) json(io, { status: 'waiting', step: decision.step, title: decision.title, ask: decision.ask, text: decision.text, seen_sha256: decision.seen_sha256, state: saved });
103
+ if (saved) {
104
+ if (!flags.json) say(`\n${s.bold(decision.title)} ${s.yellow('needs a decision')}\n\n${decision.text}\n\n${s.dim(decision.ask)}`);
105
+ say(`\nWaiting at "${decision.title}". Nothing was approved. The run is saved in ${saved}.\nShow the person the text above, then continue with their decision:\n factoryhub resume ${saved} --decision approve --seen ${decision.seen_sha256}\n factoryhub resume ${saved} --decision revise --note "what to change" --seen ${decision.seen_sha256}\n factoryhub resume ${saved} --decision reject --seen ${decision.seen_sha256}`);
106
+ } else if (state.experimental?.length) say(`\nWaiting at "${decision.title}": nobody answered, so nothing was approved and no result was written.\nA run with experimental tool sources cannot be saved and resumed yet: run it at a terminal to decide, or pass --yes to approve every decision.`);
107
+ else say(`\nWaiting at "${decision.title}": nobody answered, so nothing was approved and no result was written.\nRun it at a terminal to decide, pass --save <file> to continue later with "factoryhub resume", or pass --yes to approve every decision.`);
108
+ return 3;
109
+ }
110
+ if (state.status === 'stopped') {
111
+ if (flags.json) json(io, { status: 'stopped', step: state.stopped.step });
112
+ say(`\nStopped at "${state.stopped.title}" by your decision. No result was made.`);
113
+ return 1;
114
+ }
115
+ if (state.status !== 'done') {
116
+ // A run with tool sources says which calls ran before it stopped: the
117
+ // tool, whether it completed, the class of a failure and the time. No
118
+ // text from a server.
119
+ const calls = state.experimental?.length || factory.steps.some((step) => step.sources?.length)
120
+ ? state.records.flatMap((record) => record.tools.filter((tool) => tool.source).map((tool) => ({ step: record.id, tool: tool.tool, ok: tool.ok === true, error: tool.error ?? null, ms: Number.isFinite(tool.ms) ? tool.ms : null })))
121
+ : null;
122
+ if (flags.json) json(io, { status: state.status, error: errorText(), failure: state.failure, ...(calls ? { tool_calls: calls } : {}) });
123
+ else if (calls?.length) say(`\nTool calls before the run stopped:\n${calls.map((call) => ` ${titleOf(call.step)}: ${call.tool} ${call.ok ? 'ok' : `failed (${call.error})`}${call.ms === null ? '' : `, ${call.ms} ms`}`).join('\n')}`);
124
+ return 1;
125
+ }
126
+ const stamp = new Date(state.finished).toISOString().slice(0, 16).replace(/[-:T]/g, '');
127
+ const file = resolve(io.cwd, flags.out || `${factory.name}-${stamp}.md`);
128
+ const receiptFile = `${file.replace(/\.md$/, '')}.receipt.json`;
129
+ await mkdir(dirname(file), { recursive: true });
130
+ await writeFile(file, state.deliverable);
131
+ await writeFile(receiptFile, `${JSON.stringify(state.receipt, null, 2)}\n`);
132
+ const review = state.receipt.deliverable_review.status;
133
+ if (flags.json) json(io, { status: 'done', deliverable: relative(io.cwd, file), receipt: relative(io.cwd, receiptFile), review, flags: state.receipt.flags, usage: { calls: state.usage.llmCalls, input: state.usage.input, output: state.usage.output } });
134
+ else out(io, state.deliverable);
135
+ say(s.dim(`\nSaved ${relative(io.cwd, file)} and its receipt. Final check: ${review}. ${state.usage.llmCalls} model calls, ${state.usage.input + state.usage.output} tokens.`));
136
+ if (state.receipt.flags.length) say(s.dim(`Note: ${state.receipt.flags.join('; ')}`));
137
+ return 0;
138
+ }
139
+
140
+ export default {
141
+ name: 'run',
142
+ usage: `Usage: factoryhub run <factory> [--input name=value ...] [options]
143
+ factoryhub run <id> --hosted [--input name=value ...] [--yes] [--out file]
144
+
145
+ Runs the factory's steps in order on this machine. Steps that do not depend
146
+ on each other run at the same time. At every approval step the run stops,
147
+ shows you the text, and asks: approve, reject, or say what to change.
148
+
149
+ The model is yours. The first of these that is set decides:
150
+ ANTHROPIC_API_KEY Anthropic (default model claude-sonnet-5-5)
151
+ OPENAI_API_KEY OpenAI (default model gpt-5)
152
+ OPENROUTER_API_KEY OpenRouter (default model openrouter/auto)
153
+ or a server on your machine:
154
+ --provider ollama --model <name> Ollama at http://localhost:11434/v1
155
+ --base-url <url> --model <name> any OpenAI-compatible server
156
+
157
+ Web search needs an Anthropic model; with other models the agents read the
158
+ pages they are given. Agents read public web pages only.
159
+
160
+ When it finishes, the deliverable is printed and saved with a receipt of
161
+ every step. Inputs are asked for when missing and you are at a terminal.
162
+
163
+ With nobody at the terminal (a script, or an agent's shell) a decision is
164
+ never taken for you: the run stops there. With --save <file> it is saved, and
165
+ "factoryhub resume" continues it with the person's decision.
166
+
167
+ With --hosted, factoryhub runs the factory with that id on its own model,
168
+ on your account's daily allowance, and this command follows it. It needs a
169
+ login with runs:start and runs:read ("factoryhub login"). Where the run
170
+ waits for you, you decide here only with a token that holds the opt-in
171
+ runs:decide scope and the whole text fits; otherwise decide on the run page,
172
+ then read the result with "factoryhub runs <run-id>". The result is saved
173
+ with its signed receipt, checked here: on factory.mindburn.org against the
174
+ production keys pinned in this version of factoryhub, elsewhere against the
175
+ keys that hub publishes; the receipt's issuer must be that hub. Options
176
+ for a run on this machine, the experimental tool sources included, are
177
+ refused with --hosted.
178
+
179
+ Exit codes: 0 done, 1 failed or stopped by your decision, 2 usage,
180
+ 3 waiting for a decision (nothing was approved).
181
+
182
+ Options:
183
+ --input, -i name=value; name=@file reads the value from a file (repeatable)
184
+ --provider anthropic, openai, openrouter, ollama or openai-compatible
185
+ --model the model name (or set FACTORYHUB_MODEL)
186
+ --base-url an OpenAI-compatible server address
187
+ --api-key-env the environment variable holding the key for --base-url
188
+ --effort anthropic only: low, medium, high, xhigh or max
189
+ --max-output anthropic only: tokens each call may spend on thinking and
190
+ its answer (default 32000)
191
+ --timeout seconds one model call may take (default 300)
192
+ --check-model the model that runs every check step (default: --model), on
193
+ the same provider
194
+ --check-effort anthropic only: the effort for check steps (default: --effort)
195
+ --save where to save the run when it waits for a decision
196
+ --yes, -y approve every decision without asking; the receipt's runner
197
+ mode then reads "cli --yes"
198
+ --out where to save the deliverable (default: <name>-<time>.md)
199
+ --json print the outcome as JSON instead of the deliverable
200
+ --hosted run on factoryhub instead of this machine; <factory> is an id
201
+ --hub the factoryhub address for --hosted (default:
202
+ FACTORYHUB_BASE_URL or ${DEFAULT_HUB})
203
+
204
+ Experimental tool sources. Off unless --experimental-tools is given; they
205
+ will change with the RFC on tool sources (docs/experimental-tool-sources.md).
206
+ A step that names a tool a source offers gets it. Only allowed tools that
207
+ only read can run, and the receipt is marked experimental.
208
+ --experimental-tools turn on the options below
209
+ --mcp name=url: a remote MCP server over Streamable HTTP
210
+ (repeatable)
211
+ --mcp-token-env name=VAR: send the value of VAR, at least 16
212
+ characters, to that server as a bearer token
213
+ --allow-tool name:tool[,tool]: a tool steps may use (repeatable); it runs
214
+ only if its server marks it read-only
215
+ --read-only name:tool[,tool]: allow a tool and declare that it only reads
216
+ --nango integration[:tool,tool]: a short Nango agent session for
217
+ those tools, with NANGO_API_KEY (repeatable). When the
218
+ integration is not connected, prints the link to connect it
219
+ and stops with exit code 1
220
+ --nango-user the Nango end user whose connection is used (default
221
+ local-dev-user)
222
+
223
+ A flow may declare the sources it needs (requires: [tool-sources] and a
224
+ sources block, the form RFC 0017 proposes). The run shows what it asks for
225
+ and connects to a source only once you grant it: at a terminal you are asked
226
+ source by source; otherwise grant each one with a flag. --yes never grants.
227
+ --grant source: use the address the factory declares (repeatable)
228
+ --bind source=nango:integration: serve it from a Nango agent
229
+ session limited to the declared tools; or source=url: use
230
+ another address (repeatable)
231
+ --target source=value: the one target the run is limited to, for a
232
+ source that declares one (repeatable)
233
+ --mcp-token-env and --read-only take a declared source's name too`,
234
+ options: { boolean: ['yes', 'json', 'hosted', 'experimental-tools'], string: ['provider', 'model', 'base-url', 'api-key-env', 'effort', 'max-output', 'timeout', 'check-model', 'check-effort', 'out', 'save', 'nango-user', 'hub'], repeat: ['input', 'mcp', 'mcp-token-env', 'allow-tool', 'read-only', 'nango', 'grant', 'bind', 'target'], alias: { i: 'input', y: 'yes' } },
235
+ async run({ positional, flags }, io) {
236
+ const [target] = positional;
237
+ if (!target) throw usage('Name the factory to run.');
238
+ if (flags.hosted) {
239
+ const reader = lineReader(io);
240
+ try { return await hostedRun(target, flags, io, reader); } finally { reader.close(); }
241
+ }
242
+ if (flags.hub) throw usage('--hub names the factoryhub address for --hosted runs.');
243
+ const s = styles(io);
244
+ const read = await readFactory(target, { cwd: io.cwd, fetchImpl: io.fetch });
245
+ // Experimental and opt-in: a flow may declare the sources it needs
246
+ // (requires: [tool-sources], RFC 0017); otherwise flags may name them.
247
+ const declarations = flags['experimental-tools'] ? declaredSources(canonicalFiles(read.files)) : { declared: false };
248
+ const features = declarations.declared ? ['tool-sources'] : [];
249
+ const legacyPlan = declarations.declared ? null : toolSourcePlan(flags, io.env);
250
+ const parsed = engine.parseBundle(canonicalFiles(read.files), features.length ? { features } : undefined);
251
+ if (!parsed.ok) throw new Error(`This is not a valid factory:\n${parsed.errors.map((error) => ` - ${flags['experimental-tools'] ? displayLine(error, 1000) : error}`).join('\n')}`);
252
+ if (declarations.declared && declarations.problems.length) throw new Error(`The sources this factory declares cannot be used:\n${declarations.problems.map((problem) => ` - ${problem}`).join('\n')}`);
253
+ const plan = declarations.declared ? declaredPlan(declarations, flags, io.env) : legacyPlan;
254
+ if (plan && flags.save) throw usage('--save does not work with --experimental-tools yet: a run that uses tool sources cannot be resumed.');
255
+ if (declarations.declared) for (const [capability, steps] of Object.entries(declarations.uses)) {
256
+ const [source, tool] = capability.split('/');
257
+ const effect = declarations.sources[source].tools[tool].effect;
258
+ if (effect !== 'read') {
259
+ const step = parsed.factory.steps.find((item) => item.id === steps[0]);
260
+ throw new Error(`The step "${displayLine(step?.title || steps[0], 100)}" needs \`${capability}\`, which the flow declares as ${effect}. ${WRITE_REFUSED}`);
261
+ }
262
+ }
263
+ // With tool sources, the tools a step names are checked by name before any session opens.
264
+ const runnable = plan ? admitFactory(parsed.factory, plannedCatalog(plan.sources), { checkEffects: false, exact: declarations.declared }).factory : parsed.factory;
265
+ if (!runnable.runnable) throw new Error(runnable.blockers.map((blocker) => plan ? displayLine(blocker, 1000) : blocker).join('\n') || 'This factory has no flow.yaml, so there are no steps to run.');
266
+ const { factory } = parsed;
267
+ const settings = { provider: flags.provider || null, model: flags.model || null, baseUrl: flags['base-url'] || null, apiKeyEnv: flags['api-key-env'] || null,
268
+ effort: flags.effort || null, maxOutput: flags['max-output'] || null, timeout: flags.timeout || null,
269
+ checkModel: flags['check-model'] || null, checkEffort: flags['check-effort'] || null };
270
+ const provider = providerFor(settings, io);
271
+ settings.model = provider.model;
272
+
273
+ const inputs = {};
274
+ for (const pair of flags.input) {
275
+ const at = pair.indexOf('=');
276
+ if (at < 1) throw usage(`--input needs name=value, not "${pair}".`);
277
+ const key = pair.slice(0, at);
278
+ const value = pair.slice(at + 1);
279
+ inputs[key] = value.startsWith('@') ? await readFile(resolve(io.cwd, value.slice(1)), 'utf8') : value;
280
+ }
281
+
282
+ const line = plan ? displayLine : (value) => value;
283
+ const say = (text = '') => io.stderr.write(`${plan ? cleanTerminalText(text) : text}\n`);
284
+ const reader = lineReader(io);
285
+ let stopListening = null;
286
+ let opened;
287
+ try {
288
+ if (declarations.declared) {
289
+ // The factory says what it needs; the person grants it, source by
290
+ // source, before anything is connected. --yes never grants.
291
+ say(`\n${consentText({ title: factory.title, version: factory.version, hash: await engine.bundleHash(factory.files), plan, provider: `${provider.name}, ${provider.model}` })}`);
292
+ for (const source of plan.sources) {
293
+ if (!source.binding && io.interactive && source.url) {
294
+ const answer = await reader.ask(`\nGrant the source "${source.name}" for this run? It will be reached at:\n ${new URL(source.url).href}\nType yes to grant: `);
295
+ if (answer !== null && /^(y|yes)$/i.test(answer.trim())) source.binding = { kind: 'mcp', url: source.url, granted: 'declared' };
296
+ }
297
+ if (!source.binding) throw usage(`The factory asks for the source "${source.name}", and it was not granted, so nothing was connected or run. Grant it with --grant ${source.name}, or serve it from another place with --bind ${source.name}=nango:<integration> or --bind ${source.name}=<url>.`);
298
+ if (source.target && source.targetValue === undefined && io.interactive) {
299
+ const answer = await reader.ask(`\n${line(source.target.label, 40)} for "${source.name}"${source.target.help ? ` (${line(source.target.help, 200)})` : ''}: `);
300
+ if (answer !== null && answer.trim() && answer.length <= 500 && !/[\p{Cc}\p{Cf}]/u.test(answer)) source.targetValue = answer.trim();
301
+ }
302
+ if (source.target && source.targetValue === undefined) throw usage(`The source "${source.name}" is limited to one ${line(source.target.label, 40)}, and none was given, so nothing was connected or run. Give it with --target ${source.name}=<value>.`);
303
+ }
304
+ // Every grant and target is known before even a DNS lookup is made.
305
+ for (const source of plan.sources) {
306
+ if (source.binding.granted === 'declared') await checkDeclaredAddress(source, io.lookup ? { lookupImpl: io.lookup } : {});
307
+ Object.assign(source, { kind: source.binding.kind, url: source.binding.url, integration: source.binding.integration, granted: source.binding.granted });
308
+ }
309
+ }
310
+ // Ctrl+C ends what is open, from the moment the first source starts to open.
311
+ const open = plan ? openThings() : null;
312
+ stopListening = open ? io.onInterrupt?.(() => open.close()) || null : null;
313
+ opened = plan ? await openToolSources(plan, { env: io.env, fetchImpl: io.fetch, open }) : null;
314
+ if (opened?.needsConnection) {
315
+ const link = opened.needsConnection;
316
+ if (flags.json) json(io, { status: 'needs_connection', integration: link.integration, end_user: link.end_user_id, connect_url: link.connect_url, expires_at: link.expires_at });
317
+ say(`\n${link.integration} is not connected in Nango for "${link.end_user_id}", so nothing was run.\nOpen this link to connect it, then run the same command again:\n ${link.connect_url}${link.expires_at ? `\n${s.dim(`The link works until ${link.expires_at}.`)}` : ''}`);
318
+ return 1;
319
+ }
320
+ say(`\n${s.bold(line(factory.title, 120))} ${s.dim(`v${line(factory.version, 40)}, ${provider.name}, ${provider.model}`)}`);
321
+ say(line(factory.description, 2000));
322
+ if (opened) {
323
+ // Now the effects are known: a tool that may change data is refused before any model call.
324
+ const admitted = opened.sources.admit(parsed.factory, { exact: declarations.declared });
325
+ if (!admitted.factory.runnable) throw new Error(admitted.factory.blockers.map((blocker) => displayLine(blocker, 1000)).join('\n'));
326
+ say(s.dim(`Experimental tool sources: ${opened.sources.summary().map((source) => `${source.name} (${source.tools.map((tool) => `${tool.id.slice(source.name.length + 1)}: ${tool.effect}`).join(', ')})`).join('; ')}. The receipt says so.`));
327
+ // The person sees what the model will get of each definition, before any model does.
328
+ say(`\nTool definitions from the servers, pinned for this run, as the model gets them. Their text is the servers' own, not instructions:`);
329
+ for (const tool of opened.sources.describe()) {
330
+ const schema = JSON.stringify(tool.input_schema);
331
+ const cut = schema.length > SCHEMA_SHOWN ? ` (and ${schema.length - SCHEMA_SHOWN} more characters not shown here)` : '';
332
+ say(` ${tool.id} ${tool.effect} (${tool.effect_from}) sha256 ${tool.digest.slice(0, 12)}\n ${tool.description ? tool.description.replace(/\n/g, '\n ') : '(no description)'}\n input schema: ${schema.slice(0, SCHEMA_SHOWN)}${cut}`);
333
+ }
334
+ }
335
+ for (const input of factory.inputs) {
336
+ if (inputs[input.key] !== undefined || !io.interactive) continue;
337
+ const answer = await reader.ask(`\n${s.bold(line(input.label, 120))}${input.required ? '' : s.dim(' (optional, Enter to skip)')} ${s.dim(`${input.type}${input.type === 'longtext' ? ', or @file' : ''}`)}\n${s.dim(line(input.help, 500))}\n> `);
338
+ if (answer === null) break;
339
+ inputs[input.key] = answer.startsWith('@') ? await readFile(resolve(io.cwd, answer.slice(1).trim()), 'utf8') : answer;
340
+ }
341
+ // An unattended approval is recorded in the receipt's runner mode.
342
+ const prepared = prepareRun({ files: read.files, inputs, runner: { mode: flags.yes ? 'cli --yes' : 'cli', provider: provider.name, model: provider.model }, toolSources: opened?.sources || null, features });
343
+ if (!prepared.ok) throw usage(prepared.problems.join('\n'));
344
+ prepared.state.cli = settings;
345
+ return await drive(prepared.state, { provider, flags, io, reader, saveTo: flags.save ? resolve(io.cwd, flags.save) : null, toolSources: opened?.sources || null });
346
+ } finally {
347
+ reader.close();
348
+ stopListening?.();
349
+ await opened?.close?.();
350
+ }
351
+ },
352
+ };