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.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +195 -2
- package/factories/README.md +17 -0
- package/factories/client-file-readiness/README.md +3 -0
- package/factories/client-file-readiness/agents/analyst/agent.md +10 -0
- package/factories/client-file-readiness/agents/reviewer/agent.md +10 -0
- package/factories/client-file-readiness/agents/writer/agent.md +11 -0
- package/factories/client-file-readiness/factory.yaml +12 -0
- package/factories/client-file-readiness/flow.yaml +56 -0
- package/factories/competitor-teardown/README.md +3 -0
- package/factories/competitor-teardown/agents/analyst/agent.md +10 -0
- package/factories/competitor-teardown/agents/researcher/agent.md +11 -0
- package/factories/competitor-teardown/agents/reviewer/agent.md +10 -0
- package/factories/competitor-teardown/agents/writer/agent.md +11 -0
- package/factories/competitor-teardown/factory.yaml +11 -0
- package/factories/competitor-teardown/flow.yaml +75 -0
- package/factories/homepage-audit/README.md +3 -0
- package/factories/homepage-audit/agents/analyst/agent.md +10 -0
- package/factories/homepage-audit/agents/researcher/agent.md +11 -0
- package/factories/homepage-audit/agents/reviewer/agent.md +10 -0
- package/factories/homepage-audit/agents/writer/agent.md +11 -0
- package/factories/homepage-audit/factory.yaml +11 -0
- package/factories/homepage-audit/flow.yaml +77 -0
- package/factories/inbox-triage/README.md +3 -0
- package/factories/inbox-triage/agents/analyst/agent.md +10 -0
- package/factories/inbox-triage/agents/reviewer/agent.md +10 -0
- package/factories/inbox-triage/agents/writer/agent.md +11 -0
- package/factories/inbox-triage/factory.yaml +11 -0
- package/factories/inbox-triage/flow.yaml +57 -0
- package/factories/index.json +587 -0
- package/factories/launch-kit/README.md +3 -0
- package/factories/launch-kit/agents/reviewer/agent.md +10 -0
- package/factories/launch-kit/agents/strategist/agent.md +10 -0
- package/factories/launch-kit/agents/writer/agent.md +11 -0
- package/factories/launch-kit/factory.yaml +12 -0
- package/factories/launch-kit/flow.yaml +75 -0
- package/factories/meeting-to-actions/CHANGELOG.md +7 -0
- package/factories/meeting-to-actions/README.md +15 -0
- package/factories/meeting-to-actions/agents/analyst/agent.md +10 -0
- package/factories/meeting-to-actions/agents/reviewer/agent.md +10 -0
- package/factories/meeting-to-actions/agents/writer/agent.md +11 -0
- package/factories/meeting-to-actions/factory.yaml +11 -0
- package/factories/meeting-to-actions/flow.yaml +53 -0
- package/factories/meeting-to-actions/mindburn.requirements.yaml +8 -0
- package/factories/meeting-to-actions/mindburn.tests.yaml +16 -0
- package/factories/newsletter-note/README.md +11 -0
- package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
- package/factories/newsletter-note/agents/writer/agent.md +11 -0
- package/factories/newsletter-note/factory.yaml +12 -0
- package/factories/newsletter-note/flow.yaml +45 -0
- package/factories/newsletter-note/mindburn.tests.yaml +80 -0
- package/factories/outreach-pack/README.md +3 -0
- package/factories/outreach-pack/agents/reviewer/agent.md +5 -0
- package/factories/outreach-pack/agents/writer/agent.md +5 -0
- package/factories/outreach-pack/factory.yaml +9 -0
- package/factories/outreach-pack/flow.yaml +42 -0
- package/factories/prospect-brief/README.md +3 -0
- package/factories/prospect-brief/agents/researcher/agent.md +11 -0
- package/factories/prospect-brief/agents/reviewer/agent.md +10 -0
- package/factories/prospect-brief/agents/strategist/agent.md +10 -0
- package/factories/prospect-brief/agents/writer/agent.md +11 -0
- package/factories/prospect-brief/factory.yaml +11 -0
- package/factories/prospect-brief/flow.yaml +79 -0
- package/factories/release-desk/README.md +13 -0
- package/factories/release-desk/agents/editor/agent.md +10 -0
- package/factories/release-desk/agents/reviewer/agent.md +10 -0
- package/factories/release-desk/factory.yaml +13 -0
- package/factories/release-desk/flow.yaml +67 -0
- package/factories/release-desk/mindburn.tests.yaml +163 -0
- package/factories/short-video-cut/README.md +11 -0
- package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
- package/factories/short-video-cut/agents/writer/agent.md +11 -0
- package/factories/short-video-cut/factory.yaml +13 -0
- package/factories/short-video-cut/flow.yaml +46 -0
- package/factories/short-video-cut/mindburn.tests.yaml +80 -0
- package/factories/video-facts/README.md +11 -0
- package/factories/video-facts/agents/analyst/agent.md +10 -0
- package/factories/video-facts/agents/reviewer/agent.md +10 -0
- package/factories/video-facts/factory.yaml +13 -0
- package/factories/video-facts/flow.yaml +39 -0
- package/factories/video-facts/mindburn.tests.yaml +34 -0
- package/factories/video-to-posts/README.md +35 -0
- package/factories/video-to-posts/agents/analyst/agent.md +10 -0
- package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
- package/factories/video-to-posts/agents/writer/agent.md +11 -0
- package/factories/video-to-posts/factory.yaml +12 -0
- package/factories/video-to-posts/flow.yaml +62 -0
- package/factories/video-to-posts/mindburn.tests.yaml +34 -0
- package/factories/week-of-posts/README.md +11 -0
- package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
- package/factories/week-of-posts/agents/writer/agent.md +11 -0
- package/factories/week-of-posts/factory.yaml +12 -0
- package/factories/week-of-posts/flow.yaml +46 -0
- package/factories/week-of-posts/mindburn.tests.yaml +80 -0
- package/package.json +92 -4
- package/packages/cli/bin/factoryhub.mjs +8 -0
- package/packages/cli/package.json +29 -0
- package/packages/cli/src/args.mjs +47 -0
- package/packages/cli/src/commands/export.mjs +54 -0
- package/packages/cli/src/commands/get.mjs +37 -0
- package/packages/cli/src/commands/inspect.mjs +65 -0
- package/packages/cli/src/commands/list.mjs +24 -0
- package/packages/cli/src/commands/login.mjs +161 -0
- package/packages/cli/src/commands/logout.mjs +43 -0
- package/packages/cli/src/commands/mcp.mjs +25 -0
- package/packages/cli/src/commands/new.mjs +147 -0
- package/packages/cli/src/commands/pack.mjs +96 -0
- package/packages/cli/src/commands/publish.mjs +62 -0
- package/packages/cli/src/commands/pull.mjs +59 -0
- package/packages/cli/src/commands/push.mjs +115 -0
- package/packages/cli/src/commands/resume.mjs +66 -0
- package/packages/cli/src/commands/run.mjs +352 -0
- package/packages/cli/src/commands/runs.mjs +52 -0
- package/packages/cli/src/commands/search.mjs +30 -0
- package/packages/cli/src/commands/validate.mjs +79 -0
- package/packages/cli/src/commands/whoami.mjs +34 -0
- package/packages/cli/src/hosted.mjs +159 -0
- package/packages/cli/src/main.mjs +134 -0
- package/packages/cli/src/tool-sources.mjs +272 -0
- package/packages/cli/src/util.mjs +58 -0
- package/packages/engine/lib/catalog.mjs +50 -0
- package/packages/engine/lib/credentials.mjs +121 -0
- package/packages/engine/lib/hosted.mjs +119 -0
- package/packages/engine/lib/hub.mjs +423 -0
- package/packages/engine/lib/nango.mjs +157 -0
- package/packages/engine/lib/node.mjs +242 -0
- package/packages/engine/lib/providers.mjs +57 -0
- package/packages/engine/lib/run.mjs +192 -0
- package/packages/engine/lib/skill.mjs +229 -0
- package/packages/engine/lib/text.mjs +5 -0
- package/packages/engine/package.json +45 -0
- package/packages/engine/src/author-tests.mjs +78 -0
- package/packages/engine/src/compose.mjs +150 -0
- package/packages/engine/src/cost.mjs +41 -0
- package/packages/engine/src/diagnostics.mjs +21 -0
- package/packages/engine/src/factory-engine.mjs +2109 -0
- package/packages/engine/src/flow-sources.mjs +135 -0
- package/packages/engine/src/index.mjs +9 -0
- package/packages/engine/src/markdown.mjs +97 -0
- package/packages/engine/src/mcp-client.mjs +429 -0
- package/packages/engine/src/package.mjs +116 -0
- package/packages/engine/src/run.mjs +308 -0
- package/packages/engine/src/tool-sources.mjs +566 -0
- package/packages/mcp/bin/factoryhub-mcp.mjs +6 -0
- package/packages/mcp/package.json +36 -0
- package/packages/mcp/src/server.mjs +310 -0
- package/packages/sdk/package.json +28 -0
- package/packages/sdk/src/index.d.ts +163 -0
- package/packages/sdk/src/index.mjs +266 -0
- package/spec/README.md +42 -0
- package/spec/factory-format.md +866 -0
- package/spec/schema/factory.schema.json +43 -0
- package/spec/schema/factory.yaml.json +815 -0
- package/spec/schema/flow.schema.json +191 -0
- 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
|
+
};
|