Skip to content

Commit 66b366d

Browse files
MagnetonIOclaude
andcommitted
Phase 15: Phoenix migration + API hardening + CLI upgrade
Phase 2 of the architecture restructure. Three parallel teams: Team 2A — Phoenix migration: - Replaced Plug.Router with Phoenix.Router (pipelines, scoped routes) - Created Phoenix.Endpoint with CORS support - All 7 controllers converted to Phoenix.Controller pattern - Added Phoenix.PubSub for pipeline event broadcasting - Created SSE EventsController (GET /api/v1/pipeline/:id/events) - Pipeline broadcasts stage_complete/pipeline_complete events Team 2B — API hardening: - Replaced 130-line hand-rolled YAML parser with yaml_elixir - Created AgentOS.JobTracker GenServer (ETS-backed job status) - Added pagination to agent list (limit/offset params) - Created EscrowController (balance + set_balance endpoints) Team 2C — CLI upgrade: - Added ~/.agent-os/config.json persistent config - Added login/logout commands with token storage - Added audit <pipeline-id> command - Added contracts list command - Fixed docker-compose v1 → docker compose v2 - Added progress indicator (dots) for long-running commands - 13 new CLI tests 196 tests pass (149 Elixir + 47 CLI), zero warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 803eb82 commit 66b366d

13 files changed

Lines changed: 431 additions & 153 deletions

File tree

cli/src/commands/audit.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Audit commands — trail.
3+
*
4+
* agent-os audit <pipeline-id>
5+
*/
6+
7+
import * as http from '../http.js';
8+
import * as out from '../output.js';
9+
10+
/**
11+
* Show the audit trail for a pipeline run.
12+
* GET /api/v1/audit/:id
13+
* @param {string[]} args - Positional arguments (args[0] = pipeline id)
14+
* @param {object} opts - Global CLI options
15+
*/
16+
export async function trail(args, opts) {
17+
const id = args[0];
18+
if (!id) {
19+
out.error('Usage: agent-os audit <pipeline-id>');
20+
process.exit(1);
21+
}
22+
23+
const result = await http.get(`/api/v1/audit/${id}`, opts);
24+
25+
if (opts.json) {
26+
out.json(result);
27+
return;
28+
}
29+
30+
const entries = result.data || result.entries || result;
31+
if (Array.isArray(entries) && entries.length > 0) {
32+
out.info(`Audit trail for ${id}:`);
33+
const headers = ['TIMESTAMP', 'STAGE', 'STATUS', 'DETAIL'];
34+
const rows = entries.map((e) => [
35+
e.timestamp || e.ts || '-',
36+
e.stage || e.name || '-',
37+
e.status || '-',
38+
(e.detail || e.message || '-').slice(0, 60),
39+
]);
40+
out.table(headers, rows);
41+
} else if (Array.isArray(entries)) {
42+
out.info(`No audit entries found for ${id}.`);
43+
} else {
44+
out.json(entries);
45+
}
46+
}

cli/src/commands/auth.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Auth commands — login, logout.
3+
*/
4+
5+
import * as config from '../config.js';
6+
import * as out from '../output.js';
7+
import { createInterface } from 'node:readline';
8+
9+
/**
10+
* Prompt the user for an API key via stdin.
11+
* @returns {Promise<string>} The entered API key
12+
*/
13+
function promptForKey() {
14+
return new Promise((resolve) => {
15+
const rl = createInterface({ input: process.stdin, output: process.stdout });
16+
rl.question('API key: ', (answer) => {
17+
rl.close();
18+
resolve(answer.trim());
19+
});
20+
});
21+
}
22+
23+
/**
24+
* Login — store API key and host in config.
25+
* @param {string[]} args - Positional arguments
26+
* @param {object} opts - Global CLI options
27+
*/
28+
export async function login(args, opts) {
29+
const apiKey = opts.apiKey || await promptForKey();
30+
if (!apiKey) {
31+
out.error('API key is required. Pass --api-key <key> or enter interactively.');
32+
process.exit(1);
33+
}
34+
config.set('apiKey', apiKey);
35+
if (opts.host && opts.host !== 'http://localhost:4000') {
36+
config.set('host', opts.host);
37+
}
38+
out.success('Logged in. Config saved to ~/.agent-os/config.json');
39+
}
40+
41+
/**
42+
* Logout — clear stored API key.
43+
* @param {string[]} args - Positional arguments
44+
* @param {object} opts - Global CLI options
45+
*/
46+
export async function logout(args, opts) {
47+
config.set('apiKey', null);
48+
out.success('Logged out.');
49+
}

cli/src/commands/contracts.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Contracts commands — list.
3+
*
4+
* agent-os contracts list
5+
*/
6+
7+
import * as http from '../http.js';
8+
import * as out from '../output.js';
9+
10+
/**
11+
* List available contracts.
12+
* GET /api/v1/contracts
13+
* @param {string[]} args - Positional arguments
14+
* @param {object} opts - Global CLI options
15+
*/
16+
export async function listContracts(args, opts) {
17+
const result = await http.get('/api/v1/contracts', opts);
18+
19+
if (opts.json) {
20+
out.json(result);
21+
return;
22+
}
23+
24+
const contracts = result.contracts || result.data || result;
25+
if (Array.isArray(contracts) && contracts.length > 0) {
26+
out.info('Available contracts:');
27+
for (const name of contracts) {
28+
out.info(` - ${typeof name === 'string' ? name : name.name || JSON.stringify(name)}`);
29+
}
30+
} else {
31+
out.info('No contracts found.');
32+
}
33+
}

cli/src/commands/deploy.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export async function docker(args, opts) {
3838
run('docker', ['build', '-t', 'agent-os', '.'], root);
3939

4040
out.info('Starting containers...');
41-
run('docker-compose', ['up', '-d'], root);
41+
run('docker', ['compose', 'up', '-d'], root);
4242

4343
out.info('Checking health...');
4444
try {

cli/src/commands/run.js

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,17 @@ export async function runSingle(args, opts) {
3434
if (opts.model) body.model = opts.model;
3535
if (opts.provider) body.provider = opts.provider;
3636

37-
const result = await http.post('/api/v1/run', body, opts);
37+
const spinner = setInterval(() => process.stdout.write('.'), 1000);
38+
let result;
39+
try {
40+
result = await http.post('/api/v1/run', body, opts);
41+
clearInterval(spinner);
42+
console.log(''); // newline after dots
43+
} catch (e) {
44+
clearInterval(spinner);
45+
console.log('');
46+
throw e;
47+
}
3848

3949
out.success('Pipeline completed!');
4050

@@ -68,10 +78,20 @@ export async function runPipeline(args, opts) {
6878
out.info(`Running pipeline '${opts.contract}'...`);
6979
out.info(`Topic: ${opts.topic}`);
7080

71-
const result = await http.post('/api/v1/pipeline/run', {
72-
contract: opts.contract,
73-
topic: opts.topic,
74-
}, opts);
81+
const spinner = setInterval(() => process.stdout.write('.'), 1000);
82+
let result;
83+
try {
84+
result = await http.post('/api/v1/pipeline/run', {
85+
contract: opts.contract,
86+
topic: opts.topic,
87+
}, opts);
88+
clearInterval(spinner);
89+
console.log(''); // newline after dots
90+
} catch (e) {
91+
clearInterval(spinner);
92+
console.log('');
93+
throw e;
94+
}
7595

7696
out.success('Pipeline completed!');
7797

cli/src/config.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Persistent config file support for Agent-OS CLI.
3+
* Stores config in ~/.agent-os/config.json.
4+
*/
5+
6+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7+
import { homedir } from 'node:os';
8+
import { join } from 'node:path';
9+
10+
const CONFIG_DIR = join(homedir(), '.agent-os');
11+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
12+
13+
export function load() {
14+
try {
15+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
16+
} catch {
17+
return {};
18+
}
19+
}
20+
21+
export function save(config) {
22+
mkdirSync(CONFIG_DIR, { recursive: true });
23+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
24+
}
25+
26+
export function get(key) {
27+
return load()[key];
28+
}
29+
30+
export function set(key, value) {
31+
const config = load();
32+
config[key] = value;
33+
save(config);
34+
}
35+
36+
// Exported for testing
37+
export { CONFIG_DIR, CONFIG_FILE };

cli/src/http.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,27 @@
33
* Uses Node.js built-in fetch() (Node 18+).
44
*/
55

6+
import * as config from './config.js';
7+
68
/**
79
* Resolve the API host URL.
8-
* Priority: opts.host → AGENT_OS_HOST env → default.
10+
* Priority: opts.host → AGENT_OS_HOST env → config → default.
911
* @param {object} opts - Global CLI options
1012
* @returns {string} Base URL without trailing slash
1113
*/
1214
function resolveHost(opts = {}) {
13-
const host = opts.host || process.env.AGENT_OS_HOST || 'http://localhost:4000';
15+
const host = opts.host || process.env.AGENT_OS_HOST || config.get('host') || 'http://localhost:4000';
1416
return host.replace(/\/+$/, '');
1517
}
1618

1719
/**
1820
* Resolve the API key.
19-
* Priority: opts.apiKey → AGENT_OS_API_KEY env → undefined.
21+
* Priority: opts.apiKey → AGENT_OS_API_KEY env → config → undefined.
2022
* @param {object} opts - Global CLI options
2123
* @returns {string|undefined}
2224
*/
2325
function resolveApiKey(opts = {}) {
24-
return opts.apiKey || process.env.AGENT_OS_API_KEY || undefined;
26+
return opts.apiKey || process.env.AGENT_OS_API_KEY || config.get('apiKey') || undefined;
2527
}
2628

2729
/**

cli/src/main.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import * as jobCmd from './commands/job.js';
1313
import * as memoryCmd from './commands/memory.js';
1414
import * as deployCmd from './commands/deploy.js';
1515
import * as runCmd from './commands/run.js';
16+
import * as authCmd from './commands/auth.js';
17+
import * as auditCmd from './commands/audit.js';
18+
import * as contractsCmd from './commands/contracts.js';
1619
import * as http from './http.js';
1720
import * as out from './output.js';
1821

@@ -23,6 +26,9 @@ USAGE
2326
agent-os <command> <subcommand> [options]
2427
2528
COMMANDS
29+
login Store API key [--api-key <key>] [--host <url>]
30+
logout Clear stored API key
31+
2632
agent create Create an agent --type <type> --name <name> [--oversight <level>]
2733
agent list List agents
2834
agent start Start an agent <id> --job '<json>'
@@ -38,6 +44,9 @@ COMMANDS
3844
run <type> Run a single agent --topic <topic> [--model <m>] [--provider <p>]
3945
run pipeline Run a pipeline --contract <name> --topic <topic>
4046
47+
contracts list List available contracts
48+
audit Show audit trail <pipeline-id>
49+
4150
deploy docker Deploy with Docker
4251
deploy fly Deploy to Fly.io [--region <region>] [--app <name>]
4352
@@ -148,6 +157,29 @@ export async function main(argv) {
148157
out.info(`agent-os v${getVersion()}`);
149158
break;
150159

160+
case 'login':
161+
await authCmd.login(args, opts);
162+
break;
163+
164+
case 'logout':
165+
await authCmd.logout(args, opts);
166+
break;
167+
168+
case 'audit':
169+
// subcommand becomes the first positional arg for audit
170+
await auditCmd.trail(subcommand ? [subcommand, ...args] : args, opts);
171+
break;
172+
173+
case 'contracts':
174+
switch (subcommand) {
175+
case 'list': await contractsCmd.listContracts(args, opts); break;
176+
default:
177+
out.error(`Unknown contracts command: ${subcommand}`);
178+
out.info('Available: list');
179+
process.exit(1);
180+
}
181+
break;
182+
151183
case 'health': {
152184
const result = await http.get('/api/v1/health', opts);
153185
if (opts.json) {

0 commit comments

Comments
 (0)