factoryhub 0.1.0 → 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/README.md +9 -3
- package/factories/index.json +231 -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/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 +15 -2
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/export.mjs +0 -1
- package/packages/cli/src/commands/get.mjs +0 -1
- package/packages/cli/src/commands/inspect.mjs +0 -1
- package/packages/cli/src/commands/list.mjs +1 -6
- 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 +0 -1
- package/packages/cli/src/commands/new.mjs +0 -1
- package/packages/cli/src/commands/pack.mjs +0 -1
- 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 +0 -1
- package/packages/cli/src/commands/run.mjs +182 -20
- package/packages/cli/src/commands/runs.mjs +52 -0
- package/packages/cli/src/commands/search.mjs +0 -1
- package/packages/cli/src/commands/validate.mjs +1 -3
- package/packages/cli/src/commands/whoami.mjs +34 -0
- package/packages/cli/src/hosted.mjs +159 -0
- package/packages/cli/src/main.mjs +80 -14
- package/packages/cli/src/tool-sources.mjs +272 -0
- package/packages/cli/src/util.mjs +37 -0
- package/packages/engine/lib/credentials.mjs +121 -0
- package/packages/engine/lib/hosted.mjs +119 -0
- package/packages/engine/lib/hub.mjs +376 -24
- package/packages/engine/lib/nango.mjs +157 -0
- package/packages/engine/lib/node.mjs +161 -33
- package/packages/engine/lib/providers.mjs +8 -3
- package/packages/engine/lib/run.mjs +70 -9
- package/packages/engine/lib/text.mjs +5 -0
- package/packages/engine/package.json +8 -2
- package/packages/engine/src/factory-engine.mjs +415 -68
- package/packages/engine/src/flow-sources.mjs +135 -0
- package/packages/engine/src/mcp-client.mjs +429 -0
- package/packages/engine/src/run.mjs +81 -5
- package/packages/engine/src/tool-sources.mjs +566 -0
- package/packages/mcp/package.json +1 -1
- package/packages/mcp/src/server.mjs +47 -17
- 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 +7 -1
- package/spec/factory-format.md +264 -19
- package/spec/schema/factory.yaml.json +815 -0
- package/spec/schema/flow.schema.json +24 -2
- package/spec/skill-export.md +3 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { out, json, styles, usage, connect, BASE_URL_HELP } from '../util.mjs';
|
|
2
|
+
|
|
3
|
+
/** The reasons a refused submission carries, as lines for a person. */
|
|
4
|
+
export function refusalLines(details) {
|
|
5
|
+
if (!details) return [];
|
|
6
|
+
const lines = [];
|
|
7
|
+
for (const blocker of details.blockers || []) lines.push(` - ${blocker}`);
|
|
8
|
+
const tests = details.tests;
|
|
9
|
+
if (tests && typeof tests === 'object') {
|
|
10
|
+
lines.push(` - Declared examples: ${tests.status || 'not run'}${Number.isFinite(tests.passed) && Number.isFinite(tests.total) ? ` (${tests.passed} of ${tests.total} passed)` : ''}`);
|
|
11
|
+
for (const failure of (tests.failures || tests.cases || []).filter((item) => item && item.status && item.status !== 'passed').slice(0, 5)) lines.push(` ${failure.id || failure.name || 'case'}: ${failure.status}`);
|
|
12
|
+
}
|
|
13
|
+
const scan = details.scan;
|
|
14
|
+
if (scan && typeof scan === 'object') {
|
|
15
|
+
lines.push(` - Archive check: ${scan.status || 'not run'}${scan.summary ? `. ${scan.summary}` : ''}`);
|
|
16
|
+
for (const concern of (scan.concerns || scan.findings || []).slice(0, 5)) lines.push(` ${typeof concern === 'string' ? concern : concern.summary || concern.title || JSON.stringify(concern)}`);
|
|
17
|
+
}
|
|
18
|
+
return lines;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
name: 'publish',
|
|
23
|
+
usage: `Usage: factoryhub publish <id> [--note text] [--base-url url] [--json]
|
|
24
|
+
|
|
25
|
+
Publishes a factory you pushed. factoryhub runs the checks the site runs
|
|
26
|
+
first (for example, that it runs), and refuses with the reasons when one
|
|
27
|
+
fails. Publishing again changes nothing.
|
|
28
|
+
|
|
29
|
+
Needs a login with the opt-in factories:publish scope:
|
|
30
|
+
factoryhub login --scopes factories:read,factories:write,factories:publish
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
--note a note recorded with this publication
|
|
34
|
+
--base-url ${BASE_URL_HELP}
|
|
35
|
+
--json print the hub's answer as JSON`,
|
|
36
|
+
options: { boolean: ['json'], string: ['note', 'base-url'] },
|
|
37
|
+
async run({ positional, flags }, io) {
|
|
38
|
+
const [id, ...rest] = positional;
|
|
39
|
+
if (!id || rest.length) throw usage('Name the factory to publish: factoryhub publish <id> ("factoryhub push" prints the id.)');
|
|
40
|
+
const { hub } = await connect(flags, io, { needToken: true });
|
|
41
|
+
let answer;
|
|
42
|
+
try {
|
|
43
|
+
answer = await hub.publish(id, { note: flags.note || '' });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const lines = refusalLines(error.details);
|
|
46
|
+
if (lines.length || error.status === 409 || error.status === 422) error.message = [`Not published: ${error.message}`, ...lines].join('\n');
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
if (flags.json) { json(io, answer); return 0; }
|
|
50
|
+
const s = styles(io);
|
|
51
|
+
// The hub says what state the factory is in; nothing is assumed here.
|
|
52
|
+
const factory = answer.factory || {};
|
|
53
|
+
const url = factory.url || hub.sourceUrl(factory.id || id);
|
|
54
|
+
if (factory.visibility === 'public') {
|
|
55
|
+
out(io, `${answer.already ? s.dim('Already published:') : s.green('Published:')} ${s.bold(factory.id || id)} is live at ${url}`);
|
|
56
|
+
} else {
|
|
57
|
+
out(io, `${s.bold(factory.id || id)}: ${factory.visibility || 'unknown state'}. ${answer.message || ''}`.trim());
|
|
58
|
+
}
|
|
59
|
+
if (factory.safety) out(io, s.dim(`Safety check: ${typeof factory.safety === 'string' ? factory.safety : factory.safety.status || 'unknown'}`));
|
|
60
|
+
return 0;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -14,7 +14,6 @@ const REASONS = {
|
|
|
14
14
|
|
|
15
15
|
export default {
|
|
16
16
|
name: 'resume',
|
|
17
|
-
summary: 'Continue a saved run with the person\'s decision.',
|
|
18
17
|
usage: `Usage: factoryhub resume <run.json> --decision approve|revise|reject --seen <sha256> [--note text]
|
|
19
18
|
factoryhub resume <run.json> --show
|
|
20
19
|
|
|
@@ -4,6 +4,18 @@ import { createInterface } from 'node:readline';
|
|
|
4
4
|
import { engine, readFactory, canonicalFiles, out, json, styles, usage } from '../util.mjs';
|
|
5
5
|
import { chooseProvider } from '../../../engine/lib/providers.mjs';
|
|
6
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.`;
|
|
7
19
|
|
|
8
20
|
/** Reads typed lines one at a time, keeping answers typed ahead. */
|
|
9
21
|
export function lineReader(io) {
|
|
@@ -23,29 +35,43 @@ export function lineReader(io) {
|
|
|
23
35
|
|
|
24
36
|
/** The provider a saved run continues with: the same settings, the key from this environment. */
|
|
25
37
|
export function providerFor(settings, io) {
|
|
26
|
-
|
|
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 });
|
|
27
47
|
}
|
|
28
48
|
|
|
29
49
|
/**
|
|
30
50
|
* Runs a prepared or restored run to its end or to a decision nobody answered,
|
|
31
51
|
* then reports it. Shared by `run` and `resume`. Returns the exit code.
|
|
32
52
|
*/
|
|
33
|
-
export async function drive(state, { provider, flags, io, reader, saveTo }) {
|
|
53
|
+
export async function drive(state, { provider, flags, io, reader, saveTo, toolSources = null }) {
|
|
34
54
|
const s = styles(io);
|
|
35
|
-
const say = (text = '') => io.stderr.write(`${text}\n`);
|
|
55
|
+
const say = (text = '') => io.stderr.write(`${toolSources ? cleanTerminalText(text) : text}\n`);
|
|
36
56
|
const { factory } = state;
|
|
37
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);
|
|
38
60
|
const host = nodeHost({
|
|
39
61
|
provider,
|
|
40
62
|
fetchImpl: io.fetch,
|
|
41
63
|
emit(event) {
|
|
42
64
|
if (event.type === 'step_started') say(`\n${s.bold(event.title)} ${s.dim(event.agent)}${event.redo ? s.dim(' (redoing)') : ''}`);
|
|
43
|
-
else if (event.type === 'tool') say(s.dim(` ${titleOf(event.step)}: ${event.tool === 'web_search' ?
|
|
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}`}`));
|
|
44
66
|
else if (event.type === 'step_done') say(s.dim(` done, ${event.chars} characters`));
|
|
45
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('')}`);
|
|
46
|
-
else if (event.type === 'run_failed') say(`\n${s.red('Stopped')}: ${
|
|
68
|
+
else if (event.type === 'run_failed') say(`\n${s.red('Stopped')}: ${errorText()}`);
|
|
47
69
|
},
|
|
48
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;
|
|
49
75
|
|
|
50
76
|
await continueRun(state, host, async (approval, text, problem) => {
|
|
51
77
|
const seen = await engine.sha256(text);
|
|
@@ -77,7 +103,8 @@ export async function drive(state, { provider, flags, io, reader, saveTo }) {
|
|
|
77
103
|
if (saved) {
|
|
78
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)}`);
|
|
79
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}`);
|
|
80
|
-
} else say(`\nWaiting at "${decision.title}": nobody answered, so nothing was approved and no result was written.\
|
|
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.`);
|
|
81
108
|
return 3;
|
|
82
109
|
}
|
|
83
110
|
if (state.status === 'stopped') {
|
|
@@ -86,7 +113,14 @@ export async function drive(state, { provider, flags, io, reader, saveTo }) {
|
|
|
86
113
|
return 1;
|
|
87
114
|
}
|
|
88
115
|
if (state.status !== 'done') {
|
|
89
|
-
|
|
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')}`);
|
|
90
124
|
return 1;
|
|
91
125
|
}
|
|
92
126
|
const stamp = new Date(state.finished).toISOString().slice(0, 16).replace(/[-:T]/g, '');
|
|
@@ -105,8 +139,8 @@ export async function drive(state, { provider, flags, io, reader, saveTo }) {
|
|
|
105
139
|
|
|
106
140
|
export default {
|
|
107
141
|
name: 'run',
|
|
108
|
-
summary: 'Run a factory with your own model key or a local model. Stops at every decision.',
|
|
109
142
|
usage: `Usage: factoryhub run <factory> [--input name=value ...] [options]
|
|
143
|
+
factoryhub run <id> --hosted [--input name=value ...] [--yes] [--out file]
|
|
110
144
|
|
|
111
145
|
Runs the factory's steps in order on this machine. Steps that do not depend
|
|
112
146
|
on each other run at the same time. At every approval step the run stops,
|
|
@@ -130,6 +164,18 @@ With nobody at the terminal (a script, or an agent's shell) a decision is
|
|
|
130
164
|
never taken for you: the run stops there. With --save <file> it is saved, and
|
|
131
165
|
"factoryhub resume" continues it with the person's decision.
|
|
132
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
|
+
|
|
133
179
|
Exit codes: 0 done, 1 failed or stopped by your decision, 2 usage,
|
|
134
180
|
3 waiting for a decision (nothing was approved).
|
|
135
181
|
|
|
@@ -139,22 +185,88 @@ Options:
|
|
|
139
185
|
--model the model name (or set FACTORYHUB_MODEL)
|
|
140
186
|
--base-url an OpenAI-compatible server address
|
|
141
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)
|
|
142
195
|
--save where to save the run when it waits for a decision
|
|
143
196
|
--yes, -y approve every decision without asking; the receipt's runner
|
|
144
197
|
mode then reads "cli --yes"
|
|
145
198
|
--out where to save the deliverable (default: <name>-<time>.md)
|
|
146
|
-
--json print the outcome as JSON instead of the deliverable
|
|
147
|
-
|
|
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' } },
|
|
148
235
|
async run({ positional, flags }, io) {
|
|
149
236
|
const [target] = positional;
|
|
150
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.');
|
|
151
243
|
const s = styles(io);
|
|
152
244
|
const read = await readFactory(target, { cwd: io.cwd, fetchImpl: io.fetch });
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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.');
|
|
156
266
|
const { factory } = parsed;
|
|
157
|
-
const settings = { provider: flags.provider || null, model: flags.model || null, baseUrl: flags['base-url'] || null, apiKeyEnv: flags['api-key-env'] || null
|
|
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 };
|
|
158
270
|
const provider = providerFor(settings, io);
|
|
159
271
|
settings.model = provider.model;
|
|
160
272
|
|
|
@@ -167,24 +279,74 @@ Options:
|
|
|
167
279
|
inputs[key] = value.startsWith('@') ? await readFile(resolve(io.cwd, value.slice(1)), 'utf8') : value;
|
|
168
280
|
}
|
|
169
281
|
|
|
282
|
+
const line = plan ? displayLine : (value) => value;
|
|
283
|
+
const say = (text = '') => io.stderr.write(`${plan ? cleanTerminalText(text) : text}\n`);
|
|
170
284
|
const reader = lineReader(io);
|
|
171
|
-
|
|
285
|
+
let stopListening = null;
|
|
286
|
+
let opened;
|
|
172
287
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
}
|
|
175
335
|
for (const input of factory.inputs) {
|
|
176
336
|
if (inputs[input.key] !== undefined || !io.interactive) continue;
|
|
177
|
-
const answer = await reader.ask(`\n${s.bold(input.label)}${input.required ? '' : s.dim(' (optional, Enter to skip)')} ${s.dim(`${input.type}${input.type === 'longtext' ? ', or @file' : ''}`)}\n${s.dim(input.help)}\n> `);
|
|
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> `);
|
|
178
338
|
if (answer === null) break;
|
|
179
339
|
inputs[input.key] = answer.startsWith('@') ? await readFile(resolve(io.cwd, answer.slice(1).trim()), 'utf8') : answer;
|
|
180
340
|
}
|
|
181
341
|
// An unattended approval is recorded in the receipt's runner mode.
|
|
182
|
-
const prepared = prepareRun({ files: read.files, inputs, runner: { mode: flags.yes ? 'cli --yes' : 'cli', provider: provider.name, model: provider.model } });
|
|
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 });
|
|
183
343
|
if (!prepared.ok) throw usage(prepared.problems.join('\n'));
|
|
184
344
|
prepared.state.cli = settings;
|
|
185
|
-
return await drive(prepared.state, { provider, flags, io, reader, saveTo: flags.save ? resolve(io.cwd, flags.save) : null });
|
|
345
|
+
return await drive(prepared.state, { provider, flags, io, reader, saveTo: flags.save ? resolve(io.cwd, flags.save) : null, toolSources: opened?.sources || null });
|
|
186
346
|
} finally {
|
|
187
347
|
reader.close();
|
|
348
|
+
stopListening?.();
|
|
349
|
+
await opened?.close?.();
|
|
188
350
|
}
|
|
189
351
|
},
|
|
190
352
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { out, json, table, styles, usage, connect, BASE_URL_HELP } from '../util.mjs';
|
|
2
|
+
import { listRuns, finish } from '../hosted.mjs';
|
|
3
|
+
import { FINISHED } from '../../../engine/lib/hosted.mjs';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
name: 'runs',
|
|
7
|
+
usage: `Usage: factoryhub runs [--limit n] [--base-url url] [--json]
|
|
8
|
+
factoryhub runs <run-id> [--out file] [--base-url url] [--json]
|
|
9
|
+
|
|
10
|
+
With no run id, lists your recent hosted runs, newest first.
|
|
11
|
+
|
|
12
|
+
With a run id, shows where that run is. A finished run's result is printed
|
|
13
|
+
and saved with its signed receipt, which is checked here: on
|
|
14
|
+
factory.mindburn.org against the production keys pinned in this version of
|
|
15
|
+
factoryhub, elsewhere against the keys that hub publishes; the receipt's
|
|
16
|
+
issuer must be that hub. A run waiting for you names the step and the page
|
|
17
|
+
where you decide.
|
|
18
|
+
|
|
19
|
+
Needs a login with the runs:read scope.
|
|
20
|
+
|
|
21
|
+
Exit codes with a run id: 0 done and verified, 1 failed, stopped or not
|
|
22
|
+
verified, 3 waiting for a decision.
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--limit how many runs to list, 1 to 30 (default 15)
|
|
26
|
+
--out where to save the result (default: <factory>-<time>.md)
|
|
27
|
+
--base-url ${BASE_URL_HELP}
|
|
28
|
+
--json print the answer as JSON`,
|
|
29
|
+
options: { boolean: ['json'], string: ['limit', 'out', 'base-url'] },
|
|
30
|
+
async run({ positional, flags }, io) {
|
|
31
|
+
const [id, ...rest] = positional;
|
|
32
|
+
if (rest.length) throw usage('Show one run at a time: factoryhub runs <run-id>');
|
|
33
|
+
if (!id) {
|
|
34
|
+
if (flags.out) throw usage('--out saves one run\'s result: factoryhub runs <run-id> --out file.md');
|
|
35
|
+
const runs = await listRuns(flags, io);
|
|
36
|
+
if (flags.json) { json(io, runs); return 0; }
|
|
37
|
+
if (!runs.length) { out(io, 'No hosted runs yet. Start one with: factoryhub run <id> --hosted'); return 0; }
|
|
38
|
+
out(io, table([['ID', 'STATUS', 'STARTED', 'FACTORY'], ...runs.map((run) => [run.id, run.status, String(run.started || '').slice(0, 16).replace('T', ' '), run.title || run.factoryId])]));
|
|
39
|
+
out(io, styles(io).dim('\nShow one, and save its result: factoryhub runs <run-id>'));
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
if (flags.limit) throw usage('--limit applies to the list of runs, not to one run.');
|
|
43
|
+
const { hub } = await connect(flags, io, { needToken: true });
|
|
44
|
+
const run = await hub.getRun(id);
|
|
45
|
+
if (!run.waiting && !FINISHED.has(run.status)) {
|
|
46
|
+
if (flags.json) json(io, { status: run.status, run: run.id, url: run.url });
|
|
47
|
+
else out(io, `${run.title || run.factoryId}: ${run.status}. Follow it on ${run.url}`);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
return finish(hub, run, { io, flags, name: run.factoryId || 'run' });
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -7,7 +7,6 @@ and it is sent as the x-hub-access-key header.`;
|
|
|
7
7
|
|
|
8
8
|
export default {
|
|
9
9
|
name: 'search',
|
|
10
|
-
summary: 'Search the published factories on factoryhub (needs FACTORYHUB_ACCESS_KEY in preview).',
|
|
11
10
|
usage: `Usage: factoryhub search [words ...] [--base-url url] [--json]
|
|
12
11
|
|
|
13
12
|
Lists the published factories whose id, title, description or tags contain
|
|
@@ -4,7 +4,6 @@ import { engine, readFactory, out, json, plural, styles, usage, canonicalFiles }
|
|
|
4
4
|
|
|
5
5
|
export default {
|
|
6
6
|
name: 'validate',
|
|
7
|
-
summary: 'Check factories the way the engine reads them. No key, no model.',
|
|
8
7
|
usage: `Usage: factoryhub validate [factory ...] [--json]
|
|
9
8
|
|
|
10
9
|
Reads each factory with the engine's parser and lists every problem, every
|
|
@@ -20,8 +19,7 @@ Options:
|
|
|
20
19
|
async run({ positional, flags }, io) {
|
|
21
20
|
const targets = positional.length ? positional : existsSync(join(io.cwd, 'factory.yaml')) ? ['.'] : [];
|
|
22
21
|
if (!targets.length) throw usage('Name a factory to validate, or run this inside a factory folder.');
|
|
23
|
-
const results =
|
|
24
|
-
for (const target of targets) results.push(await check(target, io));
|
|
22
|
+
const results = await Promise.all(targets.map((target) => check(target, io)));
|
|
25
23
|
if (flags.json) json(io, results);
|
|
26
24
|
else results.forEach((result, index) => { if (index) out(io); print(result, io); });
|
|
27
25
|
return results.every((result) => result.runnable) ? 0 : 1;
|