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,37 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve, relative } from 'node:path';
3
+ import { engine, writeFiles, out, styles, usage } from '../util.mjs';
4
+ import { hubClient, DEFAULT_HUB } from '../../../engine/lib/hub.mjs';
5
+ import { HUB_NOTE } from './search.mjs';
6
+
7
+ export default {
8
+ name: 'get',
9
+ usage: `Usage: factoryhub get <id> [--out dir] [--base-url url] [--force]
10
+
11
+ Downloads the factory's transfer bundle, checks every file against its
12
+ recorded SHA-256, and writes the files to <dir>/<id>/.
13
+
14
+ ${HUB_NOTE}
15
+
16
+ Options:
17
+ --out the parent folder (default: the current folder)
18
+ --base-url the factoryhub address (default: FACTORYHUB_BASE_URL or ${DEFAULT_HUB})
19
+ --force write into an existing folder`,
20
+ options: { boolean: ['force'], string: ['out', 'base-url'] },
21
+ async run({ positional, flags }, io) {
22
+ const [id] = positional;
23
+ if (!id) throw usage('Name the factory to get: factoryhub get <id>');
24
+ const hub = hubClient({ baseUrl: flags['base-url'], env: io.env, fetchImpl: io.fetch });
25
+ const factory = await hub.get(id);
26
+ const folder = resolve(io.cwd, flags.out || '.', id);
27
+ if (existsSync(folder) && !flags.force) throw usage(`${folder} already exists. Pass --force to write into it.`);
28
+ await writeFiles(folder, factory.files);
29
+ const parsed = engine.parseBundle(factory.files);
30
+ const s = styles(io);
31
+ const state = !parsed.ok ? s.red('invalid') : parsed.factory.runnable ? s.green('runs here') : s.yellow('does not run here');
32
+ out(io, `${s.green('saved')} ${relative(io.cwd, folder) || folder}/ ${parsed.factory.title} v${parsed.factory.version}, ${Object.keys(factory.files).length} files, ${state}`);
33
+ if (factory.leftOut.length) out(io, `Not part of the factory, so not written: ${factory.leftOut.join(', ')}`);
34
+ out(io, `Source: ${factory.source}`);
35
+ return parsed.ok ? 0 : 1;
36
+ },
37
+ };
@@ -0,0 +1,65 @@
1
+ import { engine, readFactory, summarize, out, json, table, plural, styles, usage, canonicalFiles } from '../util.mjs';
2
+
3
+ export default {
4
+ name: 'inspect',
5
+ usage: `Usage: factoryhub inspect <factory> [--json]
6
+
7
+ Prints a factory's inputs, its steps stage by stage (steps in one stage can
8
+ run at the same time), who does each, its agents, tools, and the
9
+ statements its deliverable is checked against. A factory is a folder, a
10
+ bundle .json file, an https address of a bundle, or a house factory id.
11
+
12
+ Options:
13
+ --json print the full summary as JSON, including the JSON Schema of the inputs`,
14
+ options: { boolean: ['json'] },
15
+ async run({ positional, flags }, io) {
16
+ const [target] = positional;
17
+ if (!target) throw usage('Name a factory to inspect.');
18
+ const read = await readFactory(target, { cwd: io.cwd, fetchImpl: io.fetch });
19
+ const summary = await summarize(read.files, { id: read.source.id });
20
+ if (flags.json) { json(io, summary); return summary.valid ? 0 : 1; }
21
+ const s = styles(io);
22
+ if (!summary.valid) {
23
+ out(io, `${s.red('invalid')} ${target}`);
24
+ for (const error of summary.errors) out(io, ` - ${error}`);
25
+ return 1;
26
+ }
27
+ const { factory } = engine.parseBundle(canonicalFiles(read.files));
28
+ out(io, `${s.bold(summary.title)} ${s.dim(`v${summary.version}${summary.license ? `, ${summary.license}` : ''}${summary.author ? `, by ${summary.author}` : ''}`)}`);
29
+ out(io, summary.description);
30
+ out(io, s.dim(`${summary.name} sha256 ${summary.sha256}${summary.time ? ` ${summary.time}` : ''} ${summary.runnable ? 'runs here' : 'does not run here'}`));
31
+ out(io);
32
+ out(io, s.bold('Inputs'));
33
+ out(io, summary.inputs.length ? table(summary.inputs.map((input) => [` ${input.key}`, input.type, input.required ? 'required' : 'optional', input.help])) : ' none');
34
+ out(io);
35
+ if (factory.steps.length) {
36
+ const { columns } = engine.layout(factory);
37
+ out(io, s.bold(`Steps (${plural(factory.steps.length, 'step')} in ${plural(columns.length, 'stage')})`));
38
+ const rows = [];
39
+ columns.forEach((ids, stage) => {
40
+ for (const id of ids) {
41
+ const step = factory.steps.find((item) => item.id === id);
42
+ const who = step.kind === 'approve' ? 'the person' : step.agent;
43
+ const detail = step.kind === 'check' ? `checks ${step.target}` : step.kind === 'approve' ? `shows ${step.target}, waits for a decision` : step.needs.length ? `after ${step.needs.join(', ')}` : 'first';
44
+ const tools = [...step.tools, ...step.missing];
45
+ rows.push([` ${stage + 1}`, step.id, step.kind, who, `${detail}${tools.length ? `; tools ${tools.join(', ')}` : ''}`]);
46
+ }
47
+ });
48
+ out(io, table(rows));
49
+ } else out(io, `${s.bold('Steps')}\n none: this factory has no flow`);
50
+ out(io);
51
+ out(io, s.bold('Agents'));
52
+ out(io, table(summary.agents.map((agent) => [` ${agent.name}`, agent.type === 'FOREMAN' ? 'coordinates the flow (generated from it)' : agent.description])));
53
+ if (summary.done.length) {
54
+ out(io);
55
+ out(io, s.bold('Done when'));
56
+ for (const line of summary.done) out(io, ` - ${line}`);
57
+ }
58
+ if (summary.blockers.length) {
59
+ out(io);
60
+ out(io, s.bold('Cannot run here'));
61
+ for (const blocker of summary.blockers) out(io, ` - ${blocker}`);
62
+ }
63
+ return 0;
64
+ },
65
+ };
@@ -0,0 +1,24 @@
1
+ import { resolve } from 'node:path';
2
+ import { readFolder, listFactoryDirs, summarize, matches, out, json, table, HOUSE_FACTORIES } from '../util.mjs';
3
+
4
+ export default {
5
+ name: 'list',
6
+ usage: `Usage: factoryhub list [directory] [--query words] [--json]
7
+
8
+ Lists the factory folders directly inside a directory. With no directory,
9
+ lists the house factories that ship with factoryhub.
10
+
11
+ Options:
12
+ --query keep factories whose id, title, description or tags contain every word
13
+ --json print the summaries as JSON`,
14
+ options: { boolean: ['json'], string: ['query'] },
15
+ async run({ positional, flags }, io) {
16
+ const dir = positional[0] ? resolve(io.cwd, positional[0]) : HOUSE_FACTORIES;
17
+ const entries = await Promise.all((await listFactoryDirs(dir)).map(async (id) => summarize((await readFolder(resolve(dir, id))).files, { id })));
18
+ const kept = entries.filter((entry) => matches(entry, flags.query));
19
+ if (flags.json) { json(io, kept); return 0; }
20
+ if (!kept.length) { out(io, entries.length ? 'No factory matches.' : `No factory folders in ${dir}.`); return 0; }
21
+ out(io, table([['ID', 'VERSION', 'STEPS', 'INPUTS', 'TITLE'], ...kept.map((entry) => [entry.id, entry.version, entry.steps.length, entry.inputs.length, entry.valid ? entry.title : `${entry.title} (invalid)`])]));
22
+ return 0;
23
+ },
24
+ };
@@ -0,0 +1,161 @@
1
+ import { hostname } from 'node:os';
2
+ import { createInterface } from 'node:readline';
3
+ import { Writable } from 'node:stream';
4
+ import { out, err, json, styles, usage, connect, day, BASE_URL_HELP } from '../util.mjs';
5
+ import { hubClient, deviceLogin, SCOPES, OPT_IN_SCOPES, TOKEN_PATTERN } from '../../../engine/lib/hub.mjs';
6
+ import { saveCredential, storedCredential, credentialsPath, TOKEN_ENV, PRIVACY } from '../../../engine/lib/credentials.mjs';
7
+
8
+ const MAX_DAYS = 366;
9
+
10
+ /** Scopes from --scopes "a,b" or "a b": each one known, none repeated. */
11
+ export function parseScopes(value) {
12
+ if (value === undefined) return null;
13
+ const scopes = [...new Set(String(value).split(/[\s,]+/).filter(Boolean))];
14
+ if (!scopes.length) throw usage(`--scopes needs at least one of: ${SCOPES.join(', ')}.`);
15
+ const unknown = scopes.filter((scope) => !SCOPES.includes(scope));
16
+ if (unknown.length) throw usage(`Unknown scope ${unknown.join(', ')}. Choose from: ${SCOPES.join(', ')}.`);
17
+ return SCOPES.filter((scope) => scopes.includes(scope));
18
+ }
19
+
20
+ function parseDays(value) {
21
+ if (value === undefined) return null;
22
+ const days = Number(value);
23
+ if (!Number.isInteger(days) || days < 1 || days > MAX_DAYS) throw usage(`--days needs a whole number from 1 to ${MAX_DAYS}, not "${value}".`);
24
+ return days;
25
+ }
26
+
27
+ /** Reads a token without showing it: hidden typing at a terminal, else all of stdin. */
28
+ export async function readSecret(io, prompt) {
29
+ if (io.stdin.isTTY) {
30
+ io.stderr.write(prompt);
31
+ const muted = new Writable({ write(_chunk, _encoding, done) { done(); } });
32
+ const reader = createInterface({ input: io.stdin, output: muted, terminal: true });
33
+ try {
34
+ const line = await new Promise((resolve) => { reader.once('line', resolve); reader.once('close', () => resolve('')); });
35
+ io.stderr.write('\n');
36
+ return line.trim();
37
+ } finally { reader.close(); }
38
+ }
39
+ let text = '';
40
+ for await (const chunk of io.stdin) text += chunk;
41
+ return text.trim();
42
+ }
43
+
44
+ const shortName = () => `factoryhub CLI on ${hostname() || 'this computer'}`.slice(0, 60);
45
+ const prefixOf = (token) => (token?.prefix ? `${token.prefix}…` : 'fhpat_…');
46
+
47
+ /** What the person sees once a token is stored. */
48
+ function report(io, { base, me, path, flags, replaced, origin }) {
49
+ const s = styles(io);
50
+ if (flags.json) {
51
+ json(io, { base, account: { handle: me.account.handle, name: me.account.name }, token: { id: me.token.id, name: me.token.name, prefix: me.token.prefix, scopes: me.token.scopes, expires: me.token.expires, origin }, saved: path, replaced });
52
+ return;
53
+ }
54
+ out(io, `${s.green('Logged in')} to ${base} as ${s.bold(`@${me.account.handle}`)}.`);
55
+ out(io, ` Token ${me.token.name} (${prefixOf(me.token)}), expires ${day(me.token.expires)}`);
56
+ out(io, ` Scopes ${me.token.scopes.join(', ')}`);
57
+ out(io, ` Saved in ${path}, ${PRIVACY}.`);
58
+ if (replaced === 'revoked') out(io, s.dim(' The token this computer received by its previous login was revoked.'));
59
+ else if (replaced === 'kept') out(io, s.dim(' The token saved before was one you passed in, so it was not revoked: revoke it in your account settings if nothing else uses it.'));
60
+ else if (replaced === 'not_revoked') out(io, s.yellow(' The token this computer received by its previous login could not be revoked: revoke it in your account settings.'));
61
+ out(io, `\nNext: ${s.bold('factoryhub push <folder>')} saves a factory to your account, privately.`);
62
+ if (!me.token.scopes.includes('factories:publish')) out(io, s.dim('To publish factories from here, log in with --scopes including factories:publish.'));
63
+ if (io.env[TOKEN_ENV]) err(io, s.yellow(`\nFACTORYHUB_TOKEN is set, so it is used instead of this login until you unset it.`));
64
+ }
65
+
66
+ // After a new token is stored, the one it replaced is revoked only if this
67
+ // CLI received it by device login: it was never printed, so nothing else
68
+ // should be using it. A token the person passed in with --token may also live
69
+ // in CI or another machine, so it is left alone. A record without an origin
70
+ // predates this rule and is treated as passed in.
71
+ // Returns 'revoked', 'not_revoked', 'kept' or null when nothing was replaced.
72
+ async function revokeReplaced(base, previous, io) {
73
+ if (!previous?.token) return null;
74
+ if (previous.origin !== 'device') return 'kept';
75
+ try {
76
+ await hubClient({ baseUrl: base, env: io.env, fetchImpl: io.fetch, token: previous.token }).revoke();
77
+ return 'revoked';
78
+ } catch {
79
+ return 'not_revoked';
80
+ }
81
+ }
82
+
83
+ export default {
84
+ name: 'login',
85
+ usage: `Usage: factoryhub login [--scopes a,b] [--name text] [--days n] [--no-browser]
86
+ factoryhub login --token < token.txt
87
+
88
+ Shows a short code and opens the factoryhub device page. Sign in there,
89
+ check the code matches, choose what this computer may do, and approve.
90
+ The token it receives is saved in ${'${XDG_CONFIG_HOME:-~/.config}'}/factoryhub/credentials.json,
91
+ readable only by you on macOS and Linux (mode 0600; on Windows, as private
92
+ as your user folder). It is never printed. A later login revokes a token an
93
+ earlier device login saved, but never one you passed in with --token.
94
+
95
+ Scopes (default: every scope that is not opt-in):
96
+ factories:read open your private factories
97
+ factories:write save factories to your account (push)
98
+ runs:start start hosted runs
99
+ runs:read follow your runs and read their results
100
+ factories:publish publish factories (opt-in: tick it on the page)
101
+ runs:decide decide a waiting step for you (opt-in: tick it on the page)
102
+
103
+ For CI or a machine without a browser, make a token in your account
104
+ settings and pass it on standard input with --token, or set FACTORYHUB_TOKEN
105
+ (which always wins over a saved login).
106
+
107
+ Options:
108
+ --scopes the scopes to ask for, comma-separated
109
+ --name what the token is called on your account page
110
+ (default: "factoryhub CLI on <this computer>")
111
+ --days how long the token lives, 1 to ${MAX_DAYS} days (default: the hub's, 90)
112
+ --no-browser print the address instead of opening it
113
+ --token read a token from standard input instead
114
+ --base-url ${BASE_URL_HELP}
115
+ --json print the result as JSON`,
116
+ options: { boolean: ['token', 'browser', 'json'], string: ['scopes', 'name', 'days', 'base-url'] },
117
+ async run({ positional, flags }, io) {
118
+ if (positional.length) throw usage(`login takes no words, only options. To pass a token, use: factoryhub login --token < token.txt`);
119
+ const { base, hub } = await connect(flags, io);
120
+ const s = styles(io);
121
+ const scopes = parseScopes(flags.scopes);
122
+ const days = parseDays(flags.days);
123
+ const name = flags.name === undefined ? shortName() : String(flags.name).trim().slice(0, 60);
124
+ if (!name) throw usage('--name cannot be empty.');
125
+ const previous = await storedCredential(base, io.env);
126
+
127
+ let token;
128
+ let origin;
129
+ if (flags.token) {
130
+ if (flags.scopes || flags.days || flags.name) throw usage('--token uses the token as it was made; --scopes, --days and --name apply to a new login only.');
131
+ token = await readSecret(io, 'Paste your factoryhub token (it is not shown) and press Enter: ');
132
+ if (!token) throw usage('No token on standard input. Pass it like this: factoryhub login --token < token.txt');
133
+ if (!TOKEN_PATTERN.test(token)) throw usage('That is not a factoryhub personal access token: they start with fhpat_ and are 49 characters long.');
134
+ origin = 'pasted';
135
+ } else {
136
+ const asked = scopes || [];
137
+ const optIn = asked.filter((scope) => OPT_IN_SCOPES.includes(scope));
138
+ const answer = await deviceLogin(hub, {
139
+ name, scopes, days,
140
+ sleep: io.sleep,
141
+ onCode: async ({ userCode, verificationUri, verificationUriComplete, expiresIn }) => {
142
+ err(io, `To log in to ${base}, open this page in a browser where you are signed in:\n\n ${s.bold(verificationUri)}\n\nand enter the code ${s.bold(userCode)}\n`);
143
+ err(io, s.dim(`Check that the page shows the same code before you approve.${optIn.length ? ` Tick ${optIn.join(' and ')} there: opt-in scopes are granted only when you tick them.` : ''}`));
144
+ const page = verificationUriComplete || verificationUri;
145
+ if (flags.browser !== false && io.openUrl && new URL(page, base).origin === new URL(base).origin) {
146
+ if (await io.openUrl(new URL(page, base).href)) err(io, s.dim('Opened the page in your browser.'));
147
+ }
148
+ err(io, `Waiting for you to approve (the code works for ${Math.round(expiresIn / 60)} minutes; Ctrl+C to stop)...`);
149
+ },
150
+ });
151
+ token = answer.access_token;
152
+ origin = 'device';
153
+ }
154
+
155
+ const me = await hubClient({ baseUrl: base, env: io.env, fetchImpl: io.fetch, token }).me();
156
+ const path = await saveCredential(base, { token, origin, id: me.token.id, name: me.token.name, prefix: me.token.prefix, scopes: me.token.scopes, expires: me.token.expires, account: me.account.handle }, io.env);
157
+ const replaced = previous && previous.token !== token ? await revokeReplaced(base, previous, io) : null;
158
+ report(io, { base, me, path: credentialsPath(io.env) || path, flags, replaced, origin });
159
+ return 0;
160
+ },
161
+ };
@@ -0,0 +1,43 @@
1
+ import { out, styles, usage, hubBase, BASE_URL_HELP } from '../util.mjs';
2
+ import { hubClient } from '../../../engine/lib/hub.mjs';
3
+ import { storedCredential, deleteCredential, credentialsPath, TOKEN_ENV } from '../../../engine/lib/credentials.mjs';
4
+
5
+ export default {
6
+ name: 'logout',
7
+ usage: `Usage: factoryhub logout [--base-url url]
8
+
9
+ Revokes the token "factoryhub login" saved, on the factoryhub server first,
10
+ so it stops working everywhere, and then deletes it from this computer. If
11
+ the server cannot be reached, nothing is deleted: run it again later, or
12
+ revoke the token in your account settings.
13
+
14
+ A token in FACTORYHUB_TOKEN is not touched: unset it yourself, and revoke it
15
+ in your account settings if nobody needs it.
16
+
17
+ Options:
18
+ --base-url ${BASE_URL_HELP}`,
19
+ options: { string: ['base-url'] },
20
+ async run({ positional, flags }, io) {
21
+ if (positional.length) throw usage('logout takes no words, only options.');
22
+ const s = styles(io);
23
+ const base = hubBase(flags, io);
24
+ const stored = await storedCredential(base, io.env);
25
+ const envNote = io.env[TOKEN_ENV] ? s.yellow(`\nFACTORYHUB_TOKEN is still set, so commands still use that token. Unset it, and revoke it in your account settings if nobody needs it.`) : '';
26
+ if (!stored) {
27
+ out(io, `Not logged in to ${base} on this computer: nothing to do.${envNote}`);
28
+ return 0;
29
+ }
30
+ let note;
31
+ try {
32
+ await hubClient({ baseUrl: base, env: io.env, fetchImpl: io.fetch, token: stored.token, tokenSource: 'file' }).revoke();
33
+ note = 'revoked on the server';
34
+ } catch (error) {
35
+ // A token the server no longer accepts is already as good as revoked.
36
+ if (error.status === 401) note = 'the server no longer accepted it';
37
+ else throw new Error(`${error.message}\nThe token was not revoked, so it is still saved in ${credentialsPath(io.env)}. Try again, or revoke "${stored.name || 'it'}" in your account settings.`, { cause: error });
38
+ }
39
+ await deleteCredential(base, io.env);
40
+ out(io, `${s.green('Logged out')} of ${base}: ${stored.name ? `"${stored.name}" ` : ''}${note}, and deleted from this computer.${envNote}`);
41
+ return 0;
42
+ },
43
+ };
@@ -0,0 +1,25 @@
1
+ // Starts the local MCP server on stdio: the same server as the factoryhub-mcp bin.
2
+ // It exists so one published package starts its MCP server with
3
+ // `npx factoryhub mcp`, the form MCP registries and clients build from
4
+ // server.json (identifier + version, then the package arguments).
5
+ // stdout carries MCP only; the server logs to stderr.
6
+
7
+ export default {
8
+ name: 'mcp',
9
+ usage: `Usage: factoryhub mcp
10
+
11
+ Starts the factoryhub MCP server on stdin and stdout, the same server as the
12
+ factoryhub-mcp command. An MCP client starts it; it is not meant to be typed.
13
+ Model keys and folders come from the environment: ANTHROPIC_API_KEY,
14
+ OPENAI_API_KEY or OPENROUTER_API_KEY (only to run factories), FACTORYHUB_MODEL,
15
+ FACTORYHUB_FACTORIES and FACTORYHUB_RUNS_DIR.`,
16
+ options: {},
17
+ async run() {
18
+ // Loaded here, not at the top, so other commands do not pay for the MCP SDK.
19
+ const { serveStdio } = await import('@modelcontextprotocol/server/stdio');
20
+ const { createServer } = await import('../../../mcp/src/server.mjs');
21
+ serveStdio(() => createServer());
22
+ // The open stdin keeps the process alive until the client closes it.
23
+ return 0;
24
+ },
25
+ };
@@ -0,0 +1,147 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { resolve, relative } from 'node:path';
3
+ import { engine, writeFiles, out, styles, usage } from '../util.mjs';
4
+
5
+ /** The files of a new factory: small, valid, and using every kind of step. */
6
+ export function scaffold({ name, title, description }) {
7
+ const heading = title || name.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase());
8
+ const about = (description || `Turns a brief into a checked first draft, with your approval of the outline first. Use when you need a first draft that keeps to the brief.`).replace(/\s+/g, ' ').trim();
9
+ const wrapped = about.match(/.{1,76}(\s|$)/g).map((line) => ` ${line.trim()}`).join('\n');
10
+ return {
11
+ 'factory.yaml': `schemaVersion: v1alpha1
12
+ name: ${name}
13
+ description: |
14
+ ${wrapped}
15
+ repositories: []
16
+ agentDefaults:
17
+ harness:
18
+ type: claude
19
+ model: sonnet
20
+ `,
21
+ 'flow.yaml': `flow: mindburn.flow/v1
22
+ title: ${JSON.stringify(heading)}
23
+ version: "0.1.0"
24
+ license: Apache-2.0
25
+ tags: [writing]
26
+ time: about 2 minutes
27
+ inputs:
28
+ brief:
29
+ type: longtext
30
+ help: "What the draft is for, who reads it, and the facts it must use."
31
+ example:
32
+ type: url
33
+ optional: true
34
+ help: "A page whose tone the draft should match, for example example.com/about."
35
+ steps:
36
+ outline:
37
+ title: Outline the draft
38
+ agent: writer
39
+ kind: work
40
+ tools: [web_fetch]
41
+ task: |
42
+ If an example page was given, read it to learn its tone. Then write an
43
+ outline: a working title, the reader in one sentence, and four to six
44
+ sections with one line each on what the section says. Use only facts
45
+ from the brief; mark anything missing as "[ADD: what is missing]".
46
+ approve_outline:
47
+ title: Agree the outline
48
+ kind: approve
49
+ needs: [outline]
50
+ shows: outline
51
+ task: |
52
+ Everything else is written from this outline. Approve it, or say what
53
+ to change.
54
+ draft:
55
+ title: Write the draft
56
+ agent: writer
57
+ kind: deliver
58
+ needs: [approve_outline]
59
+ task: |
60
+ Write the full draft from the approved outline, section by section, in
61
+ the tone of the example page if one was read. Keep every "[ADD: ...]"
62
+ marker. Do not add facts that are not in the brief.
63
+ check_draft:
64
+ title: Check the draft
65
+ agent: reviewer
66
+ kind: check
67
+ needs: [draft]
68
+ checks: draft
69
+ task: |
70
+ Every section of the approved outline is present. Every fact in the
71
+ draft appears in the brief. Missing facts are marked "[ADD: ...]", not
72
+ invented.
73
+ done:
74
+ - "The draft follows the approved outline."
75
+ - "Nothing in the draft is missing from the brief or invented."
76
+ `,
77
+ 'agents/writer/agent.md': `---
78
+ description: Writes clear drafts from a brief and the steps before it.
79
+ agentType: CUSTOM
80
+ ---
81
+
82
+ You are the writer. You turn what the person gave and what earlier steps
83
+ produced into plain, specific text for a busy reader.
84
+
85
+ - You use only facts from the inputs, earlier results and pages you read.
86
+ - Where a fact is missing, you leave a marker such as "[ADD: the price]".
87
+ - You keep to the shape, sections and length the step asks for.
88
+ `,
89
+ 'agents/reviewer/agent.md': `---
90
+ description: Checks a result against its criteria and sends weak work back.
91
+ agentType: REVIEW
92
+ ---
93
+
94
+ You are the reviewer. You compare a result with its task, the inputs and the
95
+ earlier evidence, and you pass it only when every criterion is met.
96
+
97
+ - You fail work only for a clear breach you can point to, and you say where.
98
+ - You never fix the work yourself; you say what must change.
99
+ `,
100
+ 'README.md': `# ${heading}
101
+
102
+ ${about}
103
+
104
+ Edit the files to make this factory yours:
105
+
106
+ - \`factory.yaml\`: the name and what the factory is for.
107
+ - \`flow.yaml\`: the inputs and the steps, who does each and what each needs.
108
+ - \`agents/<name>/agent.md\`: one file per agent, its role in prose.
109
+
110
+ Check it with \`factoryhub validate\`, read it back with \`factoryhub inspect .\`,
111
+ and run it with your own model key with \`factoryhub run .\`.
112
+ `,
113
+ };
114
+ }
115
+
116
+ export default {
117
+ name: 'new',
118
+ usage: `Usage: factoryhub new <name> [--dir parent] [--title text] [--description text]
119
+
120
+ Creates <parent>/<name>/ with factory.yaml, flow.yaml, two agents and a README.
121
+ The starting factory turns a brief into a checked draft and uses every kind
122
+ of step: work, approve, deliver and check. Edit it to make it yours.
123
+
124
+ <name> is lowercase letters, digits and hyphens.
125
+
126
+ Options:
127
+ --dir where to create the folder (default: the current folder)
128
+ --title the factory's title
129
+ --description what it produces and when to use it`,
130
+ options: { string: ['dir', 'title', 'description'] },
131
+ async run({ positional, flags }, io) {
132
+ const [name] = positional;
133
+ if (!name) throw usage('Name the new factory: factoryhub new my-factory');
134
+ if (engine.slugify(name) !== name) throw usage(`"${name}" is not a factory name. Use lowercase letters, digits and hyphens, such as ${engine.slugify(name) || 'my-factory'}.`);
135
+ const folder = resolve(io.cwd, flags.dir || '.', name);
136
+ if (existsSync(folder) && readdirSync(folder).length) throw usage(`${folder} already exists and is not empty.`);
137
+ const files = scaffold({ name, title: flags.title, description: flags.description });
138
+ const parsed = engine.parseBundle(files);
139
+ if (!parsed.ok || !parsed.factory.runnable) throw new Error(`The new factory did not validate:\n${[...parsed.errors, ...parsed.factory.blockers].map((item) => ` - ${item}`).join('\n')}`);
140
+ await writeFiles(folder, files);
141
+ const s = styles(io);
142
+ const shown = relative(io.cwd, folder) || '.';
143
+ out(io, `${s.green('created')} ${shown}/ with ${Object.keys(files).sort().join(', ')}`);
144
+ out(io, `It validates and runs. Next:\n factoryhub inspect ${shown}\n factoryhub run ${shown}`);
145
+ return 0;
146
+ },
147
+ };
@@ -0,0 +1,96 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve, relative } from 'node:path';
4
+ import { engine, readFactory, canonicalFiles, out, styles, usage } from '../util.mjs';
5
+ import { packageManifest, zip } from '../../../engine/src/package.mjs';
6
+
7
+ const RUNNER = ['run.mjs', 'factory-engine.mjs', 'author-tests.mjs'];
8
+ const ENGINE_DIR = new URL('../../../engine/src/', import.meta.url);
9
+
10
+ function running(factory, withRunner) {
11
+ const example = factory.inputs.filter((input) => input.required).map((input) => ` --input ${input.key}="..."`).join('');
12
+ return `# ${factory.title}
13
+
14
+ ${factory.description}
15
+
16
+ This folder holds the factory's files${withRunner ? ' and a portable runner with no dependencies' : ''}. Running it calls the model you choose, with your own key; your provider bills you for it.
17
+
18
+ - \`factory.yaml\`, \`flow.yaml\` and \`agents/\`: the factory.
19
+ ${withRunner ? '- `run.mjs`, `factory-engine.mjs` and `author-tests.mjs`: the runner (Apache-2.0).\n' : ''}- \`INPUTS.schema.json\`: the JSON Schema of the inputs.
20
+ - \`FACTORY-HUB.json\`: the source hash, declared model and cost, and the validation result. It is generated, not source.
21
+ ${withRunner ? `
22
+ ## Run it
23
+
24
+ Needs Node.js 22 or newer.
25
+
26
+ \`\`\`sh
27
+ export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY=...
28
+ node run.mjs${example}
29
+ \`\`\`
30
+
31
+ A model on your own machine works through any OpenAI-compatible server:
32
+
33
+ \`\`\`sh
34
+ node run.mjs --base-url http://localhost:11434/v1 --model <name>
35
+ \`\`\`
36
+
37
+ The runner stops at every approval step and asks you. With nobody at the keyboard it stops with exit code 3 and approves nothing; \`--yes\` approves every decision and the receipt records it. \`node run.mjs --validate\` checks the files without a key and without running anything. \`node run.mjs --help\` lists every option.
38
+ ` : ''}`;
39
+ }
40
+
41
+ export default {
42
+ name: 'pack',
43
+ usage: `Usage: factoryhub pack <factory> [--out file] [--format zip|json] [--no-runner]
44
+
45
+ zip (the default) writes <name>/ with the factory's files, the generated
46
+ foreman, INPUTS.schema.json, FACTORY-HUB.json, RUNNING.md and the portable
47
+ runner (run.mjs with the engine), so "node run.mjs" works after unzipping.
48
+
49
+ json writes the transfer bundle (mindburn.factory.bundle/v1): every file with
50
+ its SHA-256, the form "factoryhub run" and "factoryhub validate" read back.
51
+
52
+ Options:
53
+ --out output file (default: <name>-<version>.zip or <name>.bundle.json)
54
+ --format zip or json
55
+ --no-runner leave the runner out of the zip
56
+ --force replace an existing output file`,
57
+ options: { boolean: ['runner', 'force'], string: ['out', 'format'] },
58
+ async run({ positional, flags }, io) {
59
+ const [target] = positional;
60
+ if (!target) throw usage('Name a factory to pack.');
61
+ const format = flags.format || 'zip';
62
+ if (!['zip', 'json'].includes(format)) throw usage('--format is zip or json.');
63
+ const read = await readFactory(target, { cwd: io.cwd, fetchImpl: io.fetch });
64
+ const files = canonicalFiles(read.files);
65
+ const parsed = engine.parseBundle(files);
66
+ if (!parsed.ok) throw new Error(`This factory is not valid, so it was not packed:\n${parsed.errors.map((error) => ` - ${error}`).join('\n')}`);
67
+ const { factory } = parsed;
68
+ const output = resolve(io.cwd, flags.out || (format === 'zip' ? `${factory.name}-${factory.version}.zip` : `${factory.name}.bundle.json`));
69
+ if (existsSync(output) && !flags.force) throw usage(`${output} already exists. Pass --force to replace it.`);
70
+ let bytes;
71
+ if (format === 'json') {
72
+ const envelope = engine.toEnvelope(files, { project: factory.name });
73
+ for (const entry of envelope.files) {
74
+ entry.sha256 = await engine.sha256(entry.content);
75
+ entry.bytes = new TextEncoder().encode(entry.content).length;
76
+ }
77
+ envelope.sha256 = await engine.bundleHash(files);
78
+ bytes = `${JSON.stringify(envelope, null, 2)}\n`;
79
+ } else {
80
+ const withRunner = flags.runner !== false;
81
+ const manifest = await packageManifest(files);
82
+ const dir = factory.name;
83
+ const entries = {};
84
+ for (const [path, content] of Object.entries(files)) entries[`${dir}/${path}`] = content;
85
+ entries[`${dir}/RUNNING.md`] = running(factory, withRunner);
86
+ entries[`${dir}/INPUTS.schema.json`] = `${JSON.stringify(manifest.inputs, null, 2)}\n`;
87
+ entries[`${dir}/FACTORY-HUB.json`] = `${JSON.stringify(manifest, null, 2)}\n`;
88
+ if (withRunner) for (const name of RUNNER) entries[`${dir}/${name}`] = await readFile(new URL(name, ENGINE_DIR), 'utf8');
89
+ bytes = Buffer.from(await zip(entries).arrayBuffer());
90
+ }
91
+ await writeFile(output, bytes);
92
+ const s = styles(io);
93
+ out(io, `${s.green('packed')} ${relative(io.cwd, output) || output} ${factory.title} v${factory.version}, sha256 ${await engine.bundleHash(files)}`);
94
+ return 0;
95
+ },
96
+ };
@@ -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
+ };