factoryhub 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/README.md +195 -2
  4. package/factories/README.md +17 -0
  5. package/factories/client-file-readiness/README.md +3 -0
  6. package/factories/client-file-readiness/agents/analyst/agent.md +10 -0
  7. package/factories/client-file-readiness/agents/reviewer/agent.md +10 -0
  8. package/factories/client-file-readiness/agents/writer/agent.md +11 -0
  9. package/factories/client-file-readiness/factory.yaml +12 -0
  10. package/factories/client-file-readiness/flow.yaml +56 -0
  11. package/factories/competitor-teardown/README.md +3 -0
  12. package/factories/competitor-teardown/agents/analyst/agent.md +10 -0
  13. package/factories/competitor-teardown/agents/researcher/agent.md +11 -0
  14. package/factories/competitor-teardown/agents/reviewer/agent.md +10 -0
  15. package/factories/competitor-teardown/agents/writer/agent.md +11 -0
  16. package/factories/competitor-teardown/factory.yaml +11 -0
  17. package/factories/competitor-teardown/flow.yaml +75 -0
  18. package/factories/homepage-audit/README.md +3 -0
  19. package/factories/homepage-audit/agents/analyst/agent.md +10 -0
  20. package/factories/homepage-audit/agents/researcher/agent.md +11 -0
  21. package/factories/homepage-audit/agents/reviewer/agent.md +10 -0
  22. package/factories/homepage-audit/agents/writer/agent.md +11 -0
  23. package/factories/homepage-audit/factory.yaml +11 -0
  24. package/factories/homepage-audit/flow.yaml +77 -0
  25. package/factories/inbox-triage/README.md +3 -0
  26. package/factories/inbox-triage/agents/analyst/agent.md +10 -0
  27. package/factories/inbox-triage/agents/reviewer/agent.md +10 -0
  28. package/factories/inbox-triage/agents/writer/agent.md +11 -0
  29. package/factories/inbox-triage/factory.yaml +11 -0
  30. package/factories/inbox-triage/flow.yaml +57 -0
  31. package/factories/index.json +587 -0
  32. package/factories/launch-kit/README.md +3 -0
  33. package/factories/launch-kit/agents/reviewer/agent.md +10 -0
  34. package/factories/launch-kit/agents/strategist/agent.md +10 -0
  35. package/factories/launch-kit/agents/writer/agent.md +11 -0
  36. package/factories/launch-kit/factory.yaml +12 -0
  37. package/factories/launch-kit/flow.yaml +75 -0
  38. package/factories/meeting-to-actions/CHANGELOG.md +7 -0
  39. package/factories/meeting-to-actions/README.md +15 -0
  40. package/factories/meeting-to-actions/agents/analyst/agent.md +10 -0
  41. package/factories/meeting-to-actions/agents/reviewer/agent.md +10 -0
  42. package/factories/meeting-to-actions/agents/writer/agent.md +11 -0
  43. package/factories/meeting-to-actions/factory.yaml +11 -0
  44. package/factories/meeting-to-actions/flow.yaml +53 -0
  45. package/factories/meeting-to-actions/mindburn.requirements.yaml +8 -0
  46. package/factories/meeting-to-actions/mindburn.tests.yaml +16 -0
  47. package/factories/newsletter-note/README.md +11 -0
  48. package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
  49. package/factories/newsletter-note/agents/writer/agent.md +11 -0
  50. package/factories/newsletter-note/factory.yaml +12 -0
  51. package/factories/newsletter-note/flow.yaml +45 -0
  52. package/factories/newsletter-note/mindburn.tests.yaml +80 -0
  53. package/factories/outreach-pack/README.md +3 -0
  54. package/factories/outreach-pack/agents/reviewer/agent.md +5 -0
  55. package/factories/outreach-pack/agents/writer/agent.md +5 -0
  56. package/factories/outreach-pack/factory.yaml +9 -0
  57. package/factories/outreach-pack/flow.yaml +42 -0
  58. package/factories/prospect-brief/README.md +3 -0
  59. package/factories/prospect-brief/agents/researcher/agent.md +11 -0
  60. package/factories/prospect-brief/agents/reviewer/agent.md +10 -0
  61. package/factories/prospect-brief/agents/strategist/agent.md +10 -0
  62. package/factories/prospect-brief/agents/writer/agent.md +11 -0
  63. package/factories/prospect-brief/factory.yaml +11 -0
  64. package/factories/prospect-brief/flow.yaml +79 -0
  65. package/factories/release-desk/README.md +13 -0
  66. package/factories/release-desk/agents/editor/agent.md +10 -0
  67. package/factories/release-desk/agents/reviewer/agent.md +10 -0
  68. package/factories/release-desk/factory.yaml +13 -0
  69. package/factories/release-desk/flow.yaml +67 -0
  70. package/factories/release-desk/mindburn.tests.yaml +163 -0
  71. package/factories/short-video-cut/README.md +11 -0
  72. package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
  73. package/factories/short-video-cut/agents/writer/agent.md +11 -0
  74. package/factories/short-video-cut/factory.yaml +13 -0
  75. package/factories/short-video-cut/flow.yaml +46 -0
  76. package/factories/short-video-cut/mindburn.tests.yaml +80 -0
  77. package/factories/video-facts/README.md +11 -0
  78. package/factories/video-facts/agents/analyst/agent.md +10 -0
  79. package/factories/video-facts/agents/reviewer/agent.md +10 -0
  80. package/factories/video-facts/factory.yaml +13 -0
  81. package/factories/video-facts/flow.yaml +39 -0
  82. package/factories/video-facts/mindburn.tests.yaml +34 -0
  83. package/factories/video-to-posts/README.md +35 -0
  84. package/factories/video-to-posts/agents/analyst/agent.md +10 -0
  85. package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
  86. package/factories/video-to-posts/agents/writer/agent.md +11 -0
  87. package/factories/video-to-posts/factory.yaml +12 -0
  88. package/factories/video-to-posts/flow.yaml +62 -0
  89. package/factories/video-to-posts/mindburn.tests.yaml +34 -0
  90. package/factories/week-of-posts/README.md +11 -0
  91. package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
  92. package/factories/week-of-posts/agents/writer/agent.md +11 -0
  93. package/factories/week-of-posts/factory.yaml +12 -0
  94. package/factories/week-of-posts/flow.yaml +46 -0
  95. package/factories/week-of-posts/mindburn.tests.yaml +80 -0
  96. package/package.json +92 -4
  97. package/packages/cli/bin/factoryhub.mjs +8 -0
  98. package/packages/cli/package.json +29 -0
  99. package/packages/cli/src/args.mjs +47 -0
  100. package/packages/cli/src/commands/export.mjs +54 -0
  101. package/packages/cli/src/commands/get.mjs +37 -0
  102. package/packages/cli/src/commands/inspect.mjs +65 -0
  103. package/packages/cli/src/commands/list.mjs +24 -0
  104. package/packages/cli/src/commands/login.mjs +161 -0
  105. package/packages/cli/src/commands/logout.mjs +43 -0
  106. package/packages/cli/src/commands/mcp.mjs +25 -0
  107. package/packages/cli/src/commands/new.mjs +147 -0
  108. package/packages/cli/src/commands/pack.mjs +96 -0
  109. package/packages/cli/src/commands/publish.mjs +62 -0
  110. package/packages/cli/src/commands/pull.mjs +59 -0
  111. package/packages/cli/src/commands/push.mjs +115 -0
  112. package/packages/cli/src/commands/resume.mjs +66 -0
  113. package/packages/cli/src/commands/run.mjs +352 -0
  114. package/packages/cli/src/commands/runs.mjs +52 -0
  115. package/packages/cli/src/commands/search.mjs +30 -0
  116. package/packages/cli/src/commands/validate.mjs +79 -0
  117. package/packages/cli/src/commands/whoami.mjs +34 -0
  118. package/packages/cli/src/hosted.mjs +159 -0
  119. package/packages/cli/src/main.mjs +134 -0
  120. package/packages/cli/src/tool-sources.mjs +272 -0
  121. package/packages/cli/src/util.mjs +58 -0
  122. package/packages/engine/lib/catalog.mjs +50 -0
  123. package/packages/engine/lib/credentials.mjs +121 -0
  124. package/packages/engine/lib/hosted.mjs +119 -0
  125. package/packages/engine/lib/hub.mjs +423 -0
  126. package/packages/engine/lib/nango.mjs +157 -0
  127. package/packages/engine/lib/node.mjs +242 -0
  128. package/packages/engine/lib/providers.mjs +57 -0
  129. package/packages/engine/lib/run.mjs +192 -0
  130. package/packages/engine/lib/skill.mjs +229 -0
  131. package/packages/engine/lib/text.mjs +5 -0
  132. package/packages/engine/package.json +45 -0
  133. package/packages/engine/src/author-tests.mjs +78 -0
  134. package/packages/engine/src/compose.mjs +150 -0
  135. package/packages/engine/src/cost.mjs +41 -0
  136. package/packages/engine/src/diagnostics.mjs +21 -0
  137. package/packages/engine/src/factory-engine.mjs +2109 -0
  138. package/packages/engine/src/flow-sources.mjs +135 -0
  139. package/packages/engine/src/index.mjs +9 -0
  140. package/packages/engine/src/markdown.mjs +97 -0
  141. package/packages/engine/src/mcp-client.mjs +429 -0
  142. package/packages/engine/src/package.mjs +116 -0
  143. package/packages/engine/src/run.mjs +308 -0
  144. package/packages/engine/src/tool-sources.mjs +566 -0
  145. package/packages/mcp/bin/factoryhub-mcp.mjs +6 -0
  146. package/packages/mcp/package.json +36 -0
  147. package/packages/mcp/src/server.mjs +310 -0
  148. package/packages/sdk/package.json +28 -0
  149. package/packages/sdk/src/index.d.ts +163 -0
  150. package/packages/sdk/src/index.mjs +266 -0
  151. package/spec/README.md +42 -0
  152. package/spec/factory-format.md +866 -0
  153. package/spec/schema/factory.schema.json +43 -0
  154. package/spec/schema/factory.yaml.json +815 -0
  155. package/spec/schema/flow.schema.json +191 -0
  156. package/spec/skill-export.md +129 -0
@@ -0,0 +1,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
+ };
@@ -0,0 +1,30 @@
1
+ import { out, json, table } from '../util.mjs';
2
+ import { hubClient, DEFAULT_HUB } from '../../../engine/lib/hub.mjs';
3
+
4
+ export const HUB_NOTE = `The factoryhub site is in private preview. Until it opens, it answers
5
+ only requests that carry the preview access key: set FACTORYHUB_ACCESS_KEY
6
+ and it is sent as the x-hub-access-key header.`;
7
+
8
+ export default {
9
+ name: 'search',
10
+ usage: `Usage: factoryhub search [words ...] [--base-url url] [--json]
11
+
12
+ Lists the published factories whose id, title, description or tags contain
13
+ every word. With no words, lists them all.
14
+
15
+ ${HUB_NOTE}
16
+
17
+ Options:
18
+ --base-url the factoryhub address (default: FACTORYHUB_BASE_URL or ${DEFAULT_HUB})
19
+ --json print the catalogue entries as JSON`,
20
+ options: { boolean: ['json'], string: ['base-url'] },
21
+ async run({ positional, flags }, io) {
22
+ const hub = hubClient({ baseUrl: flags['base-url'], env: io.env, fetchImpl: io.fetch });
23
+ const found = await hub.search(positional.join(' '));
24
+ if (flags.json) { json(io, found); return 0; }
25
+ if (!found.length) { out(io, 'No published factory matches.'); return 0; }
26
+ out(io, table([['ID', 'VERSION', 'TITLE'], ...found.map((entry) => [entry.id, entry.version || '', entry.title || entry.name])]));
27
+ out(io, `\nGet one with: factoryhub get <id>`);
28
+ return 0;
29
+ },
30
+ };
@@ -0,0 +1,79 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { engine, readFactory, out, json, plural, styles, usage, canonicalFiles } from '../util.mjs';
4
+
5
+ export default {
6
+ name: 'validate',
7
+ usage: `Usage: factoryhub validate [factory ...] [--json]
8
+
9
+ Reads each factory with the engine's parser and lists every problem, every
10
+ tool this runner does not have and every file it did not read. A factory is a
11
+ folder, a bundle .json file, an https address of a bundle, or a house factory
12
+ id. With no factory, validates the folder you are in.
13
+
14
+ Exit codes: 0 every factory is valid and runs here, 1 one or more does not.
15
+
16
+ Options:
17
+ --json print the results as JSON`,
18
+ options: { boolean: ['json'] },
19
+ async run({ positional, flags }, io) {
20
+ const targets = positional.length ? positional : existsSync(join(io.cwd, 'factory.yaml')) ? ['.'] : [];
21
+ if (!targets.length) throw usage('Name a factory to validate, or run this inside a factory folder.');
22
+ const results = await Promise.all(targets.map((target) => check(target, io)));
23
+ if (flags.json) json(io, results);
24
+ else results.forEach((result, index) => { if (index) out(io); print(result, io); });
25
+ return results.every((result) => result.runnable) ? 0 : 1;
26
+ },
27
+ };
28
+
29
+ export async function check(target, io) {
30
+ let read;
31
+ try {
32
+ read = await readFactory(target, { cwd: io.cwd, fetchImpl: io.fetch });
33
+ } catch (error) {
34
+ return { target, valid: false, runnable: false, errors: [error.message], warnings: [], blockers: [], leftOut: [] };
35
+ }
36
+ const { ok, errors, warnings, factory } = engine.parseBundle(read.files);
37
+ // Blockers and the missing flow are reported on their own lines.
38
+ const notes = warnings.filter((warning) => !factory.blockers.includes(warning) && !warning.startsWith('This bundle has no flow.yaml'));
39
+ return {
40
+ target,
41
+ name: factory.name,
42
+ title: factory.title,
43
+ version: factory.version,
44
+ valid: ok,
45
+ hasFlow: factory.flowValid,
46
+ runnable: ok && factory.runnable,
47
+ sha256: ok ? await engine.bundleHash(canonicalFiles(read.files)) : null,
48
+ steps: factory.steps.length,
49
+ agents: Object.values(factory.agents).filter((agent) => agent.type !== 'FOREMAN').length,
50
+ inputs: factory.inputs.length,
51
+ tools: [...new Set(factory.steps.flatMap((step) => step.tools))].sort(),
52
+ errors,
53
+ blockers: factory.blockers,
54
+ warnings: notes,
55
+ leftOut: read.leftOut,
56
+ hasFlowFile: read.files['flow.yaml'] !== undefined,
57
+ };
58
+ }
59
+
60
+ function print(result, io) {
61
+ const s = styles(io);
62
+ const name = result.title ? `${result.title} v${result.version}` : result.target;
63
+ if (!result.valid) {
64
+ out(io, `${s.red('invalid')} ${name} (${result.target}): ${plural(result.errors.length, 'problem')}`);
65
+ for (const error of result.errors) out(io, ` - ${error}`);
66
+ } else if (!result.hasFlow) {
67
+ out(io, `${s.yellow('no flow')} ${name}: readable, but ${result.hasFlowFile ? 'its flow.yaml has no steps' : 'it has no flow.yaml'}, so there is nothing to run. An agent can follow agents/foreman/agent.md.`);
68
+ } else if (result.blockers.length) {
69
+ out(io, `${s.yellow('blocked')} ${name}: valid, but it cannot run here:`);
70
+ for (const blocker of result.blockers) out(io, ` - ${blocker}`);
71
+ } else {
72
+ out(io, `${s.green('valid')} ${name}: runs here. ${plural(result.steps, 'step')}, ${plural(result.agents, 'agent')}, ${plural(result.inputs, 'input')}; tools: ${result.tools.join(', ') || 'none'}.`);
73
+ }
74
+ if (result.warnings.length) {
75
+ out(io, ' Worth a look:');
76
+ for (const warning of result.warnings) out(io, ` - ${warning}`);
77
+ }
78
+ if (result.leftOut.length) out(io, ` Not part of the factory, so not read: ${result.leftOut.join(', ')}`);
79
+ }
@@ -0,0 +1,34 @@
1
+ import { out, json, styles, usage, connect, day, BASE_URL_HELP } from '../util.mjs';
2
+ import { credentialsPath } from '../../../engine/lib/credentials.mjs';
3
+
4
+ export default {
5
+ name: 'whoami',
6
+ usage: `Usage: factoryhub whoami [--base-url url] [--json]
7
+
8
+ Asks factoryhub who the token acts for: the account, the token's name,
9
+ scopes and expiry, and where the token comes from (FACTORYHUB_TOKEN or the
10
+ login saved on this computer). The token itself is never printed.
11
+
12
+ Exit codes: 0 logged in, 1 not logged in or the token is not accepted.
13
+
14
+ Options:
15
+ --base-url ${BASE_URL_HELP}
16
+ --json print the answer as JSON`,
17
+ options: { boolean: ['json'], string: ['base-url'] },
18
+ async run({ positional, flags }, io) {
19
+ if (positional.length) throw usage('whoami takes no words, only options.');
20
+ const { hub, credential, base } = await connect(flags, io, { needToken: true });
21
+ const me = await hub.me();
22
+ const source = credential.source === 'env' ? 'FACTORYHUB_TOKEN' : credentialsPath(io.env);
23
+ if (flags.json) {
24
+ json(io, { base, source: credential.source, account: me.account, token: me.token });
25
+ return 0;
26
+ }
27
+ const s = styles(io);
28
+ out(io, `${s.bold(`@${me.account.handle}`)}${me.account.name ? ` (${me.account.name})` : ''} on ${base}`);
29
+ out(io, ` Token ${me.token.name} (${me.token.prefix ? `${me.token.prefix}…` : 'fhpat_…'}), from ${source}`);
30
+ out(io, ` Scopes ${me.token.scopes.join(', ')}`);
31
+ out(io, ` Expires ${day(me.token.expires)}`);
32
+ return 0;
33
+ },
34
+ };
@@ -0,0 +1,159 @@
1
+ // `factoryhub run <id> --hosted` and `factoryhub runs <id>`: start and follow
2
+ // runs on factoryhub, decide where the person decides, and save the result
3
+ // with its signed receipt.
4
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
5
+ import { resolve, relative, dirname } from 'node:path';
6
+ import { out, err, json, styles, usage, connect } from './util.mjs';
7
+ import { followRun, runResult, newIdempotencyKey, receiptVerdict } from '../../engine/lib/hosted.mjs';
8
+ import { FACTORY_ID } from '../../engine/lib/hub.mjs';
9
+
10
+ const pauseFor = (io) => io.sleep || ((ms) => new Promise((done) => setTimeout(done, ms)));
11
+ /** Options of `run` that only a run on this machine uses; a hosted run refuses them. */
12
+ export const LOCAL_ONLY = ['provider', 'model', 'base-url', 'api-key-env', 'effort', 'max-output', 'timeout', 'check-model', 'check-effort', 'save',
13
+ 'experimental-tools', 'mcp', 'mcp-token-env', 'allow-tool', 'read-only', 'nango', 'nango-user', 'grant', 'bind', 'target'];
14
+ // A repeatable option is an empty list when it is not given.
15
+ const given = (value) => (Array.isArray(value) ? value.length > 0 : value !== undefined);
16
+
17
+ /** name=value pairs, with name=@file read from a file. */
18
+ export async function readInputs(pairs, io) {
19
+ const inputs = {};
20
+ for (const pair of pairs) {
21
+ const at = pair.indexOf('=');
22
+ if (at < 1) throw usage(`--input needs name=value, not "${pair}".`);
23
+ const value = pair.slice(at + 1);
24
+ inputs[pair.slice(0, at)] = value.startsWith('@') ? await readFile(resolve(io.cwd, value.slice(1)), 'utf8') : value;
25
+ }
26
+ return inputs;
27
+ }
28
+
29
+ /**
30
+ * Saves a finished run's result and receipt; returns their paths. Without
31
+ * `outFile` the name comes from the factory id, which the hub sends: one that
32
+ * is not a factory id is replaced by "run", so it never names a path.
33
+ */
34
+ export async function saveResult(io, { name, result, outFile, finished }) {
35
+ const stamp = new Date(finished || Date.now()).toISOString().slice(0, 16).replace(/[-:T]/g, '');
36
+ const file = resolve(io.cwd, outFile || `${FACTORY_ID.test(String(name || '')) ? name : 'run'}-${stamp}.md`);
37
+ const receiptFile = `${file.replace(/\.md$/, '')}.receipt.json`;
38
+ await mkdir(dirname(file), { recursive: true });
39
+ await writeFile(file, result.text);
40
+ if (result.receipt) await writeFile(receiptFile, `${JSON.stringify(result.receipt, null, 2)}\n`);
41
+ return { file: relative(io.cwd, file) || file, receipt: result.receipt ? relative(io.cwd, receiptFile) || receiptFile : null };
42
+ }
43
+
44
+
45
+ /**
46
+ * Reports a run that finished, waits or failed. Returns the exit code:
47
+ * 0 done, 1 failed or stopped, 3 waiting for a decision.
48
+ */
49
+ export async function finish(hub, run, { io, flags, name }) {
50
+ const s = styles(io);
51
+ const say = (text = '') => err(io, text);
52
+ if (run.waiting) {
53
+ const waiting = run.waiting;
54
+ if (flags.json) json(io, { status: 'waiting', run: run.id, url: run.url, step: waiting.step, title: waiting.title, ask: waiting.ask, approval_id: waiting.approval_id, artifact_sha256: waiting.artifact_sha256, complete: waiting.complete });
55
+ say(`\nWaiting at "${waiting.title}" for your decision. Nothing was decided.\nDecide on the run page: ${run.url}\nThen read the result with: factoryhub runs ${run.id}`);
56
+ return 3;
57
+ }
58
+ if (run.status === 'done') {
59
+ const result = await runResult(hub, run.id);
60
+ if (!result) {
61
+ if (flags.json) json(io, { status: 'done', run: run.id, url: run.url, error: 'no_result' });
62
+ say(`\n${s.red('No result')}: the run is done, but ${hub.base} sent no result to save. ${run.url}`);
63
+ return 1;
64
+ }
65
+ const saved = await saveResult(io, { name, result, outFile: flags.out });
66
+ if (flags.json) json(io, { status: 'done', run: run.id, url: run.url, deliverable: saved.file, receipt: saved.receipt, review: result.review, verification: result.verification });
67
+ else out(io, result.text);
68
+ say(s.dim(`\nSaved ${saved.file}${saved.receipt ? ' and its receipt' : ''}. Final check: ${result.review}. Receipt: ${receiptVerdict(result.verification)}.`));
69
+ return result.verification.verified ? 0 : 1;
70
+ }
71
+ if (flags.json) json(io, { status: run.status, run: run.id, url: run.url, error: run.error, stopped: run.stopped });
72
+ if (run.status === 'stopped' && run.stopped) say(`\nStopped at "${run.stopped.title}" by a decision. No result was made.`);
73
+ else say(`\n${s.red(run.status === 'failed' ? 'Failed' : `Run ${run.status}`)}${run.error ? `: ${run.error}` : '.'} ${run.url}`);
74
+ return 1;
75
+ }
76
+
77
+ /** Asks the person at the terminal; null when nobody answers. */
78
+ async function askPerson(reader, io, waiting) {
79
+ const s = styles(io);
80
+ let answer = '';
81
+ while (!answer) {
82
+ const line = await reader.ask(`\nType ${s.bold('approve')} to continue, ${s.bold('reject')} to stop the run, or what to change:\n> `);
83
+ if (line === null) return null;
84
+ answer = line.trim();
85
+ }
86
+ if (/^(approve|a|yes|y)$/i.test(answer)) return { decision: 'approve' };
87
+ if (/^(reject|stop|no|n)$/i.test(answer)) return { decision: 'reject' };
88
+ if (waiting.revisionsLeft === 0) err(io, s.dim('This step cannot be sent back again: approve it or reject it.'));
89
+ return waiting.revisionsLeft === 0 ? askPerson(reader, io, waiting) : { decision: 'revise', note: answer };
90
+ }
91
+
92
+ /** `factoryhub run <id> --hosted`. */
93
+ export async function hostedRun(target, flags, io, reader) {
94
+ const used = LOCAL_ONLY.filter((name) => given(flags[name]));
95
+ if (used.length) throw usage(`--${used[0]} applies to a run on this machine. A hosted run uses factoryhub's model; leave it out.`);
96
+ const s = styles(io);
97
+ const say = (text = '') => err(io, text);
98
+ const { hub } = await connect({ 'base-url': flags.hub }, io, { needToken: true });
99
+ const me = await hub.me();
100
+ const scopes = me.token.scopes || [];
101
+ if (!scopes.includes('runs:start') || !scopes.includes('runs:read')) {
102
+ throw new Error(`A hosted run needs a token with runs:start and runs:read; this one has ${scopes.join(', ') || 'none'}. Log in again: factoryhub login --scopes ${[...new Set([...scopes, 'runs:start', 'runs:read'])].join(',')}`);
103
+ }
104
+ const canDecide = scopes.includes('runs:decide');
105
+ if (flags.yes && !canDecide) throw new Error('--yes on a hosted run needs a token with the opt-in runs:decide scope. Log in again with it, or leave out --yes and decide on the run page.');
106
+
107
+ const { factory } = await hub.factory(target);
108
+ if (!factory.runnable) throw new Error(`${target} does not run: ${(factory.blockers || []).join(' ') || 'it has no steps.'}`);
109
+ const inputs = await readInputs(flags.input, io);
110
+ say(`\n${s.bold(factory.title)} ${s.dim(`v${factory.version}, hosted on ${hub.base}`)}`);
111
+ if (factory.description) say(factory.description);
112
+ for (const input of factory.inputs || []) {
113
+ if (inputs[input.key] !== undefined || !io.interactive) continue;
114
+ 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> `);
115
+ if (answer === null) break;
116
+ if (answer !== '' || input.required) inputs[input.key] = answer.startsWith('@') ? await readFile(resolve(io.cwd, answer.slice(1).trim()), 'utf8') : answer;
117
+ }
118
+
119
+ const started = await hub.startRun(target, inputs, { idempotencyKey: newIdempotencyKey() });
120
+ say(`Started ${started.url}${Number.isFinite(started.runsLeftToday) ? s.dim(` (${started.runsLeftToday} hosted runs left today)`) : ''}`);
121
+ say(s.dim('Ctrl+C stops following here; the run goes on, and "factoryhub runs" finds it.'));
122
+ const titles = new Map();
123
+ const decided = new Set();
124
+ const pause = pauseFor(io);
125
+ for (;;) {
126
+ const run = await followRun(hub, started.id, {
127
+ sleep: io.sleep,
128
+ onEvent(event, view) {
129
+ for (const step of view.steps || []) titles.set(step.id, step.title);
130
+ const title = titles.get(event.step) || event.step || '';
131
+ if (event.type === 'step_started') say(`\n${s.bold(title)}`);
132
+ else if (event.type === 'step_done') say(s.dim(' done'));
133
+ else if (event.type === 'check') say(` ${event.status === 'passed' ? s.green('passed') : s.yellow(event.status || 'checked')}`);
134
+ else if (event.type === 'run_failed') say(`\n${s.red('Stopped')}: ${event.error || ''}`);
135
+ else if (event.type && title) say(s.dim(` ${title}: ${event.type.replace(/_/g, ' ')}${event.status ? ` (${event.status})` : ''}`));
136
+ },
137
+ });
138
+ const waiting = run.waiting;
139
+ // A decision this command sent that the run has not taken yet: keep following.
140
+ if (waiting && decided.has(`${waiting.approval_id}:${waiting.artifact_sha256}`)) { await pause(2000); continue; }
141
+ if (!waiting) return finish(hub, run, { io, flags, name: started.factoryId || target });
142
+ if (!flags.json) say(`\n${s.bold(waiting.title)} ${s.yellow('needs your decision')}\n\n${waiting.text}${waiting.complete ? '' : `\n[${waiting.totalChars} characters in all; the rest is on the run page.]`}\n\n${s.dim(waiting.ask)}`);
143
+ let choice = null;
144
+ if (flags.yes) { choice = { decision: 'approve' }; say(s.dim('Approved by --yes.')); }
145
+ else if (canDecide && io.interactive && waiting.complete) choice = await askPerson(reader, io, waiting);
146
+ else if (!canDecide && io.interactive) say(s.dim('This token cannot decide steps (it lacks the opt-in runs:decide scope).'));
147
+ if (!choice) return finish(hub, run, { io, flags, name: started.factoryId || target });
148
+ await hub.decide(run.id, { approvalId: waiting.approval_id, seenSha256: waiting.artifact_sha256, decision: choice.decision, note: choice.note || '' });
149
+ decided.add(`${waiting.approval_id}:${waiting.artifact_sha256}`);
150
+ }
151
+ }
152
+
153
+ /** `factoryhub runs` with no id: the account's recent runs. */
154
+ export async function listRuns(flags, io) {
155
+ const { hub } = await connect(flags, io, { needToken: true });
156
+ const limit = flags.limit === undefined ? undefined : Number(flags.limit);
157
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 30)) throw usage('--limit needs a whole number from 1 to 30.');
158
+ return hub.runs({ limit });
159
+ }
@@ -0,0 +1,134 @@
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 { spawn } from 'node:child_process';
5
+ import { parseArgs } from './args.mjs';
6
+ import { hasControl } from '../../engine/lib/text.mjs';
7
+
8
+ // Every command, in the order --help lists them. A command's module is
9
+ // imported only when that command runs: `factoryhub --version` or one
10
+ // command does not load the engine, the packer, the runner and the hub
11
+ // client of all the others. Usage and options stay in each module.
12
+ export const COMMANDS = [
13
+ { name: 'list', summary: 'List the house factories, or the factory folders in a directory.', load: () => import('./commands/list.mjs') },
14
+ { name: 'inspect', summary: 'Show what a factory does: inputs, steps in order, agents, tools, checks.', load: () => import('./commands/inspect.mjs') },
15
+ { name: 'validate', summary: 'Check factories the way the engine reads them. No key, no model.', load: () => import('./commands/validate.mjs') },
16
+ { name: 'run', summary: 'Run a factory with your own model key or a local model. Stops at every decision.', load: () => import('./commands/run.mjs') },
17
+ { name: 'resume', summary: 'Continue a saved run with the person\'s decision.', load: () => import('./commands/resume.mjs') },
18
+ { name: 'new', summary: 'Create a new factory folder that validates and runs.', load: () => import('./commands/new.mjs') },
19
+ { name: 'pack', summary: 'Pack a factory into a zip that runs anywhere with Node, or a bundle .json.', load: () => import('./commands/pack.mjs') },
20
+ { name: 'export', summary: 'Export a factory as an Agent Skill folder (SKILL.md plus the factory).', load: () => import('./commands/export.mjs') },
21
+ { name: 'search', summary: 'Search the published factories on factoryhub (needs FACTORYHUB_ACCESS_KEY in preview).', load: () => import('./commands/search.mjs') },
22
+ { name: 'get', summary: 'Download a published factory from factoryhub into a folder, checking every file.', load: () => import('./commands/get.mjs') },
23
+ { name: 'login', summary: 'Log in to factoryhub from this computer, with a code you approve in your browser.', load: () => import('./commands/login.mjs') },
24
+ { name: 'logout', summary: 'Revoke this computer\'s factoryhub token on the server, then forget it.', load: () => import('./commands/logout.mjs') },
25
+ { name: 'whoami', summary: 'Show which factoryhub account and token this computer uses.', load: () => import('./commands/whoami.mjs') },
26
+ { name: 'push', summary: 'Save a factory folder to your factoryhub account, privately. Nothing is published.', load: () => import('./commands/push.mjs') },
27
+ { name: 'pull', summary: 'Download a factory you can open on factoryhub, yours included, checking every file.', load: () => import('./commands/pull.mjs') },
28
+ { name: 'publish', summary: 'Publish one of your factories on factoryhub, so anyone can find and run it.', load: () => import('./commands/publish.mjs') },
29
+ { name: 'runs', summary: 'List your hosted runs on factoryhub, or show one and save its result and receipt.', load: () => import('./commands/runs.mjs') },
30
+ { name: 'mcp', summary: 'Start the local MCP server on stdio, for MCP clients.', load: () => import('./commands/mcp.mjs') },
31
+ ];
32
+
33
+ /** Every command's module with its summary, for the CLI reference. */
34
+ export async function loadCommands() {
35
+ return Promise.all(COMMANDS.map(async ({ name, summary, load }) => ({ ...(await load()).default, name, summary })));
36
+ }
37
+
38
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
39
+
40
+ /**
41
+ * The command that opens `url` in a browser on `platform`, or null when the
42
+ * address is not a plain http(s) one. No shell runs it: on Windows,
43
+ * rundll32 hands the address to the default browser, so characters cmd.exe
44
+ * would read (& | ^ % < > ") are never interpreted.
45
+ */
46
+ export function browserCommand(url, platform = process.platform) {
47
+ let parsed;
48
+ try { parsed = new URL(String(url)); } catch { return null; }
49
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return null;
50
+ const href = parsed.href;
51
+ // The URL parser percent-encodes spaces, quotes and controls; refuse anything it left.
52
+ if (/[\s"'`<>\\]/.test(href) || hasControl(href)) return null;
53
+ if (platform === 'darwin') return ['open', [href]];
54
+ if (platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler', href]];
55
+ return ['xdg-open', [href]];
56
+ }
57
+
58
+ /** Opens an address in the person's browser. True when a browser was started. */
59
+ export function openInBrowser(url, { platform = process.platform, spawnImpl = spawn } = {}) {
60
+ const command = browserCommand(url, platform);
61
+ if (!command) return Promise.resolve(false);
62
+ return new Promise((resolve) => {
63
+ try {
64
+ const child = spawnImpl(command[0], command[1], { stdio: 'ignore', detached: true, shell: false, windowsHide: true });
65
+ child.once('error', () => resolve(false));
66
+ child.once('spawn', () => { child.unref(); resolve(true); });
67
+ } catch { resolve(false); }
68
+ });
69
+ }
70
+
71
+ /** The streams and environment a command uses. Tests pass their own. */
72
+ export function defaultIo() {
73
+ return {
74
+ stdout: process.stdout,
75
+ stderr: process.stderr,
76
+ stdin: process.stdin,
77
+ env: process.env,
78
+ cwd: process.cwd(),
79
+ fetch: globalThis.fetch,
80
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
81
+ color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR,
82
+ // Runs `cleanup` (at most five seconds) before Ctrl+C or a stop signal
83
+ // ends the process. Returns the function that removes it again.
84
+ onInterrupt(cleanup) {
85
+ const handler = (signal) => {
86
+ Promise.race([Promise.resolve().then(cleanup).catch(() => {}), new Promise((resolve) => { setTimeout(resolve, 5000).unref?.(); })])
87
+ .finally(() => process.exit(signal === 'SIGTERM' ? 143 : 130));
88
+ };
89
+ process.once('SIGINT', handler);
90
+ process.once('SIGTERM', handler);
91
+ return () => { process.off('SIGINT', handler); process.off('SIGTERM', handler); };
92
+ },
93
+ // Only at a terminal: a script or an agent's shell never opens a browser.
94
+ openUrl: process.stdin.isTTY && process.stdout.isTTY ? openInBrowser : null,
95
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
96
+ };
97
+ }
98
+
99
+ function help(io) {
100
+ const width = Math.max(...COMMANDS.map((command) => command.name.length));
101
+ io.stdout.write(`factoryhub ${VERSION}: find, run, make and share AI factories.
102
+
103
+ Usage: factoryhub <command> [options]
104
+
105
+ Commands:
106
+ ${COMMANDS.map((command) => ` ${command.name.padEnd(width)} ${command.summary}`).join('\n')}
107
+
108
+ Run "factoryhub <command> --help" for a command's options.
109
+ A factory is a folder with factory.yaml, flow.yaml and agents/<name>/agent.md.
110
+ The format: spec/factory-format.md in https://github.com/Mindburn-Labs/factoryhub
111
+ `);
112
+ }
113
+
114
+ /** Runs one command line. Returns the exit code. */
115
+ export async function main(argv, io = defaultIo()) {
116
+ const [name, ...rest] = argv;
117
+ if (!name || name === '--help' || name === '-h' || name === 'help') { help(io); return 0; }
118
+ if (name === '--version' || name === '-v') { io.stdout.write(`${VERSION}\n`); return 0; }
119
+ const entry = COMMANDS.find((item) => item.name === name);
120
+ if (!entry) { io.stderr.write(`Unknown command "${name}". Run "factoryhub --help" for the list.\n`); return 2; }
121
+ const command = (await entry.load()).default;
122
+ let args;
123
+ try { args = parseArgs(rest, command.options || {}); } catch (error) {
124
+ io.stderr.write(`${error.message}\n\n${command.usage}\n`);
125
+ return 2;
126
+ }
127
+ if (args.flags.help) { io.stdout.write(`${command.usage}\n`); return 0; }
128
+ try {
129
+ return await command.run(args, io);
130
+ } catch (error) {
131
+ io.stderr.write(`${error.message}\n`);
132
+ return error.usage ? 2 : 1;
133
+ }
134
+ }