subconscious-cli 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -1,16 +1,91 @@
1
- # @subconscious/cli
1
+ # subconscious-cli
2
2
 
3
- Authenticate with Subconscious from your terminal. One command opens your browser, signs you in (or up), and saves your API key locally.
3
+ Log in to Subconscious from your terminal, then launch your favorite coding
4
+ agent against your hosted Subconscious model — no per-agent config required.
4
5
 
5
6
  ## Quick start
6
7
 
7
8
  ```bash
8
- npx @subconscious/cli login
9
+ npx subconscious-cli login # sign in, saves your API key
10
+ npx subconscious-cli claude-code # launch Claude Code on Subconscious
9
11
  ```
10
12
 
11
- That's it. Your API key is saved to `~/.subcon/config.json` and ready to use.
13
+ Installed globally it's just `subconscious <command>`:
12
14
 
13
- ## How it works
15
+ ```bash
16
+ npm install -g subconscious-cli
17
+ subconscious login
18
+ subconscious open-code
19
+ ```
20
+
21
+ ## Launching coding agents
22
+
23
+ `subconscious <agent>` resolves your saved API key, injects the env vars that
24
+ point the agent at Subconscious, and exec's the real CLI. Nothing is written to
25
+ the agent's own config — the provider is passed in-memory for that run only.
26
+
27
+ Install commands are **OS-specific** — the CLI picks the right one for your
28
+ platform automatically. The table below shows the macOS/Linux command; on
29
+ Windows the equivalent native installer is used instead.
30
+
31
+ | Command | Launches | Requires (install, macOS/Linux) |
32
+ | -------------------------- | ----------- | ------------------------------------------------------- |
33
+ | `subconscious claude-code` | Claude Code | `curl -fsSL https://claude.ai/install.sh \| bash` |
34
+ | `subconscious open-code` | OpenCode | `npm i -g opencode-ai` |
35
+ | `subconscious aider` | Aider | `python3 -m pip install aider-install && aider-install` |
36
+ | `subconscious codex` | Codex CLI | `npm i -g @openai/codex` |
37
+
38
+ Claude Code uses its **native installer** (the `curl`/`irm` script above), with
39
+ `npm i -g @anthropic-ai/claude-code` kept as an automatic fallback if the native
40
+ installer fails.
41
+
42
+ If the underlying agent isn't installed and you're in an interactive terminal,
43
+ the CLI offers to install it for you (just press Enter), runs the right
44
+ installer for your OS (trying the fallback if the primary fails), and launches
45
+ it once the install succeeds. In a non-interactive context (CI) it instead
46
+ prints the exact install command (and any fallback) and exits without running
47
+ anything.
48
+
49
+ Freshly-installed binaries (e.g. Aider and Claude Code land in `~/.local/bin`,
50
+ npm globals in the npm prefix) often aren't on your current shell's `PATH` yet.
51
+ The CLI looks in those common locations and launches the agent anyway. If it
52
+ still can't find the binary right after install, it tells you to **open a new
53
+ terminal** (or add the printed dir to `PATH`) and re-run the command — the
54
+ install itself succeeded.
55
+
56
+ Anything after the agent name is forwarded straight to it:
57
+
58
+ ```bash
59
+ subconscious claude-code --resume
60
+ subconscious codex exec "write a test"
61
+ ```
62
+
63
+ ### Choosing a model
64
+
65
+ Defaults to `subconscious/tim-qwen3.6-27b`. Override per run with `--model`, or
66
+ set `SUBCONSCIOUS_MODEL` in your environment:
67
+
68
+ ```bash
69
+ subconscious open-code --model subconscious/tim-qwen3.6-27b
70
+ export SUBCONSCIOUS_MODEL=subconscious/tim-qwen3.6-27b
71
+ ```
72
+
73
+ ### Pointing at a different endpoint
74
+
75
+ By default the CLI targets `https://api.subconscious.dev`. Override the base URL
76
+ per run (or for a whole session) with `SUBCONSCIOUS_BASE_URL` — it flows to both
77
+ the Anthropic-style base and the OpenAI-compatible `/v1` base:
78
+
79
+ ```bash
80
+ SUBCONSCIOUS_BASE_URL=http://localhost:9999 subconscious claude-code
81
+ ```
82
+
83
+ ## Auth commands
84
+
85
+ ### `login`
86
+
87
+ Opens your browser to sign in (or create an account). After authentication, your
88
+ API key is automatically generated and saved.
14
89
 
15
90
  ```
16
91
  Terminal Browser
@@ -19,41 +94,24 @@ Terminal Browser
19
94
  │ 2. Open browser ───────────────►│
20
95
  │ │ 3. Sign in / sign up via Clerk
21
96
  │ │ 4. API key auto-created
22
- │ 5. Receive key ◄────────────────│
97
+ │ 5. Receive key ◄────────────────│
23
98
  │ 6. Save to ~/.subcon/config.json│
24
99
  │ │ "You can close this tab"
25
100
  ✓ Logged in! │
26
101
  ```
27
102
 
28
- ## Commands
29
-
30
- ### `login`
31
-
32
- Opens your browser to sign in (or create an account). After authentication, your API key is automatically generated and saved.
33
-
34
- ```bash
35
- npx @subconscious/cli login
36
- ```
37
-
38
103
  ### `logout`
39
104
 
40
105
  Removes your saved API key.
41
106
 
42
- ```bash
43
- npx @subconscious/cli logout
44
- ```
45
-
46
107
  ### `whoami`
47
108
 
48
109
  Shows your current authentication status and which key is active.
49
110
 
50
- ```bash
51
- npx @subconscious/cli whoami
52
- ```
53
-
54
111
  ## Where keys are stored
55
112
 
56
- Keys are saved to `~/.subcon/config.json` with `600` permissions (owner-read-only). The file looks like:
113
+ Keys are saved to `~/.subcon/config.json` with `600` permissions
114
+ (owner-read-only). The file looks like:
57
115
 
58
116
  ```json
59
117
  {
@@ -61,13 +119,5 @@ Keys are saved to `~/.subcon/config.json` with `600` permissions (owner-read-onl
61
119
  }
62
120
  ```
63
121
 
64
- Environment variable `SUBCONSCIOUS_API_KEY` takes precedence over the config file.
65
-
66
- ## Global install
67
-
68
- If you prefer a persistent command:
69
-
70
- ```bash
71
- npm install -g @subconscious/cli
72
- subconscious login
73
- ```
122
+ Environment variable `SUBCONSCIOUS_API_KEY` takes precedence over the config
123
+ file — handy for CI or temporary overrides.
package/bin/agents.js ADDED
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Coding-agent launcher.
3
+ *
4
+ * `subconscious <agent>` resolves your saved API key, injects the env vars that
5
+ * point the agent at your hosted Subconscious model, and exec's the real CLI —
6
+ * nothing is written to the agent's own config.
7
+ *
8
+ * There is NO hardcoded agent data here: everything is read from
9
+ * `registry.generated.json` (shipped under `bin/`), which is generated from the
10
+ * single source of truth `agents/registry.json`. Run `pnpm generate` to update.
11
+ */
12
+
13
+ import { spawn, execFileSync } from 'node:child_process';
14
+ import { readFileSync } from 'node:fs';
15
+ import fs from 'node:fs/promises';
16
+ import { constants as fsConstants } from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import readline from 'node:readline';
20
+ import { c } from './colors.js';
21
+ import { getApiKey } from './auth.js';
22
+
23
+ // --- Registry (single source of truth, generated copy shipped in the package).
24
+ const registry = JSON.parse(
25
+ readFileSync(new URL('./registry.generated.json', import.meta.url), 'utf-8'),
26
+ );
27
+ const DEFAULTS = registry.defaults;
28
+
29
+ // --- Token substitution — same rules as scripts/lib/registry.js.
30
+ // Replaces {apiKey}, {model}, {baseUrl}, {baseUrlV1}. NEVER touches {env:...}.
31
+ const TOKENS = ['apiKey', 'model', 'baseUrl', 'baseUrlV1'];
32
+
33
+ function substituteString(str, ctx) {
34
+ let out = str;
35
+ for (const token of TOKENS) {
36
+ if (ctx[token] === undefined) continue;
37
+ out = out.split(`{${token}}`).join(ctx[token]);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function substitute(value, ctx) {
43
+ if (typeof value === 'string') return substituteString(value, ctx);
44
+ if (Array.isArray(value)) return value.map((v) => substitute(v, ctx));
45
+ if (value && typeof value === 'object') {
46
+ const keys = Object.keys(value);
47
+ if (keys.length === 1 && keys[0] === '$json') {
48
+ return JSON.stringify(substitute(value.$json, ctx));
49
+ }
50
+ const out = {};
51
+ for (const key of keys) out[substituteString(key, ctx)] = substitute(value[key], ctx);
52
+ return out;
53
+ }
54
+ return value;
55
+ }
56
+
57
+ /**
58
+ * Resolve the install command for the current OS from a per-OS install object.
59
+ * Falls back to the linux command, then any string value present, if the exact
60
+ * `process.platform` key is missing. Tolerates a legacy plain-string `install`.
61
+ * Returns `{ command, fallback }` where `fallback` may be undefined.
62
+ */
63
+ function resolveInstall(install) {
64
+ if (typeof install === 'string') return { command: install, fallback: undefined };
65
+ if (!install || typeof install !== 'object') return { command: undefined, fallback: undefined };
66
+ const command =
67
+ install[process.platform] ||
68
+ install.linux ||
69
+ Object.values(install).find((v) => typeof v === 'string');
70
+ return { command, fallback: install.fallback };
71
+ }
72
+
73
+ // --- Build the in-memory registry + alias index.
74
+ // Each agent gets a resolved per-OS `install` (string) plus optional
75
+ // `installFallback`, while keeping the original per-OS object available.
76
+ const AGENTS = registry.agents.map((agent) => {
77
+ const { command, fallback } = resolveInstall(agent.install);
78
+ return { ...agent, install: command, installFallback: fallback };
79
+ });
80
+ const BY_ALIAS = new Map();
81
+ for (const agent of AGENTS) {
82
+ BY_ALIAS.set(agent.id, agent);
83
+ for (const alias of agent.aliases || []) BY_ALIAS.set(alias, agent);
84
+ }
85
+
86
+ export function resolveAgent(name) {
87
+ return BY_ALIAS.get(name) ?? null;
88
+ }
89
+
90
+ export function agentList() {
91
+ return AGENTS.map((a) => ({ name: a.name, alias: a.id }));
92
+ }
93
+
94
+ /**
95
+ * Resolve the substitution context for a launch:
96
+ * model — --model flag → SUBCONSCIOUS_MODEL → registry default
97
+ * baseUrl — SUBCONSCIOUS_BASE_URL → registry default
98
+ * baseUrlV1 — `${baseUrl}/v1` (so an override flows to both)
99
+ */
100
+ function buildContext(apiKey, model) {
101
+ const baseUrl = process.env.SUBCONSCIOUS_BASE_URL?.trim() || DEFAULTS.baseUrl;
102
+ return { apiKey, model, baseUrl, baseUrlV1: `${baseUrl}/v1` };
103
+ }
104
+
105
+ /**
106
+ * Pull a `--model <value>` / `--model=<value>` flag out of the passthrough
107
+ * args (so it sets the Subconscious model rather than reaching the agent).
108
+ * Falls back to SUBCONSCIOUS_MODEL, then the registry default.
109
+ */
110
+ function extractModel(argv) {
111
+ let model = process.env.SUBCONSCIOUS_MODEL?.trim() || DEFAULTS.model;
112
+ const rest = [];
113
+ for (let i = 0; i < argv.length; i++) {
114
+ const a = argv[i];
115
+ if (a === '--model') {
116
+ const v = argv[i + 1];
117
+ if (v && !v.startsWith('-')) {
118
+ model = v;
119
+ i++;
120
+ }
121
+ continue;
122
+ }
123
+ if (a.startsWith('--model=')) {
124
+ model = a.slice('--model='.length);
125
+ continue;
126
+ }
127
+ rest.push(a);
128
+ }
129
+ return { model, rest };
130
+ }
131
+
132
+ /**
133
+ * Common locations a freshly-installed coding-agent binary lands in but which
134
+ * are often NOT on the current process's PATH (e.g. aider/claude install into
135
+ * `~/.local/bin`; npm globals into the npm prefix bin). Best-effort, deduped.
136
+ */
137
+ function candidateBinDirs() {
138
+ const home = os.homedir();
139
+ const dirs = [];
140
+
141
+ if (process.platform === 'win32') {
142
+ if (process.env.APPDATA) dirs.push(path.join(process.env.APPDATA, 'npm'));
143
+ if (process.env.USERPROFILE) {
144
+ dirs.push(path.join(process.env.USERPROFILE, '.local', 'bin'));
145
+ }
146
+ if (home) dirs.push(path.join(home, '.local', 'bin'));
147
+ } else {
148
+ dirs.push(path.join(home, '.local', 'bin'));
149
+ dirs.push('/opt/homebrew/bin');
150
+ dirs.push('/usr/local/bin');
151
+ }
152
+
153
+ // npm global bin (best-effort — npm may be absent).
154
+ try {
155
+ const prefix = execFileSync('npm', ['prefix', '-g'], {
156
+ encoding: 'utf-8',
157
+ stdio: ['ignore', 'pipe', 'ignore'],
158
+ }).trim();
159
+ if (prefix) {
160
+ dirs.push(process.platform === 'win32' ? prefix : path.join(prefix, 'bin'));
161
+ }
162
+ } catch {
163
+ // npm not available — skip.
164
+ }
165
+
166
+ // Dedupe, drop empties.
167
+ return [...new Set(dirs.filter(Boolean))];
168
+ }
169
+
170
+ /** Executable extensions to probe (Windows uses PATHEXT). */
171
+ function binExts() {
172
+ return process.platform === 'win32'
173
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
174
+ : [''];
175
+ }
176
+
177
+ /**
178
+ * Resolve `bin` against PATH plus the candidate bin dirs. Returns the directory
179
+ * containing the executable if found, otherwise null. Searching the candidate
180
+ * dirs lets us find binaries installed this session that aren't on PATH yet.
181
+ */
182
+ async function resolveBinPath(bin) {
183
+ const pathDirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
184
+ const dirs = [...pathDirs, ...candidateBinDirs()];
185
+ const exts = binExts();
186
+ for (const dir of dirs) {
187
+ for (const ext of exts) {
188
+ const candidate = path.join(dir, bin + ext);
189
+ try {
190
+ await fs.access(candidate, fsConstants.F_OK);
191
+ return dir;
192
+ } catch {
193
+ // keep scanning
194
+ }
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ /**
201
+ * Build a PATH string with `extraDirs` prepended (deduped against PATH).
202
+ * Returns the augmented PATH value for use in a child env.
203
+ */
204
+ function augmentPath(extraDirs) {
205
+ const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
206
+ const seen = new Set(current);
207
+ const prepend = extraDirs.filter((d) => d && !seen.has(d));
208
+ return [...prepend, ...current].join(path.delimiter);
209
+ }
210
+
211
+ /** Ask a yes/no question on the TTY. Empty answer counts as yes. */
212
+ function askYesNo(question) {
213
+ return new Promise((resolve) => {
214
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
215
+ rl.question(question, (answer) => {
216
+ rl.close();
217
+ const a = answer.trim().toLowerCase();
218
+ resolve(a === '' || a === 'y' || a === 'yes');
219
+ });
220
+ });
221
+ }
222
+
223
+ /** Run the agent's install command (may contain `&&`, so shell:true). */
224
+ function runInstaller(install) {
225
+ return new Promise((resolve) => {
226
+ const child = spawn(install, { shell: true, stdio: 'inherit' });
227
+ child.on('error', () => resolve(false));
228
+ child.on('exit', (code) => resolve(code === 0));
229
+ });
230
+ }
231
+
232
+ /** Print the resolved install command (plus any fallback) for an agent. */
233
+ function printInstallCommands(agent) {
234
+ console.error(` ${c.cyan}${agent.install}${c.reset}`);
235
+ if (agent.installFallback) {
236
+ console.error(` ${c.dim}or, as a fallback:${c.reset}`);
237
+ console.error(` ${c.cyan}${agent.installFallback}${c.reset}`);
238
+ }
239
+ console.error('');
240
+ }
241
+
242
+ /**
243
+ * Ensure the agent's binary is resolvable. If missing:
244
+ * - interactive TTY: offer to run the per-OS installer (with fallback), then
245
+ * re-resolve against PATH + candidate dirs.
246
+ * - non-interactive: print the resolved install command (+ fallback) and
247
+ * exit 127 without running anything.
248
+ *
249
+ * Returns the directory containing the bin (to prepend to the child's PATH) on
250
+ * success. May exit the process on failure or when manual action is needed.
251
+ */
252
+ async function ensureInstalled(agent) {
253
+ const existing = await resolveBinPath(agent.bin);
254
+ if (existing) return existing;
255
+
256
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
257
+
258
+ if (!interactive) {
259
+ console.error(
260
+ `\n ${c.red}${agent.name} isn't installed${c.reset} ${c.dim}(\`${agent.bin}\` not found on PATH).${c.reset}`,
261
+ );
262
+ console.error(` Install it with:\n`);
263
+ printInstallCommands(agent);
264
+ process.exit(127);
265
+ }
266
+
267
+ console.error(`\n ${c.bold}${agent.name}${c.reset} isn't installed.`);
268
+ const ok = await askYesNo(` Install it now? ${c.dim}[Y/n]${c.reset} `);
269
+ if (!ok) {
270
+ console.error(`\n No problem. Install it yourself with:\n`);
271
+ printInstallCommands(agent);
272
+ process.exit(127);
273
+ }
274
+
275
+ console.error(`\n ${c.dim}Running ${c.reset}${c.cyan}${agent.install}${c.reset}\n`);
276
+ let installed = await runInstaller(agent.install);
277
+
278
+ // Primary failed and a fallback exists — try it once.
279
+ if (!installed && agent.installFallback) {
280
+ console.error(
281
+ `\n ${c.dim}That didn't work. Trying the fallback: ${c.reset}${c.cyan}${agent.installFallback}${c.reset}\n`,
282
+ );
283
+ installed = await runInstaller(agent.installFallback);
284
+ }
285
+
286
+ if (!installed) {
287
+ console.error(`\n ${c.red}Install failed.${c.reset} Try it manually:\n`);
288
+ printInstallCommands(agent);
289
+ process.exit(127);
290
+ }
291
+
292
+ // PATH hardening: the freshly-installed binary is often not on the current
293
+ // process's PATH. Re-resolve against PATH + candidate dirs.
294
+ const found = await resolveBinPath(agent.bin);
295
+ if (found) return found;
296
+
297
+ console.error(
298
+ `\n ${c.dim}Installed ${agent.name}, but it isn't on this shell's PATH yet. ` +
299
+ `Open a new terminal (or add a bin dir to PATH) and re-run \`subconscious ${agent.id}\`.${c.reset}\n`,
300
+ );
301
+ process.exit(0);
302
+ }
303
+
304
+ /**
305
+ * Launch a coding agent against Subconscious. `argv` is everything after the
306
+ * agent name; unknown flags pass straight through to the underlying CLI.
307
+ */
308
+ export async function runAgent(agent, argv) {
309
+ const { model, rest } = extractModel(argv);
310
+
311
+ const auth = await getApiKey();
312
+ if (!auth) {
313
+ console.error(`\n ${c.red}Not logged in.${c.reset}`);
314
+ console.error(
315
+ ` Run ${c.cyan}subconscious login${c.reset} (or set ${c.dim}SUBCONSCIOUS_API_KEY${c.reset}) first.\n`,
316
+ );
317
+ process.exit(1);
318
+ }
319
+
320
+ const binDir = await ensureInstalled(agent);
321
+
322
+ const ctx = buildContext(auth.key, model);
323
+ const launch = substituteString(agent.launch, ctx);
324
+ const [bin, ...launchArgs] = launch.split(' ').filter(Boolean);
325
+ const envMap = substitute(agent.env, ctx);
326
+
327
+ // Prepend the resolved bin dir + candidate dirs to the child's PATH so the
328
+ // agent (and any subprocess it spawns) resolves correctly this session, even
329
+ // if it was installed into a dir not yet on the parent shell's PATH.
330
+ const extraDirs = [binDir, ...candidateBinDirs()].filter(Boolean);
331
+ const env = { ...process.env, ...envMap, PATH: augmentPath(extraDirs) };
332
+ const args = [...launchArgs, ...rest];
333
+
334
+ console.log(
335
+ ` ${c.dim}Launching ${c.reset}${c.bold}${agent.name}${c.reset} ${c.dim}on Subconscious ${c.reset}${c.dim}(${model})${c.reset}\n`,
336
+ );
337
+
338
+ const child = spawn(bin, args, { stdio: 'inherit', env });
339
+
340
+ child.on('error', (err) => {
341
+ if (err.code === 'ENOENT') {
342
+ console.error(
343
+ `\n ${c.red}Could not launch \`${bin}\`.${c.reset} Install it with:\n`,
344
+ );
345
+ printInstallCommands(agent);
346
+ process.exit(127);
347
+ }
348
+ console.error(`\n ${c.red}${err.message}${c.reset}\n`);
349
+ process.exit(1);
350
+ });
351
+
352
+ // Mirror the child's exit status so callers/scripts see the real result.
353
+ child.on('exit', (code, signal) => {
354
+ if (signal) {
355
+ process.kill(process.pid, signal);
356
+ return;
357
+ }
358
+ process.exit(code ?? 0);
359
+ });
360
+ }