subconscious-cli 6.2.1 → 6.3.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.
package/README.md CHANGED
@@ -60,7 +60,6 @@ so OpenCode, Claude Code, Codex, and DeepSeek Harness can launch immediately.
60
60
  Persistent editor and Pi integrations are installed per agent:
61
61
 
62
62
  ```bash
63
- subc cursor install
64
63
  subc copilot install
65
64
  subc pi
66
65
  subc <agent> uninstall
@@ -90,7 +89,7 @@ profile; they do not authenticate, install, configure, or launch anything:
90
89
  subc opencode help
91
90
  subc claude help
92
91
  subc codex help
93
- subc cursor help
92
+ subc copilot help
94
93
  subc config help
95
94
  subc login help
96
95
  ```
@@ -127,7 +126,6 @@ subc.cmd claude
127
126
  subc codex
128
127
  subc opencode
129
128
  subc dsh
130
- subc cursor install
131
129
  subc copilot install
132
130
  subc pi
133
131
  subc config edit notepad
@@ -155,7 +153,7 @@ and the native TUI use Windows paths and processes. Profile editing defaults to
155
153
  Notepad, honors `VISUAL`/`EDITOR`, and supports `code`/`code-insiders` with `--wait`.
156
154
  The existing profile location remains `%USERPROFILE%\.subconscious`.
157
155
 
158
- Cursor, Copilot, Codex, and Pi setup use native JSON merges and Node hooks. Other
156
+ Copilot, Codex, and Pi setup use native JSON merges and Node hooks. Other
159
157
  providers and hooks are preserved; malformed configuration is left unchanged.
160
158
  Hook credentials are stored in `subconscious-windows.json` within the agent's
161
159
  user directory; treat that file as a secret. Copilot's model provider still uses
@@ -176,9 +174,8 @@ The packaged integrations live in `bin/runbook`.
176
174
  | `subc opencode` | Launch OpenCode with the runbook provider, client header, and context/output limits |
177
175
  | `subc claude` | Launch Claude Code with the runbook environment, context limits, subagent limits, and OTEL usage reporting |
178
176
  | `subc codex` | Launch Codex with the runbook provider, temporary model catalog, and surgically merged compaction hooks |
179
- | `subc cursor install` | Install/update Cursor conversation and compaction hooks (Cursor desktop app and IDE only — the Cursor CLI is not supported) |
180
- | `subc copilot install` | Install/update the VS Code custom endpoint and Copilot hooks |
181
177
  | `subc pi` | Refresh the Pi provider from the live catalog, then launch |
178
+ | `subc copilot install` | Install/update the VS Code custom endpoint and Copilot hooks |
182
179
  | `subc dsh` | Launch the DeepSeek Harness Web UI with a temporary provider populated from the live catalog |
183
180
 
184
181
  ## Headless runs
@@ -323,8 +320,8 @@ characters. Tool payloads, tool results, system prompts, and hidden reasoning
323
320
  are not copied. Source transcripts remain owned by their original harness and
324
321
  are never rewritten.
325
322
 
326
- Cursor and GitHub Copilot sessions are not listed because their IDE-owned
327
- conversation stores do not expose a stable local resume interface. A session
323
+ GitHub Copilot sessions are not listed because its IDE-owned
324
+ conversation store does not expose a stable local resume interface. A session
328
325
  whose local transcript is missing remains available for native resume but does
329
326
  not offer cross-harness destinations.
330
327
 
@@ -346,7 +343,6 @@ Persistent writes are merge/unmerge only. They never replace a user's
346
343
  provider list. Remove Subconscious files with the matching uninstall command:
347
344
 
348
345
  ```bash
349
- subc cursor uninstall
350
346
  subc copilot uninstall
351
347
  subc pi uninstall
352
348
  subc codex uninstall
@@ -362,14 +358,13 @@ Inspect a persistent integration with `subc <agent> status`. A one-off
362
358
  `--api-key` passed to install takes precedence for that command but is not
363
359
  saved to the selected profile.
364
360
 
365
- Cursor still requires enabling its OpenAI API Key Override in Cursor Settings.
366
361
  Copilot requires entering the custom endpoint key once through VS Code's
367
362
  Manage Language Models UI. The install scripts print the relevant next steps.
368
363
  Its Custom Endpoint provider uses the Messages API at `/v1/messages` by
369
364
  default; rerun `subc copilot install` to migrate an older Chat Completions entry.
370
365
 
371
- The runbook scripts require Bash. Cursor, Copilot, Pi, and Codex hook merge
372
- also require `jq`; Cursor and Copilot also require `curl`.
366
+ The runbook scripts require Bash. Copilot, Pi, and Codex hook merge
367
+ also require `jq`. Copilot and Codex hooks also require `curl`.
373
368
 
374
369
  `subc dsh` defaults to the DeepSeek Harness Web UI. It injects an ephemeral
375
370
  Cordis overlay containing the active gateway URL, selected model, token limits,
@@ -434,7 +429,7 @@ subc -p staging config \
434
429
  --model subconscious/glm-5.3-marathon
435
430
 
436
431
  subc -p staging claude
437
- subc -p staging cursor install
432
+ subc -p staging copilot install
438
433
  ```
439
434
 
440
435
  You can also select one with `SUBC_PROFILE=staging`. Explicit shell variables
@@ -468,7 +463,7 @@ export SUBCONSCIOUS_MODEL=subconscious/glm-5.3-marathon
468
463
  ```
469
464
 
470
465
  Every launch and install fetches the same live catalog without caching. Codex,
471
- OpenCode, Pi, Copilot, Cursor, and DeepSeek Harness receive the complete model
466
+ OpenCode, Pi, Copilot, and DeepSeek Harness receive the complete model
472
467
  list. The selected profile model is listed first only while the gateway still
473
468
  advertises it.
474
469
  OpenCode rebuilds its isolated runtime provider on every `subc opencode` launch,
@@ -481,10 +476,7 @@ custom model option. The CLI remaps those aliases to the live catalog, then
481
476
  replaces the built-in `/model` lineup (`modelPicker.replaceBuiltInOptions`) and
482
477
  allowlists only those models (`availableModels`) so Anthropic Fable and
483
478
  ungranted slugs do not appear. Any other model can still be selected with
484
- `subc claude --model MODEL`. Cursor requires adding
485
- the printed model IDs in its OpenAI API Key Override settings. Use the printed
486
- `/v1` Base URL in Cursor Settings; the profile itself stores the gateway origin
487
- so correlation hooks can post to `/v1/agent-hooks`.
479
+ `subc claude --model MODEL`.
488
480
 
489
481
  The default gateway is `https://api.subconscious.dev`. Profiles containing
490
482
  the former exact default (`https://api.subconscious.dev`) migrate automatically;
@@ -1,6 +1,7 @@
1
+ import path from 'node:path';
1
2
  import { agentInputs, agentKeyName } from './agent-data.js';
2
3
  import { openCodeConfigFromEnv } from './opencode-provider.js';
3
- import { isUnsetSetting } from './profiles.js';
4
+ import { configDir, isUnsetSetting } from './profiles.js';
4
5
 
5
6
  // One pass, so a substituted value (a prompt, a settings document) is never
6
7
  // scanned again for placeholders.
@@ -8,6 +9,9 @@ const PLACEHOLDER = /\$\{([A-Z_][A-Z0-9_]*)\}(\/)?|\{([A-Za-z][A-Za-z0-9]*)\}/g;
8
9
  const present = (value) =>
9
10
  value !== undefined && value !== null && value !== '';
10
11
 
12
+ // CLAUDE_CODE_ISOLATE values that stop Claude Code config isolation.
13
+ const ISOLATE_OFF = new Set(['off', '0', 'false', 'no']);
14
+
11
15
  function tokensFor(agent, env, extra) {
12
16
  const baseUrl = String(env.GATEWAY_URL ?? '').replace(/\/+$/, '');
13
17
  const keyName = agentKeyName(agent);
@@ -19,6 +23,26 @@ function tokensFor(agent, env, extra) {
19
23
  // Left for the runbook, which writes the file and substitutes its path.
20
24
  tempFile: '{tempFile}',
21
25
  opencodeConfig: () => JSON.stringify(openCodeConfigFromEnv(env)),
26
+ // Claude Code config isolation: subc sessions keep their Claude config
27
+ // (settings, sessions, plugins, credentials) under the subc config dir
28
+ // instead of ~/.claude, so session state cannot bleed into a plain
29
+ // `claude` installation. Empty when CLAUDE_CODE_ISOLATE opts out.
30
+ claudeConfigDir: () => {
31
+ const setting = String(env.CLAUDE_CODE_ISOLATE ?? '')
32
+ .trim()
33
+ .toLowerCase();
34
+ // Opted out: the user's own folder, as plain `claude` would use it.
35
+ if (ISOLATE_OFF.has(setting)) {
36
+ return String(
37
+ env.SUBC_CLAUDE_CONFIG_SOURCE || env.CLAUDE_CONFIG_DIR || '',
38
+ ).trim();
39
+ }
40
+ const base = String(env.SUBC_CONFIG_DIR ?? '').trim() || configDir();
41
+ // A relative base would put the config, MCP tokens included, in the
42
+ // current directory; use ~/.claude unchanged instead.
43
+ if (!path.isAbsolute(base)) return '';
44
+ return path.join(base, 'claude-code');
45
+ },
22
46
  ...extra,
23
47
  };
24
48
  }
@@ -116,15 +140,24 @@ export function resolveInputs(
116
140
  return resolved;
117
141
  }
118
142
 
119
- /** Set each env entry: the first non-empty override, otherwise its value. */
143
+ /**
144
+ * Set each env entry: the first non-empty override, otherwise its value.
145
+ * An entry marked `optional` is unset when its value renders empty, so a
146
+ * template can drop a variable instead of exporting an empty string.
147
+ */
120
148
  export function resolveAgentEnv(agent, env) {
121
149
  const resolved = { ...env };
122
150
  for (const entry of agent.env) {
123
151
  const override = (entry.override || [])
124
152
  .map((name) => resolved[name])
125
153
  .find(present);
126
- resolved[entry.name] =
127
- override !== undefined ? override : render(entry.value, agent, resolved);
154
+ if (override !== undefined) {
155
+ resolved[entry.name] = override;
156
+ continue;
157
+ }
158
+ const value = render(entry.value, agent, resolved);
159
+ if (entry.optional && !present(value)) delete resolved[entry.name];
160
+ else resolved[entry.name] = value;
128
161
  }
129
162
  return resolved;
130
163
  }
package/bin/agents.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import { launchPlan, resolveInputs } from './agent-launch.js';
20
20
  import { planCommand, runCommand, scriptCommand } from './agent-spawn.js';
21
21
  import { getApiKey } from './auth.js';
22
+ import { prepareClaudeLaunch } from './claude-config.js';
22
23
  import {
23
24
  applyClaudePickerCatalog,
24
25
  CLAUDE_PICKER_ENV_DESCRIPTION,
@@ -545,5 +546,22 @@ async function launchAgent(agent, argv, profile, headless) {
545
546
  // The headless endpoint is the one endpoint for every harness, Claude too.
546
547
  const launchEnv = headless ? { ...env, CLAUDE_GATEWAY_URL: '' } : env;
547
548
  const plan = launchPlan(agent, { env: launchEnv, ...headlessParts(rest) });
549
+ if (agent.id === CLAUDE_CODE) {
550
+ const {
551
+ warnings,
552
+ refused,
553
+ env: sourceEnv,
554
+ } = prepareClaudeLaunch(agent, plan.env, launchEnv);
555
+ const color = refused ? c.red : c.yellow;
556
+ for (const warning of warnings) {
557
+ console.error(` ${color}${warning}${c.reset}`);
558
+ }
559
+ if (refused) {
560
+ process.exitCode = 1;
561
+ return 1;
562
+ }
563
+ const prepared = { ...plan, env: { ...plan.env, ...sourceEnv } };
564
+ return runCommand(planCommand(agent, prepared, binDir));
565
+ }
548
566
  return runCommand(planCommand(agent, plan, binDir));
549
567
  }
package/bin/auth.js CHANGED
@@ -277,7 +277,7 @@ export async function loginCommand(_argv = [], options = {}) {
277
277
  }
278
278
  console.log(` ${c.dim}Runbook profile: ${profile.path}${c.reset}`);
279
279
  console.log(
280
- ` ${c.dim}Launch a terminal agent with ${c.reset}${c.cyan}subc claude${c.reset}${c.dim}, or install editor hooks with ${c.reset}${c.cyan}subc cursor install${c.reset}${c.dim}.${c.reset}`,
280
+ ` ${c.dim}Launch a terminal agent with ${c.reset}${c.cyan}subc claude${c.reset}${c.dim}, or set up VS Code Copilot with ${c.reset}${c.cyan}subc copilot install${c.reset}${c.dim}.${c.reset}`,
281
281
  );
282
282
  console.log(
283
283
  ` ${c.dim}Pi needs ${c.reset}${c.cyan}subc pi install${c.reset}${c.dim} first. List profiles with ${c.reset}${c.cyan}subc config${c.reset}${c.dim}.${c.reset}`,
@@ -0,0 +1,58 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+
3
+ // Each launch rebuilds the subc copies of settings.json and the MCP servers
4
+ // in .claude.json from the user's own files, so anything a subc session
5
+ // changed in those copies is replaced. These helpers find what a session
6
+ // changed, by comparing the copies with what the last launch wrote, so the
7
+ // launch can save it aside and name it instead of losing it silently.
8
+
9
+ const isObject = (value) =>
10
+ value !== null && typeof value === 'object' && !Array.isArray(value);
11
+
12
+ /**
13
+ * Keys of `current` that a session added or changed since `written`.
14
+ * Removed keys are ignored: Claude Code drops keys on its own.
15
+ *
16
+ * @param {Record<string, unknown>} written what the last launch wrote
17
+ * @param {Record<string, unknown>} current what the copy holds now
18
+ * @param {string[]} [ignored] keys subc keeps across launches
19
+ * @returns {string[]}
20
+ */
21
+ export function changedKeys(written, current, ignored = []) {
22
+ if (!isObject(written) || !isObject(current)) return [];
23
+ return Object.keys(current).filter(
24
+ (key) =>
25
+ !ignored.includes(key) && !isDeepStrictEqual(current[key], written[key]),
26
+ );
27
+ }
28
+
29
+ /** The MCP servers of a .claude.json: user scope and each project. */
30
+ export function mcpServersOf(state) {
31
+ const projects = isObject(state?.projects) ? state.projects : {};
32
+ return {
33
+ mcpServers: isObject(state?.mcpServers) ? state.mcpServers : {},
34
+ projects: Object.fromEntries(
35
+ Object.entries(projects)
36
+ .filter(([, project]) => isObject(project?.mcpServers))
37
+ .map(([name, project]) => [name, project.mcpServers]),
38
+ ),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * MCP server names a session added or changed since `written`, as
44
+ * `name` for user scope and `name (project)` for a project.
45
+ *
46
+ * @param {ReturnType<typeof mcpServersOf>} written
47
+ * @param {ReturnType<typeof mcpServersOf>} current
48
+ * @returns {string[]}
49
+ */
50
+ export function changedMcpServers(written, current) {
51
+ const changed = changedKeys(written.mcpServers, current.mcpServers);
52
+ for (const [project, servers] of Object.entries(current.projects)) {
53
+ for (const name of changedKeys(written.projects[project] ?? {}, servers)) {
54
+ changed.push(`${name} (${project})`);
55
+ }
56
+ }
57
+ return changed;
58
+ }
@@ -0,0 +1,103 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ // Claude Code finds an enabled plugin that installed_plugins.json does not
6
+ // list (a marketplace plugin pinned to a git SHA, for example) by its place
7
+ // in its own cache: <config dir>/plugins/cache/<marketplace>/<plugin>/<version>.
8
+ // The subc config dir never links that cache, so such a plugin, and the MCP
9
+ // servers it ships, would not load. Each missing version is copied once. A
10
+ // copy, unlike a link, cannot carry a cache sweep or orphan marker made in a
11
+ // subc session back into ~/.claude.
12
+
13
+ // A marketplace or plugin name used as one path segment.
14
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
15
+
16
+ /** `[plugin, marketplace]` of an enabledPlugins key, or `undefined`. */
17
+ function parsePluginId(id) {
18
+ const at = id.lastIndexOf('@');
19
+ if (at <= 0) return undefined;
20
+ const plugin = id.slice(0, at);
21
+ const marketplace = id.slice(at + 1);
22
+ return SAFE_NAME.test(plugin) && SAFE_NAME.test(marketplace)
23
+ ? [plugin, marketplace]
24
+ : undefined;
25
+ }
26
+
27
+ function copyVersion(from, to) {
28
+ // A copy is renamed into place only when complete, so a launch stopped
29
+ // half way never leaves a partial plugin that later launches skip.
30
+ const temp = `${to}.${crypto.randomBytes(6).toString('hex')}.tmp`;
31
+ try {
32
+ fs.cpSync(from, temp, { recursive: true, verbatimSymlinks: true });
33
+ fs.renameSync(temp, to);
34
+ } finally {
35
+ fs.rmSync(temp, { recursive: true, force: true });
36
+ }
37
+ }
38
+
39
+ /** Whether `base` or any folder from it down through `parts` is a link. */
40
+ function linkOnPath(base, parts) {
41
+ let current = base;
42
+ for (const part of ['', ...parts]) {
43
+ current = part ? path.join(current, part) : current;
44
+ try {
45
+ if (fs.lstatSync(current).isSymbolicLink()) return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+
53
+ function directories(dir) {
54
+ try {
55
+ return fs
56
+ .readdirSync(dir, { withFileTypes: true })
57
+ .filter((entry) => entry.isDirectory() && SAFE_NAME.test(entry.name))
58
+ .map((entry) => entry.name);
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Copy into `dir`'s plugin cache every cached version of each enabled plugin
66
+ * that the plugin index does not list, unless a copy is already there.
67
+ *
68
+ * @param {{ dir: string, source: string, enabledPlugins: unknown,
69
+ * indexed: unknown, warnings: string[] }} options
70
+ */
71
+ export function copyUnindexedPlugins({
72
+ dir,
73
+ source,
74
+ enabledPlugins,
75
+ indexed,
76
+ warnings,
77
+ }) {
78
+ if (!enabledPlugins || typeof enabledPlugins !== 'object') return;
79
+ const listed =
80
+ indexed && typeof indexed === 'object' ? Object.keys(indexed) : [];
81
+ for (const [id, enabled] of Object.entries(enabledPlugins)) {
82
+ if (enabled !== true || listed.includes(id)) continue;
83
+ const parsed = parsePluginId(id);
84
+ if (!parsed) continue;
85
+ const [plugin, marketplace] = parsed;
86
+ const from = path.join(source, 'plugins', 'cache', marketplace, plugin);
87
+ const to = path.join(dir, 'plugins', 'cache', marketplace, plugin);
88
+ // A link here could carry the copy into the user's own cache.
89
+ if (linkOnPath(path.join(dir, 'plugins'), ['cache', marketplace, plugin])) {
90
+ warnings.push(`Skipped plugin ${id} for subc: its cache path is a link.`);
91
+ continue;
92
+ }
93
+ for (const version of directories(from)) {
94
+ if (fs.existsSync(path.join(to, version))) continue;
95
+ try {
96
+ fs.mkdirSync(to, { recursive: true });
97
+ copyVersion(path.join(from, version), path.join(to, version));
98
+ } catch (error) {
99
+ warnings.push(`Could not copy plugin ${id} for subc: ${error.message}`);
100
+ }
101
+ }
102
+ }
103
+ }