@commonlyai/cli 0.1.0

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.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * codex adapter — wraps the local `codex` CLI as a Commonly agent.
3
+ *
4
+ * Contract: ADR-005 §Adapter pattern.
5
+ *
6
+ * Tested against codex-cli 0.125.0. The argv shape diverges from the
7
+ * ADR-005 §Adapters-shipped-in-v1 table (which was written against an
8
+ * earlier codex-acp variant): modern codex uses `codex exec resume <id>`
9
+ * as a subcommand to continue a session, NOT a `--session <id>` flag.
10
+ * If a future codex bumps the surface again, this adapter is the single
11
+ * file to update.
12
+ *
13
+ * Output shape:
14
+ * - stdout = JSONL events: `{"type":"thread.started","thread_id":"<uuid>"}`,
15
+ * `{"type":"turn.started"}`, `{"type":"turn.failed","error":{...}}`, etc.
16
+ * - stderr = Rust tracing logs (timestamped), unrelated to model output.
17
+ *
18
+ * We capture session id from the `thread.started` event and read the agent's
19
+ * final reply from the file written via `-o <FILE>` (codex's
20
+ * `--output-last-message` short alias) — cleaner than parsing every
21
+ * event-type variant the model can emit.
22
+ *
23
+ * Memory preamble: if ctx.memoryLongTerm is non-empty, the adapter prepends
24
+ * it to the prompt as a system-context preamble (§Memory bridge), matching
25
+ * the claude adapter's shape so the run loop's per-event memory plumbing
26
+ * works identically across drivers.
27
+ *
28
+ * Purity (§Load-bearing invariants #1): input = argv + env + prompt;
29
+ * output = text + session id. No direct network, no direct CAP calls.
30
+ *
31
+ * Test seam: `ctx._spawnImpl` is the sanctioned way for any adapter in this
32
+ * codebase to swap `child_process.spawn` out for a mock. Same convention as
33
+ * claude.js so future adapters stay uniform.
34
+ *
35
+ * Timeout caveat: SIGTERM at `timeoutMs`, then wait for `close`. If codex
36
+ * ignores SIGTERM the promise never resolves — same caveat as claude.js;
37
+ * SIGKILL escalation is a post-v1 concern (ADR-005 §Spawning semantics).
38
+ */
39
+
40
+ import { spawn as childSpawn, spawnSync } from 'child_process';
41
+ import { mkdtemp, readFile, rm } from 'fs/promises';
42
+ import { tmpdir } from 'os';
43
+ import { join } from 'path';
44
+
45
+ // Default timeout for a single codex spawn (exec mode).
46
+ //
47
+ // 2026-05-20 smoke surface: Cody's cycle-0 research task ran 15+ web
48
+ // searches across openai/codex release notes (a legitimate research
49
+ // workload) and was SIGTERM'd at exactly 300_000ms — the adapter killed
50
+ // her before she could synthesize the final reply. 5 minutes is too
51
+ // tight for codex `exec` mode under real tool-use; the bigger model
52
+ // (gpt-5.4) + web.run + reasoning is just slower than a chat turn.
53
+ // Bumping to 15 minutes gives multi-step research / repo investigation
54
+ // room to finish without disabling the cap (pathological hangs still
55
+ // SIGTERM).
56
+ //
57
+ // Operators can override via the COMMONLY_AGENT_RUN_TIMEOUT_MS env var
58
+ // without rebuilding (caller-supplied `ctx.timeoutMs` still wins).
59
+ // Invalid / non-positive values fall through to the default rather
60
+ // than disabling the timeout entirely.
61
+ const DEFAULT_TIMEOUT_MS = (() => {
62
+ const fallback = 15 * 60 * 1000;
63
+ const raw = process.env.COMMONLY_AGENT_RUN_TIMEOUT_MS;
64
+ if (!raw) return fallback;
65
+ const parsed = Number(raw);
66
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
67
+ })();
68
+
69
+ const buildPrompt = (prompt, memoryLongTerm) => {
70
+ if (!memoryLongTerm) return prompt;
71
+ return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
72
+ };
73
+
74
+ // Build the argv after the `codex` binary. Resume vs new turn is a
75
+ // subcommand-level distinction in modern codex, not an option flag — keep
76
+ // that detail isolated here so the spawn path stays linear.
77
+ const buildArgs = ({ sessionId, prompt, outputFile }) => {
78
+ // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
79
+ // bubblewrap (bwrap) sandbox + approval prompts. bwrap needs CAP_SYS_ADMIN
80
+ // or unprivileged user-namespaces — neither available to standard k8s
81
+ // containers without elevated securityContext. Without this flag, every
82
+ // shell tool call (git, ls, pwd, ...) fails with "bwrap: Failed to make /
83
+ // slave: Permission denied" inside cloud-codex pods (verified 2026-05-15).
84
+ // The pod is the security perimeter — agent identity is isolated, workspace
85
+ // is PVC-scoped, no host mounts. bwrap inside the pod is redundant.
86
+ // On a laptop wrapper run (sam-local-codex etc.) the same flag is fine: the
87
+ // operator's machine is already the security boundary they signed up for
88
+ // when running `commonly agent run`.
89
+ const common = [
90
+ '--json',
91
+ '--skip-git-repo-check',
92
+ '--dangerously-bypass-approvals-and-sandbox',
93
+ '-o',
94
+ outputFile,
95
+ ];
96
+ if (sessionId) {
97
+ // Place <sessionId> immediately after the `exec resume` subcommand so a
98
+ // future codex parser change can't accidentally consume it as the value
99
+ // of a preceding flag (e.g. -o). Codex's CLI signature is documented as
100
+ // `codex exec resume [OPTIONS] [SESSION_ID] [PROMPT]`, and this ordering
101
+ // matches that intent unambiguously regardless of clap version.
102
+ return ['exec', 'resume', sessionId, ...common, prompt];
103
+ }
104
+ return ['exec', ...common, prompt];
105
+ };
106
+
107
+ // Stream-parse JSONL stdout. Codex emits one event per line; partial lines
108
+ // across chunk boundaries are buffered and flushed on the next newline.
109
+ const makeEventParser = () => {
110
+ let buffer = '';
111
+ let threadId = null;
112
+ let turnFailedMessage = null;
113
+
114
+ const consume = (chunk) => {
115
+ buffer += chunk.toString();
116
+ let nl;
117
+ // eslint-disable-next-line no-cond-assign
118
+ while ((nl = buffer.indexOf('\n')) !== -1) {
119
+ const line = buffer.slice(0, nl).trim();
120
+ buffer = buffer.slice(nl + 1);
121
+ if (!line) continue;
122
+ try {
123
+ const evt = JSON.parse(line);
124
+ if (evt.type === 'thread.started' && evt.thread_id && !threadId) {
125
+ threadId = evt.thread_id;
126
+ } else if (evt.type === 'turn.failed') {
127
+ // Capture the most recent — codex may emit multiple before exit.
128
+ turnFailedMessage = evt.error?.message || 'codex turn failed';
129
+ }
130
+ } catch {
131
+ // Non-JSON lines on stdout are unexpected but not fatal — skip.
132
+ }
133
+ }
134
+ };
135
+
136
+ return {
137
+ consume,
138
+ get threadId() { return threadId; },
139
+ get turnFailedMessage() { return turnFailedMessage; },
140
+ };
141
+ };
142
+
143
+ const runCodex = ({ args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
144
+ // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
145
+ // defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
146
+ // `Reading additional input from stdin...` because it sees an open pipe
147
+ // and waits for input that never arrives. Interactive runs are fine because
148
+ // codex detects a TTY and uses the argv prompt directly. Setting stdin to
149
+ // `'ignore'` gives codex /dev/null → immediate EOF → it falls back to the
150
+ // argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
151
+ const proc = spawnImpl('codex', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
152
+ let stderr = '';
153
+ let timedOut = false;
154
+ const events = makeEventParser();
155
+
156
+ const timer = setTimeout(() => {
157
+ timedOut = true;
158
+ proc.kill('SIGTERM');
159
+ }, timeoutMs);
160
+
161
+ proc.stdout?.on('data', (chunk) => events.consume(chunk));
162
+ proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
163
+ proc.on('error', (err) => {
164
+ clearTimeout(timer);
165
+ reject(err);
166
+ });
167
+ proc.on('close', (code) => {
168
+ clearTimeout(timer);
169
+ if (timedOut) return reject(new Error(`codex timed out after ${timeoutMs}ms`));
170
+ if (events.turnFailedMessage) {
171
+ // Surface the model-side failure message verbatim — the run loop posts
172
+ // it as the agent's reply so the user sees what went wrong rather than
173
+ // a generic "non-zero exit" error.
174
+ return reject(new Error(`codex turn failed: ${events.turnFailedMessage}`));
175
+ }
176
+ if (code !== 0) {
177
+ return reject(new Error(`codex exited with code ${code}: ${stderr.trim().slice(0, 500)}`));
178
+ }
179
+ resolve({ threadId: events.threadId });
180
+ });
181
+ });
182
+
183
+ export default {
184
+ name: 'codex',
185
+ // See claude.js for the rationale — identity-bearing runtime tag persisted
186
+ // to AgentInstallation.config.runtime (paired with host:'byo') so a CLI-
187
+ // attached Codex agent and a cloud-hosted Codex agent share the same
188
+ // `runtimeType` and differ only on `host`.
189
+ runtimeType: 'codex',
190
+
191
+ async detect() {
192
+ try {
193
+ const res = spawnSync('codex', ['--version'], { encoding: 'utf8' });
194
+ if (res.error || res.status !== 0) return null;
195
+ // `codex --version` prints e.g. "codex-cli 0.125.0" — last token is
196
+ // the version. Defensive against future format tweaks: if no
197
+ // dotted-numeric token is found, fall back to the raw stdout.
198
+ const stdout = (res.stdout || '').trim();
199
+ const versionMatch = stdout.match(/(\d+\.\d+(?:\.\d+)?)/);
200
+ const version = versionMatch ? versionMatch[1] : (stdout || 'unknown');
201
+ const where = spawnSync('which', ['codex'], { encoding: 'utf8' });
202
+ const path = where.status === 0 ? (where.stdout || '').trim() || 'codex' : 'codex';
203
+ return { path, version };
204
+ } catch {
205
+ return null;
206
+ }
207
+ },
208
+
209
+ async spawn(prompt, ctx = {}) {
210
+ const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
211
+
212
+ // Per-spawn temp dir for --output-last-message. Cleaned up in `finally`
213
+ // so a crash in the middle of the spawn doesn't leak files in $TMPDIR.
214
+ const dir = await mkdtemp(join(tmpdir(), 'commonly-codex-'));
215
+ const outputFile = join(dir, 'last-message.txt');
216
+
217
+ try {
218
+ const args = buildArgs({
219
+ sessionId: ctx.sessionId || null,
220
+ prompt: fullPrompt,
221
+ outputFile,
222
+ });
223
+
224
+ const { threadId } = await runCodex({
225
+ args,
226
+ cwd: ctx.cwd,
227
+ env: ctx.env,
228
+ timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
229
+ spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
230
+ });
231
+
232
+ let text = '';
233
+ try {
234
+ text = (await readFile(outputFile, 'utf8')).trim();
235
+ } catch {
236
+ // codex didn't write the file — empty turn or stream parsing edge.
237
+ // Caller (run loop) treats empty text as a failed spawn and re-delivers.
238
+ text = '';
239
+ }
240
+
241
+ // newSessionId precedence: thread_id from this turn (always emitted on
242
+ // a new session, often re-emitted on resume) > the persisted id we
243
+ // came in with > null. The wrapper persists whichever non-null value
244
+ // we return so subsequent turns hit `codex exec resume <id>`.
245
+ const newSessionId = threadId || ctx.sessionId || null;
246
+ return { text, newSessionId };
247
+ } finally {
248
+ try { await rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
249
+ }
250
+ },
251
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Adapter registry — ADR-005 §Adapter pattern.
3
+ *
4
+ * `attach <adapter>` and `run <name>` both resolve an adapter by its string
5
+ * name through this single registry. Adding a new CLI (claude, codex, cursor,
6
+ * gemini, …) is a one-file PR that exports a default adapter and adds an
7
+ * entry below.
8
+ */
9
+
10
+ import stub from './stub.js';
11
+ import claude from './claude.js';
12
+ import codex from './codex.js';
13
+
14
+ const ADAPTERS = {
15
+ [stub.name]: stub,
16
+ [claude.name]: claude,
17
+ [codex.name]: codex,
18
+ };
19
+
20
+ export const listAdapterNames = () => Object.keys(ADAPTERS);
21
+
22
+ export const getAdapter = (name) => ADAPTERS[name] || null;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Stub adapter — the no-op reference implementation of the ADR-005 adapter
3
+ * contract. Used by `commonly agent attach stub` and the Phase-1a test
4
+ * harness so the run loop can be reviewed end-to-end without a real CLI
5
+ * on PATH.
6
+ *
7
+ * Contract (ADR-005 §Adapter pattern):
8
+ * detect(): Promise<{ path, version } | null>
9
+ * spawn(prompt, ctx): Promise<{ text, newSessionId?, memorySummary? }>
10
+ *
11
+ * The adapter MUST be pure: input (argv + env + prompt) → output (text + optional
12
+ * next session-id + optional memory summary). No direct network, no direct API
13
+ * calls, no hidden state. The run loop handles all Commonly-facing I/O.
14
+ */
15
+
16
+ export default {
17
+ name: 'stub',
18
+ async detect() {
19
+ return { path: '(builtin)', version: '0.0.0' };
20
+ },
21
+ async spawn(prompt, _ctx) {
22
+ return { text: `(stub) received: ${String(prompt ?? '').slice(0, 200)}` };
23
+ },
24
+ };
package/src/lib/api.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * CAP HTTP client — thin wrapper over fetch.
3
+ *
4
+ * Every method reads the active instance URL and token from config
5
+ * unless overridden. This is the only place that makes HTTP calls.
6
+ */
7
+
8
+ import { resolveInstanceUrl, getToken } from './config.js';
9
+
10
+ const headers = (token, extra = {}) => ({
11
+ 'Content-Type': 'application/json',
12
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
13
+ ...extra,
14
+ });
15
+
16
+ const handleResponse = async (res) => {
17
+ const text = await res.text();
18
+ let body;
19
+ try { body = JSON.parse(text); } catch { body = { message: text }; }
20
+ if (!res.ok) {
21
+ const msg = body?.error || body?.message || `HTTP ${res.status}`;
22
+ const err = new Error(msg);
23
+ err.status = res.status;
24
+ err.body = body;
25
+ throw err;
26
+ }
27
+ return body;
28
+ };
29
+
30
+ export const createClient = ({ instance = null, token = null } = {}) => {
31
+ const baseUrl = resolveInstanceUrl(instance);
32
+ const authToken = token || getToken(instance);
33
+
34
+ const get = (path, params = {}) => {
35
+ const url = new URL(`${baseUrl}${path}`);
36
+ Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v));
37
+ return fetch(url.toString(), { headers: headers(authToken) }).then(handleResponse);
38
+ };
39
+
40
+ const post = (path, body = {}) => fetch(`${baseUrl}${path}`, {
41
+ method: 'POST',
42
+ headers: headers(authToken),
43
+ body: JSON.stringify(body),
44
+ }).then(handleResponse);
45
+
46
+ const del = (path) => fetch(`${baseUrl}${path}`, {
47
+ method: 'DELETE',
48
+ headers: headers(authToken),
49
+ }).then(handleResponse);
50
+
51
+ return { get, post, del, baseUrl };
52
+ };
53
+
54
+ // Convenience: login doesn't need a token
55
+ export const login = async (instanceUrl, email, password) => {
56
+ const res = await fetch(`${instanceUrl}/api/auth/login`, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({ email, password }),
60
+ });
61
+ return handleResponse(res);
62
+ };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Config management — ~/.commonly/config.json
3
+ *
4
+ * Stores auth tokens and instance URLs across sessions.
5
+ * Multiple instances supported (default, local, custom).
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { homedir } from 'os';
11
+
12
+ const CONFIG_DIR = join(homedir(), '.commonly');
13
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
14
+
15
+ const DEFAULT_INSTANCE_URL = 'https://api.commonly.me';
16
+ const LOCAL_INSTANCE_URL = 'http://localhost:5000';
17
+
18
+ const read = () => {
19
+ if (!existsSync(CONFIG_FILE)) return { instances: {}, active: 'default' };
20
+ try {
21
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
22
+ } catch {
23
+ return { instances: {}, active: 'default' };
24
+ }
25
+ };
26
+
27
+ const write = (config) => {
28
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
29
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
30
+ };
31
+
32
+ export const getConfig = () => read();
33
+
34
+ const normalizeUrl = (value) => (value || '').replace(/\/$/, '').toLowerCase();
35
+
36
+ /**
37
+ * Resolve an instance identifier to the saved instance record.
38
+ *
39
+ * Accepts either form:
40
+ * - A saved key: 'dev', 'default', 'local'
41
+ * - A URL: 'https://api.commonly.me' (any case; trailing / ok)
42
+ * - null: falls back to config.active
43
+ *
44
+ * Historically `resolveInstanceUrl` treated the arg as a URL and `getToken`
45
+ * treated it as a key — so `--instance dev` and `--instance <url>` each
46
+ * worked for exactly one of URL-resolution or token-lookup and broke the
47
+ * other. Funneling both through this helper closes that asymmetry.
48
+ *
49
+ * URL-shaped inputs (http[s]://) go through URL resolution first so a
50
+ * hypothetical key named `"https-backup"` can't collide with a real URL.
51
+ * For an unknown URL (no saved match) we return the URL with a null token,
52
+ * which is needed for login bootstrap (there is no saved instance yet) and
53
+ * for unauthenticated probe calls.
54
+ */
55
+ export const resolveInstance = (identifier = null) => {
56
+ const config = read();
57
+
58
+ if (!identifier) {
59
+ const key = config.active || 'default';
60
+ const inst = config.instances[key];
61
+ return inst ? { key, ...inst } : null;
62
+ }
63
+
64
+ // URL-shaped inputs: resolve by URL first. Case-insensitive match so that
65
+ // `HTTPS://API-DEV.commonly.me/` finds a record saved as the lowercased
66
+ // `https://api.commonly.me`.
67
+ const looksLikeUrl = /^https?:\/\//i.test(identifier);
68
+ if (looksLikeUrl) {
69
+ const normalized = normalizeUrl(identifier);
70
+ const urlEntry = Object.entries(config.instances)
71
+ .find(([, v]) => normalizeUrl(v.url) === normalized);
72
+ if (urlEntry) {
73
+ const [key, value] = urlEntry;
74
+ return { key, ...value };
75
+ }
76
+ // Unknown URL — still usable for bootstrapping. Preserve the caller's
77
+ // original case in the returned URL (some servers are case-sensitive on
78
+ // paths; we only lowercase for match comparison above).
79
+ return { key: null, url: identifier.replace(/\/$/, ''), token: null };
80
+ }
81
+
82
+ // Key-shaped inputs: exact lookup only.
83
+ if (config.instances[identifier]) {
84
+ return { key: identifier, ...config.instances[identifier] };
85
+ }
86
+
87
+ // Unknown key — caller falls back to defaults.
88
+ return null;
89
+ };
90
+
91
+ // Alias kept for existing imports (login.js). Functionally identical to
92
+ // resolveInstance; callers using this name pre-date the refactor.
93
+ export const getActiveInstance = (instanceOverride = null) => resolveInstance(instanceOverride);
94
+
95
+ export const resolveInstanceUrl = (instanceArg = null) => {
96
+ const inst = resolveInstance(instanceArg);
97
+ if (inst?.url) return inst.url.replace(/\/$/, '');
98
+ return DEFAULT_INSTANCE_URL;
99
+ };
100
+
101
+ export const saveInstance = ({ key = 'default', url, token, userId, username }) => {
102
+ const config = read();
103
+ config.instances[key] = {
104
+ url: url.replace(/\/$/, ''),
105
+ token,
106
+ userId,
107
+ username,
108
+ savedAt: new Date().toISOString(),
109
+ };
110
+ config.active = key;
111
+ write(config);
112
+ };
113
+
114
+ export const setActive = (key) => {
115
+ const config = read();
116
+ if (!config.instances[key]) throw new Error(`Instance '${key}' not found in config`);
117
+ config.active = key;
118
+ write(config);
119
+ };
120
+
121
+ export const getToken = (instanceOverride = null) => {
122
+ // Env var takes precedence (CI, scripts)
123
+ if (process.env.COMMONLY_TOKEN) return process.env.COMMONLY_TOKEN;
124
+ const inst = resolveInstance(instanceOverride);
125
+ return inst?.token || null;
126
+ };
127
+
128
+ export const listInstances = () => {
129
+ const config = read();
130
+ return Object.entries(config.instances).map(([key, val]) => ({
131
+ key,
132
+ ...val,
133
+ active: key === config.active,
134
+ }));
135
+ };
136
+
137
+ export const LOCAL_URL = LOCAL_INSTANCE_URL;
138
+ export const DEFAULT_URL = DEFAULT_INSTANCE_URL;