diff --git a/.github/workflows/public-sdk.yml b/.github/workflows/public-sdk.yml index 5f19b69..d3cc0a4 100644 --- a/.github/workflows/public-sdk.yml +++ b/.github/workflows/public-sdk.yml @@ -38,7 +38,7 @@ jobs: import { pathToFileURL } from 'node:url'; const consumer = process.env.FACTORY_CONSUMER; const sdk = await import(pathToFileURL(join(consumer, 'node_modules', 'flujo-factory', 'src', 'public-sdk.mjs'))); - for (const name of ['Factory', 'FactoryManagedFleet', 'buildSaviaCasePlan', 'observeSaviaCaseTopology', 'createObservatoryClient']) { + for (const name of ['Factory', 'FactoryManagedFleet', 'buildSaviaCasePlan', 'observeSaviaCaseTopology', 'createObservatoryClient', 'liteWorkerCommand']) { assert.equal(typeof sdk[name], 'function', `${name} is missing`); } const require = createRequire(join(consumer, 'index.mjs')); @@ -50,4 +50,5 @@ jobs: assert.equal(factory.status().cells.length, 2); NODE node "$RUNNER_TEMP/consumer/node_modules/flujo-factory/bin/factory-public.mjs" --help + node "$RUNNER_TEMP/consumer/node_modules/flujo-factory/bin/factory-lite.mjs" --help FACTORY_TEST_MCP="$RUNNER_TEMP/consumer/node_modules/flujo-factory/bin/factory-mcp.mjs" node --test --test-name-pattern="public MCP stdio" test/public-sdk.test.mjs diff --git a/PUBLIC_SDK.md b/PUBLIC_SDK.md index 653f5b8..74f3bfd 100644 --- a/PUBLIC_SDK.md +++ b/PUBLIC_SDK.md @@ -67,6 +67,26 @@ same evidence record. ## CLI +### Lite workers + +`factory-lite` opens a regular interactive Codex or Claude CLI session in the +current directory. It does not provision a FLUJO worker or use the FLUJO +conversation harness. The CLI retains its built-in tools and unrestricted file +and command access. The only MCP server supplied for that session is FACTORY's +local board/swarm server, connected to the specified durable database. + +```sh +factory-lite codex /absolute/path/board.sqlite "Work on the Factory board" +factory-lite claude /absolute/path/board.sqlite "Work on the Factory board" +``` + +Create the swarm first with `factory create` or the SDK. Lite Codex uses a +dedicated persistent CLI home at `~/.factory/lite-codex`, so its first launch +may require a separate Codex login. Claude uses its normal login and loads only +the explicit FACTORY MCP config. The optional prompt can be +omitted to start an interactive session without initial instructions. + + Use an absolute database path for a durable swarm: ```sh diff --git a/bin/factory-lite.mjs b/bin/factory-lite.mjs new file mode 100644 index 0000000..447f6ba --- /dev/null +++ b/bin/factory-lite.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { liteWorkerCommand } from '../src/lite-worker.mjs'; + +const [provider, database, ...words] = process.argv.slice(2); +if (provider === '--help' || provider === 'help' || !provider) { + process.stdout.write('Usage: factory-lite [prompt]\n'); +} else { + try { + const spec = liteWorkerCommand({ provider, database, prompt: words.length ? words.join(' ') : undefined }); + if (spec.env?.CODEX_HOME) mkdirSync(spec.env.CODEX_HOME, { recursive: true, mode: 0o700 }); + const child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: spec.env, stdio: 'inherit' }); + child.on('error', error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); + child.on('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); }); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/package.json b/package.json index bfea2b9..f2616cf 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "src/", "bin/factory-public.mjs", "bin/factory-mcp.mjs", + "bin/factory-lite.mjs", "bin/factory.mjs", "scripts/watch-pilot.mjs", "scripts/pilot.mjs", @@ -34,7 +35,8 @@ }, "bin": { "factory": "./bin/factory-public.mjs", - "factory-mcp": "./bin/factory-mcp.mjs" + "factory-mcp": "./bin/factory-mcp.mjs", + "factory-lite": "./bin/factory-lite.mjs" }, "type": "module", "engines": { diff --git a/src/lite-worker.mjs b/src/lite-worker.mjs new file mode 100644 index 0000000..2b11ecf --- /dev/null +++ b/src/lite-worker.mjs @@ -0,0 +1,23 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; + +const mcp = fileURLToPath(new URL('../bin/factory-mcp.mjs', import.meta.url)); + +/** Start a normal provider CLI with its native tools and only FACTORY's MCP server. */ +export function liteWorkerCommand({ provider, database, prompt, cwd = process.cwd() } = {}) { + if (!['codex', 'claude'].includes(provider)) throw new TypeError('provider must be codex or claude'); + if (typeof database !== 'string' || !database.trim()) throw new TypeError('database path is required'); + if (prompt !== undefined && (typeof prompt !== 'string' || !prompt.trim())) throw new TypeError('prompt must be nonempty'); + const db = resolve(database); + const server = { command: process.execPath, args: [mcp, db] }; + if (provider === 'claude') return { + command: 'claude', cwd, args: ['--dangerously-skip-permissions', '--strict-mcp-config', + '--mcp-config', JSON.stringify({ mcpServers: { factory: server } }), ...(prompt === undefined ? [] : [prompt])], + }; + const toml = value => JSON.stringify(value); + const config = `mcp_servers={factory={command=${toml(server.command)},args=[${server.args.map(toml).join(',')}]}}`; + return { command: 'codex', cwd, env: { ...process.env, CODEX_HOME: resolve(homedir(), '.factory', 'lite-codex') }, + args: ['--sandbox', 'danger-full-access', '--ask-for-approval', 'never', + '--config', config, ...(prompt === undefined ? [] : [prompt])] }; +} diff --git a/src/public-sdk.mjs b/src/public-sdk.mjs index 73a0776..2029f1f 100644 --- a/src/public-sdk.mjs +++ b/src/public-sdk.mjs @@ -82,3 +82,4 @@ export { buildSaviaCasePlan } from './flujo-swarm/template/savia-case-plan.mjs'; export { observeSaviaCaseTopology } from './flujo-swarm/template/savia-topology.mjs'; export { createPresentationServer, startPresentationServer } from './presentation.mjs'; export { createObservatoryClient } from './observatory-client.mjs'; +export { liteWorkerCommand } from './lite-worker.mjs'; diff --git a/test/public-sdk.test.mjs b/test/public-sdk.test.mjs index ca02999..5b42df3 100644 --- a/test/public-sdk.test.mjs +++ b/test/public-sdk.test.mjs @@ -7,11 +7,31 @@ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { Factory, FactoryControl } from '../src/public-sdk.mjs'; +import { Factory, FactoryControl, liteWorkerCommand } from '../src/public-sdk.mjs'; const cli = fileURLToPath(new URL('../bin/factory-public.mjs', import.meta.url)); const mcp = process.env.FACTORY_TEST_MCP ?? fileURLToPath(new URL('../bin/factory-mcp.mjs', import.meta.url)); +test('lite workers launch native CLIs with only the Factory MCP configuration', () => { + for (const provider of ['codex', 'claude']) { + const spec = liteWorkerCommand({ provider, database: 'board.sqlite', prompt: 'Work on the board' }); + assert.equal(spec.command, provider); + assert.equal(spec.args.at(-1), 'Work on the board'); + assert.ok(spec.args.join(' ').includes('factory-mcp.mjs')); + assert.ok(spec.args.join(' ').includes('board.sqlite')); + } + const codex = liteWorkerCommand({ provider: 'codex', database: 'board.sqlite' }); + assert.ok(codex.args.includes('danger-full-access')); + assert.ok(codex.args.includes('never')); + assert.equal(codex.args.filter(value => value === '--config').length, 1); + assert.ok(codex.env.CODEX_HOME.endsWith(join('.factory', 'lite-codex'))); + const claude = liteWorkerCommand({ provider: 'claude', database: 'board.sqlite' }); + assert.ok(claude.args.includes('--dangerously-skip-permissions')); + assert.ok(claude.args.includes('--strict-mcp-config')); + assert.deepEqual(Object.keys(JSON.parse(claude.args[claude.args.indexOf('--mcp-config') + 1]).mcpServers), ['factory']); + assert.throws(() => liteWorkerCommand({ provider: 'flujo', database: 'board.sqlite' }), /provider/); +}); + test('public SDK and CLI coordinate a local swarm without a provider', async t => { const dir = await mkdtemp(join(tmpdir(), 'factory-public-')); t.after(() => rm(dir, { recursive: true, force: true }));