Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/public-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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
20 changes: 20 additions & 0 deletions PUBLIC_SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions bin/factory-lite.mjs
Original file line number Diff line number Diff line change
@@ -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 <codex|claude> <database-path> [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;
}
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
23 changes: 23 additions & 0 deletions src/lite-worker.mjs
Original file line number Diff line number Diff line change
@@ -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])] };
}
1 change: 1 addition & 0 deletions src/public-sdk.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
22 changes: 21 additions & 1 deletion test/public-sdk.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down
Loading