factoryhub 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/README.md +189 -2
  4. package/factories/README.md +17 -0
  5. package/factories/client-file-readiness/README.md +3 -0
  6. package/factories/client-file-readiness/agents/analyst/agent.md +10 -0
  7. package/factories/client-file-readiness/agents/reviewer/agent.md +10 -0
  8. package/factories/client-file-readiness/agents/writer/agent.md +11 -0
  9. package/factories/client-file-readiness/factory.yaml +12 -0
  10. package/factories/client-file-readiness/flow.yaml +56 -0
  11. package/factories/competitor-teardown/README.md +3 -0
  12. package/factories/competitor-teardown/agents/analyst/agent.md +10 -0
  13. package/factories/competitor-teardown/agents/researcher/agent.md +11 -0
  14. package/factories/competitor-teardown/agents/reviewer/agent.md +10 -0
  15. package/factories/competitor-teardown/agents/writer/agent.md +11 -0
  16. package/factories/competitor-teardown/factory.yaml +11 -0
  17. package/factories/competitor-teardown/flow.yaml +75 -0
  18. package/factories/homepage-audit/README.md +3 -0
  19. package/factories/homepage-audit/agents/analyst/agent.md +10 -0
  20. package/factories/homepage-audit/agents/researcher/agent.md +11 -0
  21. package/factories/homepage-audit/agents/reviewer/agent.md +10 -0
  22. package/factories/homepage-audit/agents/writer/agent.md +11 -0
  23. package/factories/homepage-audit/factory.yaml +11 -0
  24. package/factories/homepage-audit/flow.yaml +77 -0
  25. package/factories/inbox-triage/README.md +3 -0
  26. package/factories/inbox-triage/agents/analyst/agent.md +10 -0
  27. package/factories/inbox-triage/agents/reviewer/agent.md +10 -0
  28. package/factories/inbox-triage/agents/writer/agent.md +11 -0
  29. package/factories/inbox-triage/factory.yaml +11 -0
  30. package/factories/inbox-triage/flow.yaml +57 -0
  31. package/factories/index.json +356 -0
  32. package/factories/launch-kit/README.md +3 -0
  33. package/factories/launch-kit/agents/reviewer/agent.md +10 -0
  34. package/factories/launch-kit/agents/strategist/agent.md +10 -0
  35. package/factories/launch-kit/agents/writer/agent.md +11 -0
  36. package/factories/launch-kit/factory.yaml +12 -0
  37. package/factories/launch-kit/flow.yaml +75 -0
  38. package/factories/meeting-to-actions/CHANGELOG.md +7 -0
  39. package/factories/meeting-to-actions/README.md +15 -0
  40. package/factories/meeting-to-actions/agents/analyst/agent.md +10 -0
  41. package/factories/meeting-to-actions/agents/reviewer/agent.md +10 -0
  42. package/factories/meeting-to-actions/agents/writer/agent.md +11 -0
  43. package/factories/meeting-to-actions/factory.yaml +11 -0
  44. package/factories/meeting-to-actions/flow.yaml +53 -0
  45. package/factories/meeting-to-actions/mindburn.requirements.yaml +8 -0
  46. package/factories/meeting-to-actions/mindburn.tests.yaml +16 -0
  47. package/factories/outreach-pack/README.md +3 -0
  48. package/factories/outreach-pack/agents/reviewer/agent.md +5 -0
  49. package/factories/outreach-pack/agents/writer/agent.md +5 -0
  50. package/factories/outreach-pack/factory.yaml +9 -0
  51. package/factories/outreach-pack/flow.yaml +42 -0
  52. package/factories/prospect-brief/README.md +3 -0
  53. package/factories/prospect-brief/agents/researcher/agent.md +11 -0
  54. package/factories/prospect-brief/agents/reviewer/agent.md +10 -0
  55. package/factories/prospect-brief/agents/strategist/agent.md +10 -0
  56. package/factories/prospect-brief/agents/writer/agent.md +11 -0
  57. package/factories/prospect-brief/factory.yaml +11 -0
  58. package/factories/prospect-brief/flow.yaml +79 -0
  59. package/package.json +79 -4
  60. package/packages/cli/bin/factoryhub.mjs +8 -0
  61. package/packages/cli/package.json +29 -0
  62. package/packages/cli/src/args.mjs +47 -0
  63. package/packages/cli/src/commands/export.mjs +55 -0
  64. package/packages/cli/src/commands/get.mjs +38 -0
  65. package/packages/cli/src/commands/inspect.mjs +66 -0
  66. package/packages/cli/src/commands/list.mjs +29 -0
  67. package/packages/cli/src/commands/mcp.mjs +26 -0
  68. package/packages/cli/src/commands/new.mjs +148 -0
  69. package/packages/cli/src/commands/pack.mjs +97 -0
  70. package/packages/cli/src/commands/resume.mjs +67 -0
  71. package/packages/cli/src/commands/run.mjs +190 -0
  72. package/packages/cli/src/commands/search.mjs +31 -0
  73. package/packages/cli/src/commands/validate.mjs +81 -0
  74. package/packages/cli/src/main.mjs +68 -0
  75. package/packages/cli/src/util.mjs +21 -0
  76. package/packages/engine/lib/catalog.mjs +50 -0
  77. package/packages/engine/lib/hub.mjs +71 -0
  78. package/packages/engine/lib/node.mjs +114 -0
  79. package/packages/engine/lib/providers.mjs +52 -0
  80. package/packages/engine/lib/run.mjs +131 -0
  81. package/packages/engine/lib/skill.mjs +229 -0
  82. package/packages/engine/package.json +39 -0
  83. package/packages/engine/src/author-tests.mjs +78 -0
  84. package/packages/engine/src/compose.mjs +150 -0
  85. package/packages/engine/src/cost.mjs +41 -0
  86. package/packages/engine/src/diagnostics.mjs +21 -0
  87. package/packages/engine/src/factory-engine.mjs +1762 -0
  88. package/packages/engine/src/index.mjs +9 -0
  89. package/packages/engine/src/markdown.mjs +97 -0
  90. package/packages/engine/src/package.mjs +116 -0
  91. package/packages/engine/src/run.mjs +232 -0
  92. package/packages/mcp/bin/factoryhub-mcp.mjs +6 -0
  93. package/packages/mcp/package.json +36 -0
  94. package/packages/mcp/src/server.mjs +280 -0
  95. package/spec/README.md +36 -0
  96. package/spec/factory-format.md +621 -0
  97. package/spec/schema/factory.schema.json +43 -0
  98. package/spec/schema/flow.schema.json +169 -0
  99. package/spec/skill-export.md +126 -0
@@ -0,0 +1,68 @@
1
+ // factoryhub: find, run, make and share AI factories from the terminal.
2
+ import process from 'node:process';
3
+ import { readFileSync } from 'node:fs';
4
+ import { parseArgs } from './args.mjs';
5
+ import validate from './commands/validate.mjs';
6
+ import inspect from './commands/inspect.mjs';
7
+ import list from './commands/list.mjs';
8
+ import create from './commands/new.mjs';
9
+ import pack from './commands/pack.mjs';
10
+ import run from './commands/run.mjs';
11
+ import resume from './commands/resume.mjs';
12
+ import exportCommand from './commands/export.mjs';
13
+ import search from './commands/search.mjs';
14
+ import get from './commands/get.mjs';
15
+ import mcp from './commands/mcp.mjs';
16
+
17
+ export const COMMANDS = [list, inspect, validate, run, resume, create, pack, exportCommand, search, get, mcp];
18
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
19
+
20
+ /** The streams and environment a command uses. Tests pass their own. */
21
+ export function defaultIo() {
22
+ return {
23
+ stdout: process.stdout,
24
+ stderr: process.stderr,
25
+ stdin: process.stdin,
26
+ env: process.env,
27
+ cwd: process.cwd(),
28
+ fetch: globalThis.fetch,
29
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
30
+ color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR,
31
+ };
32
+ }
33
+
34
+ function help(io) {
35
+ const width = Math.max(...COMMANDS.map((command) => command.name.length));
36
+ io.stdout.write(`factoryhub ${VERSION}: find, run, make and share AI factories.
37
+
38
+ Usage: factoryhub <command> [options]
39
+
40
+ Commands:
41
+ ${COMMANDS.map((command) => ` ${command.name.padEnd(width)} ${command.summary}`).join('\n')}
42
+
43
+ Run "factoryhub <command> --help" for a command's options.
44
+ A factory is a folder with factory.yaml, flow.yaml and agents/<name>/agent.md.
45
+ The format: spec/factory-format.md in https://github.com/Mindburn-Labs/factoryhub
46
+ `);
47
+ }
48
+
49
+ /** Runs one command line. Returns the exit code. */
50
+ export async function main(argv, io = defaultIo()) {
51
+ const [name, ...rest] = argv;
52
+ if (!name || name === '--help' || name === '-h' || name === 'help') { help(io); return 0; }
53
+ if (name === '--version' || name === '-v') { io.stdout.write(`${VERSION}\n`); return 0; }
54
+ const command = COMMANDS.find((item) => item.name === name);
55
+ if (!command) { io.stderr.write(`Unknown command "${name}". Run "factoryhub --help" for the list.\n`); return 2; }
56
+ let args;
57
+ try { args = parseArgs(rest, command.options || {}); } catch (error) {
58
+ io.stderr.write(`${error.message}\n\n${command.usage}\n`);
59
+ return 2;
60
+ }
61
+ if (args.flags.help) { io.stdout.write(`${command.usage}\n`); return 0; }
62
+ try {
63
+ return await command.run(args, io);
64
+ } catch (error) {
65
+ io.stderr.write(`${error.message}\n`);
66
+ return error.usage ? 2 : 1;
67
+ }
68
+ }
@@ -0,0 +1,21 @@
1
+ // Shared bits for the commands: styling, output and the engine imports.
2
+ export * as engine from '../../engine/src/factory-engine.mjs';
3
+ export { readFactory, readFolder, writeFiles, listFactoryDirs, usage, HOUSE_FACTORIES } from '../../engine/lib/node.mjs';
4
+ export { summarize, canonicalFiles, matches } from '../../engine/lib/catalog.mjs';
5
+
6
+ export function styles(io) {
7
+ const wrap = (open, close) => (text) => (io.color ? `\x1b[${open}m${text}\x1b[${close}m` : String(text));
8
+ return { bold: wrap(1, 22), dim: wrap(2, 22), red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39) };
9
+ }
10
+
11
+ export const out = (io, text = '') => io.stdout.write(`${text}\n`);
12
+ export const err = (io, text = '') => io.stderr.write(`${text}\n`);
13
+ export const json = (io, value) => io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
14
+ export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
15
+
16
+ /** Lays rows out in columns, padding every column but the last. */
17
+ export function table(rows) {
18
+ if (!rows.length) return '';
19
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => String(row[column] ?? '').length)));
20
+ return rows.map((row) => row.map((cell, column) => (column === row.length - 1 ? String(cell ?? '') : String(cell ?? '').padEnd(widths[column]))).join(' ').trimEnd()).join('\n');
21
+ }
@@ -0,0 +1,50 @@
1
+ // A factory's public summary, the same fields the factoryhub site lists, and
2
+ // the canonical bundle those fields and hashes are computed from.
3
+ import { parseBundle, withForeman, bundleHash, inputSchema } from '../src/factory-engine.mjs';
4
+
5
+ /**
6
+ * The canonical bundle: the source files plus the foreman the engine writes
7
+ * from the flow when the author did not write one. An author's own foreman
8
+ * is kept byte for byte. Its hash is the factory's identity.
9
+ */
10
+ export function canonicalFiles(files) {
11
+ return withForeman(files);
12
+ }
13
+
14
+ /** A summary of a factory for listings, search and `inspect`. */
15
+ export async function summarize(files, { id } = {}) {
16
+ const bundle = canonicalFiles(files);
17
+ const { ok, errors, warnings, factory } = parseBundle(bundle);
18
+ return {
19
+ id: id || factory.name,
20
+ name: factory.name,
21
+ title: factory.title,
22
+ description: factory.description,
23
+ author: factory.author,
24
+ version: factory.version,
25
+ license: factory.license,
26
+ tags: factory.tags,
27
+ time: factory.time,
28
+ valid: ok,
29
+ runnable: ok && factory.runnable,
30
+ errors,
31
+ warnings,
32
+ blockers: factory.blockers,
33
+ inputs: factory.inputs,
34
+ steps: factory.steps.map((step) => ({ id: step.id, title: step.title, kind: step.kind, agent: step.agent, needs: step.needs, target: step.target, tools: step.tools, missing: step.missing })),
35
+ agents: Object.values(factory.agents).map((agent) => ({ name: agent.name, description: agent.description, type: agent.type })),
36
+ tools: [...new Set(factory.steps.flatMap((step) => [...step.tools, ...step.missing]))].sort(),
37
+ done: factory.done,
38
+ files: Object.keys(bundle),
39
+ sha256: await bundleHash(bundle),
40
+ ...(ok ? { inputSchema: inputSchema(factory) } : {}),
41
+ };
42
+ }
43
+
44
+ /** Keeps the entries whose words contain every word of the query. */
45
+ export function matches(entry, query) {
46
+ const words = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
47
+ if (!words.length) return true;
48
+ const text = [entry.id, entry.name, entry.title, entry.description, ...(entry.tags || [])].join(' ').toLowerCase();
49
+ return words.every((word) => text.includes(word));
50
+ }
@@ -0,0 +1,71 @@
1
+ // A client for the public read API of the factoryhub site.
2
+ //
3
+ // GET {base}/api/factories the published catalogue
4
+ // GET {base}/f/{id}.json one factory as a transfer bundle, each file with its SHA-256
5
+ //
6
+ // While the site is in private preview it answers only requests that carry
7
+ // the preview access key in the `x-hub-access-key` header. Everyone else
8
+ // gets 404. Set FACTORYHUB_ACCESS_KEY to send it.
9
+ import { fromEnvelope } from '../src/factory-engine.mjs';
10
+ import { matches } from './catalog.mjs';
11
+
12
+ export const DEFAULT_HUB = 'https://factory.mindburn.org';
13
+ export const ACCESS_HEADER = 'x-hub-access-key';
14
+ const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
+
16
+ export class HubError extends Error {
17
+ constructor(message, { status, preview = false } = {}) {
18
+ super(message);
19
+ this.status = status;
20
+ this.preview = preview;
21
+ }
22
+ }
23
+
24
+ export function hubClient({ baseUrl, accessKey, env = process.env, fetchImpl = fetch } = {}) {
25
+ const base = String(baseUrl || env.FACTORYHUB_BASE_URL || DEFAULT_HUB).replace(/\/+$/, '');
26
+ const key = accessKey ?? env.FACTORYHUB_ACCESS_KEY ?? '';
27
+ const headers = { accept: 'application/json', ...(key ? { [ACCESS_HEADER]: key } : {}) };
28
+
29
+ async function getJson(path) {
30
+ let response;
31
+ try {
32
+ response = await fetchImpl(`${base}${path}`, { headers, signal: AbortSignal.timeout(30000) });
33
+ } catch (error) {
34
+ throw new HubError(`Could not reach ${base}: ${error.message}`);
35
+ }
36
+ const text = await response.text();
37
+ if (response.status === 404 && /private preview/i.test(text)) {
38
+ throw new HubError(`${base} is in private preview and answers only with an access key. ${key ? 'The FACTORYHUB_ACCESS_KEY you set was not accepted.' : 'Set FACTORYHUB_ACCESS_KEY to the key you were given.'}`, { status: 404, preview: true });
39
+ }
40
+ let body = null;
41
+ try { body = JSON.parse(text); } catch { /* reported below */ }
42
+ if (!response.ok) throw new HubError(body?.message || `${base}${path} answered with status ${response.status}.`, { status: response.status });
43
+ if (!body || typeof body !== 'object') throw new HubError(`${base}${path} did not answer with JSON.`, { status: response.status });
44
+ return body;
45
+ }
46
+
47
+ return {
48
+ base,
49
+ /** Every published factory in the catalogue. */
50
+ async list() {
51
+ const body = await getJson('/api/factories');
52
+ if (!Array.isArray(body.factories)) throw new HubError('The catalogue answer has no factories list.');
53
+ return body.factories;
54
+ },
55
+ /** Published factories whose id, title, description or tags contain every word of `query`. */
56
+ async search(query) {
57
+ return (await this.list()).filter((entry) => matches(entry, query));
58
+ },
59
+ /** One factory's files, each checked against its recorded SHA-256. */
60
+ async get(id) {
61
+ if (!ID.test(String(id || ''))) throw new HubError(`"${id}" is not a factory id. Ids are lowercase words joined by hyphens.`);
62
+ const envelope = await getJson(`/f/${id}.json`);
63
+ const read = await fromEnvelope(envelope);
64
+ if (read.problems.length) throw new HubError(`The bundle for ${id} did not pass its checks:\n${read.problems.map((item) => ` - ${item}`).join('\n')}`);
65
+ return { id, files: read.files, leftOut: read.leftOut, source: envelope.source?.url || `${base}/f/${id}` };
66
+ },
67
+ sourceUrl(id) {
68
+ return `${base}/f/${id}`;
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,114 @@
1
+ // Node.js helpers around the engine: read a factory from a folder, a bundle
2
+ // file or an address, and write one back to disk. The engine itself stays
3
+ // free of file system access so the same code runs in a browser.
4
+ import { readFile, writeFile, readdir, mkdir, lstat } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import { join, relative, resolve, dirname, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { ALLOWED_PATH, fromEnvelope, normalizeFiles } from '../src/factory-engine.mjs';
9
+
10
+ // The folders a factory's companion files may live in. Only these are walked,
11
+ // so a factory kept inside a project never pulls in node_modules or the like.
12
+ export const SOURCE_FOLDERS = new Set(['agents', 'automations', 'scorers', 'runners', 'routers', 'webhooks', 'benchmarks', 'skills', 'self-improvement',
13
+ 'profiles', 'flows', 'schemas', 'prompts', 'assets', 'examples', 'checks', 'fixtures', 'capabilities', 'policies', 'adapters', 'ui']);
14
+
15
+ // Files the runner and packer put next to a factory. They are never source.
16
+ const GENERATED = new Set(['RUNNING.md', 'run.mjs', 'factory-engine.mjs', 'author-tests.mjs', 'INPUTS.schema.json', 'FACTORY-HUB.json']);
17
+
18
+ /** The house factories that ship in this repository. */
19
+ export const HOUSE_FACTORIES = resolve(dirname(fileURLToPath(import.meta.url)), '../../../factories');
20
+
21
+ /**
22
+ * Reads the files of a factory folder, as the engine admits them.
23
+ * `skipped` lists files that look meant for the factory but are not part of it.
24
+ */
25
+ export async function readFolder(root) {
26
+ const files = {};
27
+ const skipped = [];
28
+ const walk = async (dir) => {
29
+ const entries = await readdir(dir, { withFileTypes: true });
30
+ entries.sort((a, b) => a.name.localeCompare(b.name));
31
+ for (const entry of entries) {
32
+ const { name } = entry;
33
+ if (name.startsWith('.')) continue;
34
+ const path = join(dir, name);
35
+ const rel = relative(root, path).split(sep).join('/');
36
+ if (entry.isSymbolicLink()) {
37
+ if (dir !== root || SOURCE_FOLDERS.has(name) || ALLOWED_PATH.test(rel)) throw new Error(`Factory source cannot contain a symbolic link: ${rel}`);
38
+ continue;
39
+ }
40
+ if (entry.isDirectory()) {
41
+ if (SOURCE_FOLDERS.has(name) || dir !== root) await walk(path);
42
+ continue;
43
+ }
44
+ if (dir === root && GENERATED.has(name)) continue;
45
+ if (ALLOWED_PATH.test(rel)) files[rel] = await readFile(path, 'utf8');
46
+ else if (dir !== root || /\.(md|ya?ml)$/i.test(name)) skipped.push(rel);
47
+ }
48
+ };
49
+ await walk(root);
50
+ return { files: normalizeFiles(files), skipped };
51
+ }
52
+
53
+ const isUrl = (value) => /^https?:\/\//i.test(String(value));
54
+
55
+ /**
56
+ * Resolves what a person typed to a factory: a folder, a bundle `.json` file,
57
+ * an http(s) address of a bundle, or the id of a house factory.
58
+ * Returns { files, leftOut, source: { kind, location, id } }.
59
+ */
60
+ export async function readFactory(target, { cwd = process.cwd(), fetchImpl = fetch, headers = {}, houseDir = HOUSE_FACTORIES } = {}) {
61
+ if (!target) throw usage('Name a factory: a folder, a bundle .json file, an address, or a house factory id.');
62
+ if (isUrl(target)) {
63
+ const response = await fetchImpl(target, { headers: { accept: 'application/json', ...headers } });
64
+ if (!response.ok) throw new Error(`${target} answered with status ${response.status}.`);
65
+ const read = await fromEnvelope(await response.json());
66
+ if (read.problems.length) throw new Error(`That bundle could not be read:\n${read.problems.map((problem) => ` - ${problem}`).join('\n')}`);
67
+ return { files: read.files, leftOut: read.leftOut, source: { kind: 'url', location: target } };
68
+ }
69
+ const path = resolve(cwd, target);
70
+ if (existsSync(path) && (await lstat(path)).isFile()) {
71
+ if (!path.endsWith('.json')) throw usage(`${target} is a file. Point at a factory folder or a bundle .json file.`);
72
+ const read = await fromEnvelope(JSON.parse(await readFile(path, 'utf8')));
73
+ if (read.problems.length) throw new Error(`That bundle could not be read:\n${read.problems.map((problem) => ` - ${problem}`).join('\n')}`);
74
+ return { files: read.files, leftOut: read.leftOut, source: { kind: 'bundle', location: path } };
75
+ }
76
+ if (existsSync(path)) {
77
+ if (!existsSync(join(path, 'factory.yaml'))) throw usage(`No factory.yaml in ${target}.`);
78
+ const { files, skipped } = await readFolder(path);
79
+ return { files, leftOut: skipped, source: { kind: 'folder', location: path } };
80
+ }
81
+ if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(target) && existsSync(join(houseDir, target, 'factory.yaml'))) {
82
+ const location = join(houseDir, target);
83
+ const { files, skipped } = await readFolder(location);
84
+ return { files, leftOut: skipped, source: { kind: 'house', location, id: target } };
85
+ }
86
+ throw usage(`No factory at ${target}. Use a folder with a factory.yaml, a bundle .json file, an address, or one of: ${(await listFactoryDirs(houseDir)).join(', ')}.`);
87
+ }
88
+
89
+ /** The ids of the factory folders directly inside `dir`, sorted. */
90
+ export async function listFactoryDirs(dir = HOUSE_FACTORIES) {
91
+ if (!existsSync(dir)) return [];
92
+ const entries = await readdir(dir, { withFileTypes: true });
93
+ return entries.filter((entry) => entry.isDirectory() && existsSync(join(dir, entry.name, 'factory.yaml'))).map((entry) => entry.name).sort();
94
+ }
95
+
96
+ /**
97
+ * Writes bundle files under `dir`. Every path must be one the engine admits,
98
+ * so a bundle from elsewhere cannot write outside its folder.
99
+ */
100
+ export async function writeFiles(dir, files, { allow = (path) => ALLOWED_PATH.test(path) } = {}) {
101
+ const root = resolve(dir);
102
+ for (const [path, content] of Object.entries(files)) {
103
+ if (!allow(path)) throw new Error(`Refusing to write "${path}": it is not a factory file.`);
104
+ const target = resolve(root, path);
105
+ if (!target.startsWith(root + sep)) throw new Error(`Refusing to write outside ${root}: ${path}`);
106
+ await mkdir(dirname(target), { recursive: true });
107
+ await writeFile(target, content);
108
+ }
109
+ }
110
+
111
+ /** An error that means the person asked for something unclear: exit code 2. */
112
+ export function usage(message) {
113
+ return Object.assign(new Error(message), { usage: true });
114
+ }
@@ -0,0 +1,52 @@
1
+ // Chooses the model a run uses, from the person's own key or a local server.
2
+ // Nothing here has a key of its own: every call is billed to whoever runs it.
3
+ import { anthropicProvider, openaiProvider } from '../src/factory-engine.mjs';
4
+
5
+ export const PROVIDERS = ['anthropic', 'openai', 'openrouter', 'ollama', 'openai-compatible'];
6
+ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
7
+ export const OLLAMA_BASE_URL = 'http://localhost:11434/v1';
8
+ /** Where each hosted provider's key may go. Any other address is a custom server. */
9
+ export const OFFICIAL = { anthropic: 'https://api.anthropic.com', openai: 'https://api.openai.com/v1', openrouter: OPENROUTER_BASE_URL };
10
+ const KEY_OF = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', openrouter: 'OPENROUTER_API_KEY' };
11
+ const DEFAULT_MODELS = { anthropic: 'claude-sonnet-5-5', openai: 'gpt-5', openrouter: 'openrouter/auto' };
12
+
13
+ const problem = (message) => Object.assign(new Error(message), { usage: true });
14
+ const trim = (url) => String(url).replace(/\/+$/, '');
15
+
16
+ /**
17
+ * Picks a provider.
18
+ *
19
+ * - `provider` names one of PROVIDERS. Without it: a `baseUrl` means an
20
+ * OpenAI-compatible server, otherwise the first key found among
21
+ * ANTHROPIC_API_KEY, OPENAI_API_KEY and OPENROUTER_API_KEY decides.
22
+ * - A key from the environment goes only to its provider's own address. Any
23
+ * other `baseUrl` receives a key only from the variable named in
24
+ * `apiKeyEnv`, whichever provider is named, so a changed address can never
25
+ * carry a provider key away.
26
+ * - `model` falls back to FACTORYHUB_MODEL, then to a default for hosted
27
+ * providers. Local servers need a model name.
28
+ */
29
+ export function chooseProvider({ provider, model, baseUrl, apiKeyEnv, env = process.env, fetchImpl = fetch } = {}) {
30
+ let kind = provider ? String(provider).toLowerCase() : null;
31
+ if (kind && !PROVIDERS.includes(kind)) throw problem(`Unknown provider "${provider}". Use one of: ${PROVIDERS.join(', ')}.`);
32
+ if (!kind) {
33
+ if (baseUrl) kind = 'openai-compatible';
34
+ else if (env.ANTHROPIC_API_KEY) kind = 'anthropic';
35
+ else if (env.OPENAI_API_KEY) kind = 'openai';
36
+ else if (env.OPENROUTER_API_KEY) kind = 'openrouter';
37
+ }
38
+ if (!kind) throw problem('No model configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY or OPENROUTER_API_KEY, or use a server on your machine: provider "ollama" with a model name, or an OpenAI-compatible base URL with a model name.');
39
+ const chosen = model || env.FACTORYHUB_MODEL || DEFAULT_MODELS[kind];
40
+ if (!chosen) throw problem(`Name the model to use with ${kind}.`);
41
+ const keyFrom = (name) => {
42
+ const value = env[name];
43
+ if (!value) throw problem(`${name} is not set.`);
44
+ return value;
45
+ };
46
+ const address = baseUrl ? trim(baseUrl) : OFFICIAL[kind] || (kind === 'ollama' ? OLLAMA_BASE_URL : null);
47
+ if (!address) throw problem('An OpenAI-compatible server needs a base URL.');
48
+ // The provider's own key for its own address; otherwise only a key the person named.
49
+ const apiKey = apiKeyEnv ? keyFrom(apiKeyEnv) : KEY_OF[kind] && address === OFFICIAL[kind] ? keyFrom(KEY_OF[kind]) : '';
50
+ if (kind === 'anthropic') return anthropicProvider({ apiKey, model: chosen, fetchImpl, baseUrl: address });
51
+ return openaiProvider({ apiKey, model: chosen, fetchImpl, baseUrl: address });
52
+ }
@@ -0,0 +1,131 @@
1
+ // Runs a factory on this machine: the host the engine needs (model, web
2
+ // reading, events) and the start, decide and resume steps that the CLI and
3
+ // the MCP server share. The engine decides nothing for the person: a run
4
+ // that reaches an approval step waits until someone answers.
5
+ import { lookup } from 'node:dns/promises';
6
+ import { isIP } from 'node:net';
7
+ import { parseBundle, checkInputs, newRun, runToEnd, resume, decisionProblem, ensureApprovalIdentity, isPublicAddress, htmlToText, DEFAULT_LIMITS, sha256 } from '../src/factory-engine.mjs';
8
+ import { canonicalFiles } from './catalog.mjs';
9
+
10
+ export const USER_AGENT = 'Mozilla/5.0 (compatible; FactoryRunner/1.0)';
11
+
12
+ const v4 = (ip) => ip.split('.').map(Number);
13
+ /** True for loopback, private, link-local, shared, reserved and multicast addresses. */
14
+ export function isPrivateIp(ip) {
15
+ if (isIP(ip) === 4) {
16
+ const [a, b] = v4(ip);
17
+ return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
18
+ || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0) || (a === 198 && (b === 18 || b === 19));
19
+ }
20
+ const low = ip.toLowerCase();
21
+ const mapped = low.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
22
+ if (mapped) return isPrivateIp(mapped[1]);
23
+ return low === '::' || low === '::1' || /^f[cd]/.test(low) || /^fe[89ab]/.test(low) || /^ff/.test(low) || low.startsWith('64:ff9b:');
24
+ }
25
+
26
+ /**
27
+ * The host object the engine runs against. `fetchImpl` reads web pages.
28
+ * Only public http(s) addresses are read: the engine's check on the address
29
+ * text, and here also every address the host name resolves to, so a public
30
+ * looking name that points at this machine or its network is refused. Every
31
+ * redirect is checked again. The check happens before the request; a name
32
+ * whose answer changes in between is not caught.
33
+ */
34
+ export function nodeHost({ provider, emit = () => {}, fetchImpl = fetch, resolve = (host) => lookup(host, { all: true, verbatim: true }), limits = DEFAULT_LIMITS, now = () => Date.now() }) {
35
+ return {
36
+ provider,
37
+ limits,
38
+ now,
39
+ async fetchPage(address) {
40
+ let url = address;
41
+ for (let hop = 0; hop < 6; hop += 1) {
42
+ if (!isPublicAddress(url)) throw new Error('that is not a public web address');
43
+ let answers;
44
+ try { answers = await resolve(new URL(url).hostname); } catch { throw new Error('the address could not be found'); }
45
+ if (!answers.length || answers.some((answer) => isPrivateIp(answer.address))) throw new Error('that address points at a private network');
46
+ const response = await fetchImpl(url, { redirect: 'manual', headers: { 'user-agent': USER_AGENT, accept: 'text/html,*/*;q=0.5' }, signal: AbortSignal.timeout(20000) });
47
+ if (response.status >= 300 && response.status < 400) { url = new URL(response.headers.get('location') || '', url).href; continue; }
48
+ if (!response.ok) throw new Error(`the site answered with status ${response.status}`);
49
+ return `Address: ${url}\n\n${htmlToText(await response.text(), url)}`;
50
+ }
51
+ throw new Error('the page redirects too many times');
52
+ },
53
+ emit,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Prepares a run: parses the factory, refuses one that cannot run here, and
59
+ * checks the inputs before any model is called.
60
+ */
61
+ export function prepareRun({ files, inputs = {}, id, runner = { mode: 'local' } }) {
62
+ const parsed = parseBundle(canonicalFiles(files));
63
+ if (!parsed.ok) return { ok: false, problems: parsed.errors };
64
+ if (!parsed.factory.runnable) {
65
+ return { ok: false, problems: parsed.factory.blockers.length ? parsed.factory.blockers : ['This factory has no flow.yaml, so there are no steps to run. Follow agents/foreman/agent.md in an agent instead.'] };
66
+ }
67
+ const checked = checkInputs(parsed.factory, inputs);
68
+ if (!checked.ok) return { ok: false, problems: checked.problems, factory: parsed.factory };
69
+ const state = newRun({ id: id || `local-${Date.now().toString(36)}`, factory: parsed.factory, inputs: checked.inputs, runner });
70
+ return { ok: true, state, factory: parsed.factory };
71
+ }
72
+
73
+ /**
74
+ * Runs until the factory is done, fails, is stopped by the person, or waits
75
+ * at an approval step. `onApproval` may answer decisions; without it the run
76
+ * is returned waiting.
77
+ */
78
+ export async function continueRun(state, host, onApproval) {
79
+ await runToEnd(state, host, onApproval);
80
+ await ensureApprovalIdentity(state);
81
+ return state;
82
+ }
83
+
84
+ /** What a person must see to decide, and the values a decision must name. */
85
+ export async function pendingDecision(state) {
86
+ if (state.status !== 'waiting' || !state.approval) return null;
87
+ await ensureApprovalIdentity(state);
88
+ const text = state.artifacts[state.approval.artifact] || '';
89
+ return {
90
+ step: state.approval.step,
91
+ title: state.approval.title,
92
+ ask: state.approval.ask,
93
+ shows: state.approval.artifact,
94
+ text,
95
+ seen_sha256: await sha256(text),
96
+ approval_id: state.approval.id,
97
+ revisions_left: state.approval.revisionsLeft,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Records a person's decision on a waiting run. Returns '' when accepted, or
103
+ * the engine's reason when not: not_waiting, bad_action, unseen, stale or
104
+ * no_revisions.
105
+ */
106
+ export function decide(state, { action, note = '', seen, approvalId }) {
107
+ const decision = { action, note, seen, approvalId };
108
+ const why = decisionProblem(state, decision);
109
+ if (why) return why;
110
+ resume(state, decision);
111
+ return '';
112
+ }
113
+
114
+ /**
115
+ * A run restored from JSON is read again from its own files, so its steps
116
+ * always match the source it carries.
117
+ */
118
+ export function restoreRun(saved) {
119
+ if (!saved || typeof saved !== 'object' || !saved.factory?.files || typeof saved.factory.files !== 'object') throw new Error('This is not a saved factory run.');
120
+ const parsed = parseBundle(saved.factory.files);
121
+ if (!parsed.ok || !parsed.factory.runnable) throw new Error('The saved run carries a factory that cannot run here.');
122
+ return { ...saved, factory: parsed.factory };
123
+ }
124
+
125
+ /** A short account of how a run ended, for people and for tool results. */
126
+ export function outcome(state) {
127
+ if (state.status === 'done') return { status: 'done', deliverable: state.deliverable, review: state.receipt?.deliverable_review?.status, flags: state.receipt?.flags || [] };
128
+ if (state.status === 'waiting') return { status: 'waiting', step: state.approval?.step };
129
+ if (state.status === 'stopped') return { status: 'stopped', step: state.stopped?.step, note: state.stopped?.note || '' };
130
+ return { status: state.status, error: state.error, failure: state.failure };
131
+ }