subconscious-cli 6.2.0 → 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.
@@ -0,0 +1,624 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import {
6
+ changedKeys,
7
+ changedMcpServers,
8
+ mcpServersOf,
9
+ } from './claude-config-drift.js';
10
+ import { copyUnindexedPlugins } from './claude-config-plugins.js';
11
+ import { configDir } from './profiles.js';
12
+
13
+ // Prepares the isolated Claude Code config dir (CLAUDE_CONFIG_DIR) for a subc
14
+ // launch. Isolation exists to stop one leak: a /model pick inside subc landing
15
+ // in ~/.claude/settings.json and changing plain `claude`. Everything else the
16
+ // user set up in ~/.claude must keep working inside subc, so this dir links
17
+ // back to it and rebuilds the files Claude Code keeps per config dir.
18
+ //
19
+ // The one rule: preparing never writes, moves or deletes anything the user's
20
+ // own Claude Code reads. It writes only inside the isolated dir, and refuses a
21
+ // dir that resolves to ~/.claude, into it, or to a folder that holds it.
22
+
23
+ // Folders of ~/.claude that subc sessions share through a link: the user's
24
+ // rules, skills and hooks, session history so resume and `subc` session lists
25
+ // see one set of sessions, and the editor lock files so /ide finds the editor.
26
+ // A missing one is created in ~/.claude first, so something made inside subc
27
+ // (a first agent, a first skill) still lands in ~/.claude.
28
+ const SHARED_DIRS = [
29
+ 'rules',
30
+ 'agents',
31
+ 'commands',
32
+ 'skills',
33
+ 'output-styles',
34
+ 'hooks',
35
+ 'projects',
36
+ 'file-history',
37
+ 'todos',
38
+ 'plans',
39
+ 'ide',
40
+ ];
41
+
42
+ // Files of ~/.claude shared through a link when the user has them.
43
+ const SHARED_FILES = ['CLAUDE.md', 'history.jsonl', 'keybindings.json'];
44
+
45
+ /** Every entry of ~/.claude that subc sessions share through a link. */
46
+ export const SHARED_ENTRIES = Object.freeze([...SHARED_FILES, ...SHARED_DIRS]);
47
+
48
+ // What the last launch wrote into the subc copies, to find what a session
49
+ // changed there before the next rebuild replaces it.
50
+ const WRITTEN_FILE = '.subc-written.json';
51
+
52
+ // Plugin index files copied into the subc plugins dir on each launch. The
53
+ // install paths inside still point at ~/.claude/plugins/cache, which Claude
54
+ // Code treats as outside its own cache root: a subc session can load those
55
+ // plugins but never marks them orphaned or deletes them. The cache itself is
56
+ // never linked, so an uninstall or cache sweep in subc cannot reach it.
57
+ const PLUGIN_INDEX_FILES = [
58
+ 'installed_plugins.json',
59
+ 'known_marketplaces.json',
60
+ 'blocklist.json',
61
+ 'config.json',
62
+ ];
63
+
64
+ // User settings keys subc owns inside its sessions. `model` is the leak this
65
+ // isolation exists for; the subc value survives each rebuild.
66
+ const SUBC_OWNED_SETTINGS = ['model'];
67
+ // User settings keys dropped because they would replace the gateway credential.
68
+ const DROPPED_SETTINGS = ['apiKeyHelper'];
69
+
70
+ // ~/.claude.json keys copied in. Login state (oauthAccount, primaryApiKey)
71
+ // never is: subc sessions authenticate with the gateway token.
72
+ const COPIED_STATE = ['mcpServers', 'hasCompletedOnboarding', 'theme'];
73
+ const COPIED_PROJECT_STATE = [
74
+ 'mcpServers',
75
+ 'enabledMcpjsonServers',
76
+ 'disabledMcpjsonServers',
77
+ 'hasTrustDialogAccepted',
78
+ 'allowedTools',
79
+ ];
80
+
81
+ const isObject = (value) =>
82
+ value !== null && typeof value === 'object' && !Array.isArray(value);
83
+
84
+ function pick(source, keys) {
85
+ return Object.fromEntries(
86
+ keys
87
+ .filter((key) => Object.hasOwn(source, key))
88
+ .map((key) => [key, source[key]]),
89
+ );
90
+ }
91
+
92
+ /** Text of a regular file (links followed), `undefined` when missing. */
93
+ function readRegularFile(file) {
94
+ let stat;
95
+ try {
96
+ stat = fs.statSync(file);
97
+ } catch (error) {
98
+ if (error.code === 'ENOENT') return undefined;
99
+ throw error;
100
+ }
101
+ // A FIFO or device would block or stream forever.
102
+ if (!stat.isFile()) throw new Error('not a regular file');
103
+ return fs.readFileSync(file, 'utf8');
104
+ }
105
+
106
+ /** Parsed JSON object, `undefined` when missing; throws when invalid. */
107
+ function readJsonObject(file) {
108
+ const text = readRegularFile(file);
109
+ if (text === undefined) return undefined;
110
+ const value = JSON.parse(text);
111
+ if (!isObject(value)) throw new Error('not a JSON object');
112
+ return value;
113
+ }
114
+
115
+ // The temp name is unguessable and created exclusively, so a planted link at
116
+ // that name cannot redirect the write. The rename replaces a link at `file`
117
+ // with a regular file instead of writing through it.
118
+ function writeAtomic(file, text, mode = 0o644) {
119
+ const temp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
120
+ fs.writeFileSync(temp, text, { mode, flag: 'wx' });
121
+ try {
122
+ fs.renameSync(temp, file);
123
+ } catch (error) {
124
+ fs.rmSync(temp, { force: true });
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ const writeJsonAtomic = (file, value, mode) =>
130
+ writeAtomic(file, `${JSON.stringify(value, null, 2)}\n`, mode);
131
+
132
+ function lstatOrUndefined(file) {
133
+ try {
134
+ return fs.lstatSync(file);
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ /** The real path, resolving the nearest existing ancestor of a missing one. */
141
+ function realPath(file) {
142
+ const missing = [];
143
+ let current = path.resolve(file);
144
+ for (;;) {
145
+ try {
146
+ // .native returns the on-disk case, so ~/.CLAUDE resolves to ~/.claude.
147
+ return path.join(fs.realpathSync.native(current), ...missing);
148
+ } catch {
149
+ const parent = path.dirname(current);
150
+ if (parent === current) return path.resolve(file);
151
+ missing.unshift(path.basename(current));
152
+ current = parent;
153
+ }
154
+ }
155
+ }
156
+
157
+ /** Device and inode of `file` (links followed), `undefined` when missing. */
158
+ function identity(file) {
159
+ try {
160
+ const stat = fs.statSync(file);
161
+ return `${stat.dev}:${stat.ino}`;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ }
166
+
167
+ /** Identities of `file` and every folder above it. */
168
+ function selfAndAncestors(file) {
169
+ const ids = new Set();
170
+ let current = realPath(file);
171
+ for (;;) {
172
+ const id = identity(current);
173
+ if (id) ids.add(id);
174
+ const parent = path.dirname(current);
175
+ if (parent === current) return ids;
176
+ current = parent;
177
+ }
178
+ }
179
+
180
+ // Files of the user's own config that prepare must never replace, paired
181
+ // with the path in the subc dir that prepare writes.
182
+ // A plugins link from an earlier build is unlinked, never written through,
183
+ // before the plugin files are written, so its target needs no check.
184
+ function guardedWrites(dir, source, stateFile) {
185
+ const pluginsLink = lstatOrUndefined(path.join(dir, 'plugins'));
186
+ const plugins = pluginsLink?.isSymbolicLink() ? [] : PLUGIN_INDEX_FILES;
187
+ return [
188
+ [path.join(source, 'settings.json'), path.join(dir, 'settings.json')],
189
+ [stateFile, path.join(dir, '.claude.json')],
190
+ ...plugins.map((name) => [
191
+ path.join(source, 'plugins', name),
192
+ path.join(dir, 'plugins', name),
193
+ ]),
194
+ ];
195
+ }
196
+
197
+ /**
198
+ * Why `dir` must not be prepared, or `undefined` when it is safe. Compares
199
+ * file identities, not path text, so no spelling, case or link can alias the
200
+ * user's config into the dir.
201
+ */
202
+ function unsafeDirReason(dir, source, stateFile) {
203
+ if (!path.isAbsolute(dir)) return `${dir} is not an absolute path`;
204
+ const sourceId = identity(source);
205
+ if (sourceId && selfAndAncestors(dir).has(sourceId)) {
206
+ return `${dir} resolves into your Claude config ${source}`;
207
+ }
208
+ const dirIds = selfAndAncestors(dir);
209
+ for (const name of [...SHARED_ENTRIES, 'plugins']) {
210
+ const targetId = identity(path.join(source, name));
211
+ if (targetId && dirIds.has(targetId)) {
212
+ return `${dir} resolves into the folder behind ${path.join(source, name)}`;
213
+ }
214
+ }
215
+ const dirId = identity(dir);
216
+ const homeJson = identity(path.dirname(realPath(stateFile)));
217
+ if (dirId && (selfAndAncestors(source).has(dirId) || dirId === homeJson)) {
218
+ return `${dir} resolves to a folder that holds your Claude config`;
219
+ }
220
+ for (const [user, own] of guardedWrites(dir, source, stateFile)) {
221
+ const userId = identity(user);
222
+ // A link at `own` is replaced by the rename, never written through.
223
+ if (lstatOrUndefined(own)?.isSymbolicLink()) continue;
224
+ if (userId && userId === identity(own)) {
225
+ return `${own} is your own ${path.basename(user)}`;
226
+ }
227
+ }
228
+ return undefined;
229
+ }
230
+
231
+ function sameFile(a, b) {
232
+ try {
233
+ const left = fs.statSync(a);
234
+ const right = fs.statSync(b);
235
+ return left.dev === right.dev && left.ino === right.ino;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ function linkEntry(target, link) {
242
+ const isDir = fs.statSync(target).isDirectory();
243
+ try {
244
+ // Junctions need no privilege on Windows; elsewhere the type is ignored.
245
+ fs.symlinkSync(target, link, isDir ? 'junction' : 'file');
246
+ } catch (error) {
247
+ // Windows file symlinks need Developer Mode; a hard link shares the file.
248
+ if (isDir || process.platform !== 'win32') throw error;
249
+ fs.linkSync(target, link);
250
+ }
251
+ }
252
+
253
+ function shareEntry(dir, source, name, warnings) {
254
+ const target = path.join(source, name);
255
+ const link = path.join(dir, name);
256
+ let existing = lstatOrUndefined(link);
257
+ // A link from an earlier launch can point at another source. Removing a
258
+ // link never touches its target.
259
+ if (
260
+ existing?.isSymbolicLink() &&
261
+ path.resolve(dir, fs.readlinkSync(link)) !== path.resolve(target)
262
+ ) {
263
+ fs.unlinkSync(link);
264
+ existing = undefined;
265
+ }
266
+ if (SHARED_DIRS.includes(name) && !fs.existsSync(target)) {
267
+ fs.mkdirSync(target, { recursive: true });
268
+ }
269
+ if (!fs.existsSync(target)) return;
270
+ if (existing?.isSymbolicLink() || (existing && sameFile(link, target))) {
271
+ return;
272
+ }
273
+ if (existing) {
274
+ warnings.push(
275
+ `${link} is a real ${existing.isDirectory() ? 'directory' : 'file'}, so subc sessions do not share ${target}; move it aside to share it.`,
276
+ );
277
+ return;
278
+ }
279
+ linkEntry(target, link);
280
+ }
281
+
282
+ function shareEntries(dir, source, warnings) {
283
+ for (const name of SHARED_ENTRIES) {
284
+ try {
285
+ shareEntry(dir, source, name, warnings);
286
+ } catch (error) {
287
+ warnings.push(
288
+ `Could not share ${path.join(source, name)}: ${error.message}`,
289
+ );
290
+ }
291
+ }
292
+ }
293
+
294
+ function copyPluginIndex(dir, source, warnings) {
295
+ const own = path.join(dir, 'plugins');
296
+ const existing = lstatOrUndefined(own);
297
+ // An earlier build linked the whole folder. Removing a link never touches
298
+ // its target.
299
+ if (existing?.isSymbolicLink()) fs.unlinkSync(own);
300
+ else if (existing && !existing.isDirectory()) {
301
+ warnings.push(`${own} is not a directory, so plugins are not shared.`);
302
+ return;
303
+ }
304
+ fs.mkdirSync(own, { recursive: true });
305
+ for (const name of PLUGIN_INDEX_FILES) {
306
+ try {
307
+ const text = readRegularFile(path.join(source, 'plugins', name));
308
+ if (text !== undefined) writeAtomic(path.join(own, name), text, 0o600);
309
+ } catch (error) {
310
+ warnings.push(
311
+ `Could not copy ${path.join(source, 'plugins', name)}: ${error.message}`,
312
+ );
313
+ }
314
+ }
315
+ }
316
+
317
+ function rebuildSettings(dir, source, launchEnvNames, written, warnings) {
318
+ const file = path.join(dir, 'settings.json');
319
+ let user = {};
320
+ try {
321
+ user = readJsonObject(path.join(source, 'settings.json')) ?? {};
322
+ } catch (error) {
323
+ warnings.push(
324
+ `Ignored ${path.join(source, 'settings.json')} in subc sessions: ${error.message}`,
325
+ );
326
+ }
327
+ let current = {};
328
+ try {
329
+ current = readJsonObject(file) ?? {};
330
+ } catch {
331
+ current = {};
332
+ }
333
+ const settings = { ...user };
334
+ for (const key of [...SUBC_OWNED_SETTINGS, ...DROPPED_SETTINGS]) {
335
+ delete settings[key];
336
+ }
337
+ if (isObject(user.env)) {
338
+ const env = Object.fromEntries(
339
+ Object.entries(user.env).filter(([name]) => !launchEnvNames.has(name)),
340
+ );
341
+ if (Object.keys(env).length) settings.env = env;
342
+ else delete settings.env;
343
+ }
344
+ const lost = written
345
+ ? changedKeys(written, current, SUBC_OWNED_SETTINGS)
346
+ : [];
347
+ if (lost.length) {
348
+ const saved = path.join(dir, 'settings.discarded.json');
349
+ writeJsonAtomic(saved, current, 0o600);
350
+ warnings.push(
351
+ `Settings changed inside your last subc session are not kept: ${lost.join(', ')}. They are saved in ${saved}. To keep them, add them in plain claude.`,
352
+ );
353
+ }
354
+ const next = { ...settings, ...pick(current, SUBC_OWNED_SETTINGS) };
355
+ writeJsonAtomic(file, next);
356
+ return next;
357
+ }
358
+
359
+ function mergeProjects(current, user) {
360
+ const projects = isObject(current) ? { ...current } : {};
361
+ for (const [project, state] of Object.entries(user)) {
362
+ if (!isObject(state)) continue;
363
+ const copied = pick(state, COPIED_PROJECT_STATE);
364
+ if (!Object.keys(copied).length) continue;
365
+ const existing = isObject(projects[project]) ? projects[project] : {};
366
+ projects[project] = { ...existing, ...copied };
367
+ }
368
+ return projects;
369
+ }
370
+
371
+ function syncState(dir, stateFile, written, warnings) {
372
+ const file = path.join(dir, '.claude.json');
373
+ let user;
374
+ let current;
375
+ try {
376
+ user = readJsonObject(stateFile);
377
+ } catch (error) {
378
+ warnings.push(`Ignored ${stateFile} in subc sessions: ${error.message}`);
379
+ return undefined;
380
+ }
381
+ if (!user) return undefined;
382
+ try {
383
+ current = readJsonObject(file) ?? {};
384
+ } catch (error) {
385
+ warnings.push(`Left ${file} unchanged: ${error.message}`);
386
+ return undefined;
387
+ }
388
+ const lost = written ? changedMcpServers(written, mcpServersOf(current)) : [];
389
+ if (lost.length) {
390
+ const saved = path.join(dir, 'mcp.discarded.json');
391
+ writeJsonAtomic(saved, mcpServersOf(current), 0o600);
392
+ warnings.push(
393
+ `MCP servers added inside your last subc session are not kept: ${lost.join(', ')}. They are saved in ${saved}. To keep them, add them in plain claude.`,
394
+ );
395
+ }
396
+ const next = { ...current, ...pick(user, COPIED_STATE) };
397
+ if (isObject(user.projects)) {
398
+ next.projects = mergeProjects(current.projects, user.projects);
399
+ }
400
+ writeJsonAtomic(file, next, 0o600);
401
+ return mcpServersOf(next);
402
+ }
403
+
404
+ /** A JSON object, or `undefined` when missing or invalid; errors are reported elsewhere. */
405
+ function readQuietly(file) {
406
+ try {
407
+ return readJsonObject(file);
408
+ } catch {
409
+ return undefined;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Make sure `dir` is a subc dir: an earlier launch's marker, or a new or
415
+ * empty folder that is claimed now. Returns why a folder is not one, or
416
+ * `undefined`. A Claude config folder that subc did not make is never
417
+ * prepared, wherever it sits.
418
+ */
419
+ function claimDir(dir) {
420
+ const marker = path.join(dir, WRITTEN_FILE);
421
+ if (lstatOrUndefined(marker)) return undefined;
422
+ const existing = lstatOrUndefined(dir);
423
+ if (existing) {
424
+ const holdsConfig = ['settings.json', '.claude.json', 'projects'].some(
425
+ (name) => lstatOrUndefined(path.join(dir, name)),
426
+ );
427
+ if (holdsConfig) {
428
+ return `${dir} holds a Claude config that subc did not make. If it is an old subc folder, rename it and launch again.`;
429
+ }
430
+ }
431
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
432
+ writeJsonAtomic(marker, {}, 0o600);
433
+ return undefined;
434
+ }
435
+
436
+ function readWritten(dir) {
437
+ try {
438
+ return readJsonObject(path.join(dir, WRITTEN_FILE)) ?? {};
439
+ } catch {
440
+ return {};
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Make `dir` a Claude Code config dir that shares the user's ~/.claude setup
446
+ * and keeps only subc's own settings apart. Never writes into ~/.claude except
447
+ * to create ~/.claude/projects, and never deletes anything there. Problems
448
+ * come back as warnings; preparing never throws, so a launch always proceeds.
449
+ *
450
+ * `source` is the user's own Claude config folder (~/.claude, or their
451
+ * CLAUDE_CONFIG_DIR) and `stateFile` its .claude.json (~/.claude.json, or
452
+ * .claude.json inside their CLAUDE_CONFIG_DIR).
453
+ *
454
+ * @param {{ dir: string, home?: string, source?: string, stateFile?: string,
455
+ * launchEnvNames?: Iterable<string> }} options
456
+ * @returns {{ warnings: string[], refused: boolean }} `refused` means the
457
+ * dir aliases the user's own config; the launch must not run on it.
458
+ */
459
+ export function prepareClaudeConfigDir({
460
+ dir,
461
+ home = os.homedir(),
462
+ source = path.join(home, '.claude'),
463
+ stateFile = path.join(home, '.claude.json'),
464
+ launchEnvNames = [],
465
+ }) {
466
+ const warnings = [];
467
+ let unsafe = unsafeDirReason(dir, source, stateFile);
468
+ if (!unsafe) {
469
+ try {
470
+ unsafe = claimDir(dir);
471
+ } catch (error) {
472
+ unsafe = `could not create it: ${error.message}`;
473
+ }
474
+ }
475
+ if (unsafe) {
476
+ warnings.push(
477
+ `Refused to use ${dir} as the subc Claude config dir: ${unsafe}. Claude Code would write into your own config there. Point SUBC_CONFIG_DIR somewhere else, or run subc claude --no-isolate.`,
478
+ );
479
+ return { warnings, refused: true };
480
+ }
481
+ try {
482
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
483
+ shareEntries(dir, source, warnings);
484
+ copyPluginIndex(dir, source, warnings);
485
+ copyUnindexedPlugins({
486
+ dir,
487
+ source,
488
+ enabledPlugins: readQuietly(path.join(source, 'settings.json'))
489
+ ?.enabledPlugins,
490
+ indexed: readQuietly(
491
+ path.join(source, 'plugins', 'installed_plugins.json'),
492
+ )?.plugins,
493
+ warnings,
494
+ });
495
+ const written = readWritten(dir);
496
+ const settings = rebuildSettings(
497
+ dir,
498
+ source,
499
+ new Set(launchEnvNames),
500
+ written.settings,
501
+ warnings,
502
+ );
503
+ const mcp = syncState(dir, stateFile, written.mcp, warnings);
504
+ writeJsonAtomic(path.join(dir, WRITTEN_FILE), { settings, mcp }, 0o600);
505
+ } catch (error) {
506
+ warnings.push(
507
+ `Could not prepare the subc Claude config dir ${dir}: ${error.message}`,
508
+ );
509
+ }
510
+ return { warnings, refused: false };
511
+ }
512
+
513
+ // Carries the user's own config folder into a subc session, so a `subc
514
+ // claude` started inside it shares from that folder, not from the subc dir
515
+ // its shell now has as CLAUDE_CONFIG_DIR.
516
+ const SOURCE_ENV = 'SUBC_CLAUDE_CONFIG_SOURCE';
517
+
518
+ // CLAUDE_CODE_ISOLATE values that opt out; the launch runs on the user's
519
+ // own folder and nothing is prepared.
520
+ const ISOLATE_OFF = new Set(['off', '0', 'false', 'no']);
521
+
522
+ /**
523
+ * Whether `folder` is a Claude config folder Claude Code has used: a typo or
524
+ * a code repo would otherwise get shared folders created in it.
525
+ */
526
+ function looksLikeClaudeConfig(folder) {
527
+ return ['settings.json', '.claude.json', 'projects'].some((name) =>
528
+ fs.existsSync(path.join(folder, name)),
529
+ );
530
+ }
531
+
532
+ /**
533
+ * The user's own Claude config folder for a launch: the one carried from an
534
+ * outer subc session, else their CLAUDE_CONFIG_DIR, else ~/.claude. The subc
535
+ * dir itself is never a source.
536
+ */
537
+ function launchSource(inputEnv, dir, home, warnings) {
538
+ const fallback = {
539
+ source: path.join(home, '.claude'),
540
+ stateFile: path.join(home, '.claude.json'),
541
+ };
542
+ for (const name of [SOURCE_ENV, 'CLAUDE_CONFIG_DIR']) {
543
+ const value = inputEnv[name]?.trim();
544
+ if (!value) continue;
545
+ if (!path.isAbsolute(value)) {
546
+ warnings.push(
547
+ `${name}=${value} is not an absolute path, so subc sessions share ~/.claude instead.`,
548
+ );
549
+ return fallback;
550
+ }
551
+ if (identity(value) && identity(value) === identity(dir)) continue;
552
+ if (path.resolve(value) === path.resolve(dir)) continue;
553
+ if (!looksLikeClaudeConfig(value)) {
554
+ warnings.push(
555
+ `${name}=${value} is not a Claude config folder, so subc sessions share ~/.claude instead.`,
556
+ );
557
+ return fallback;
558
+ }
559
+ return { source: value, stateFile: path.join(value, '.claude.json') };
560
+ }
561
+ return fallback;
562
+ }
563
+
564
+ /**
565
+ * The Claude config folder plain `claude` uses: the source a subc launch
566
+ * shares from. `subc sessions` reads Claude sessions there.
567
+ *
568
+ * @param {{ env?: Record<string, string | undefined>, home?: string }} [options]
569
+ * @returns {string}
570
+ */
571
+ export function claudeSourceDir({
572
+ env = process.env,
573
+ home = os.homedir(),
574
+ } = {}) {
575
+ const subcBase =
576
+ env.SUBC_CONFIG_DIR?.trim() || path.join(home, '.subconscious');
577
+ return launchSource(env, path.join(subcBase, 'claude-code'), home, []).source;
578
+ }
579
+
580
+ /**
581
+ * Prepare the subc config dir for a planned Claude Code launch. It shares
582
+ * the folder the user's plain `claude` uses. An opted-out launch (no
583
+ * CLAUDE_CONFIG_DIR) is left alone.
584
+ *
585
+ * @param {{ env: { name: string }[] }} agent
586
+ * @param {Record<string, string | undefined>} launchEnv the planned launch env
587
+ * @param {Record<string, string | undefined>} inputEnv the env before planning
588
+ * @param {{ home?: string, subcDir?: string }} [options] `subcDir` is the
589
+ * subc config dir; it defaults to $SUBC_CONFIG_DIR/claude-code
590
+ * @returns {{ warnings: string[], refused: boolean,
591
+ * env: Record<string, string> }} `env` goes into the launch env
592
+ */
593
+ export function prepareClaudeLaunch(agent, launchEnv, inputEnv, options = {}) {
594
+ const none = { warnings: [], refused: false, env: {} };
595
+ const dir = launchEnv.CLAUDE_CONFIG_DIR?.trim();
596
+ if (!dir) return none;
597
+ const isolate = String(launchEnv.CLAUDE_CODE_ISOLATE ?? '')
598
+ .trim()
599
+ .toLowerCase();
600
+ // Opted out: CLAUDE_CONFIG_DIR is the user's own folder.
601
+ if (ISOLATE_OFF.has(isolate)) return none;
602
+ const home = options.home ?? os.homedir();
603
+ // Only the subc dir itself is ever prepared, whatever the launch env says.
604
+ const subcBase =
605
+ launchEnv.SUBC_CONFIG_DIR?.trim() ||
606
+ (options.home ? path.join(home, '.subconscious') : configDir());
607
+ const subcDir = options.subcDir ?? path.join(subcBase, 'claude-code');
608
+ if (path.resolve(dir) !== path.resolve(subcDir)) return none;
609
+ const warnings = [];
610
+ const { source, stateFile } = launchSource(inputEnv, dir, home, warnings);
611
+ const launchEnvNames = agent.env.map((entry) => entry.name);
612
+ const prepared = prepareClaudeConfigDir({
613
+ dir,
614
+ home,
615
+ source,
616
+ stateFile,
617
+ launchEnvNames,
618
+ });
619
+ return {
620
+ warnings: [...warnings, ...prepared.warnings],
621
+ refused: prepared.refused,
622
+ env: { [SOURCE_ENV]: source },
623
+ };
624
+ }
package/bin/cli.js CHANGED
@@ -115,8 +115,8 @@ ${agents}
115
115
  ${c.dim}$${c.reset} subc feedback --subject "Bug" --message "Playground won't load"
116
116
  ${c.dim}$${c.reset} subc claude
117
117
  ${c.dim}$${c.reset} subc claude help
118
- ${c.dim}$${c.reset} subc cursor install
119
- ${c.dim}$${c.reset} subc cursor uninstall
118
+ ${c.dim}$${c.reset} subc copilot install
119
+ ${c.dim}$${c.reset} subc copilot uninstall
120
120
  ${c.dim}$${c.reset} subc pi install
121
121
  ${c.dim}$${c.reset} subc harness-manifest --json
122
122
  ${c.dim}$${c.reset} subc codex headless "fix the failing test"
@@ -227,7 +227,7 @@ session with a bounded, text-only handoff from the source conversation.
227
227
 
228
228
  function printRetiredSetup(args = []) {
229
229
  const maybeAgent = resolveAgent(args[0]);
230
- const command = maybeAgent ? agentCommandName(maybeAgent) : 'cursor';
230
+ const command = maybeAgent ? agentCommandName(maybeAgent) : 'copilot';
231
231
  console.error(`
232
232
  ${c.red}subc setup is no longer used.${c.reset} Use the agent command instead:
233
233
 
@@ -248,6 +248,25 @@ function printRetiredSettings() {
248
248
  `);
249
249
  }
250
250
 
251
+ function printRetiredCursor() {
252
+ console.error(`
253
+ ${c.red}subc no longer supports Cursor.${c.reset} Support ended in October 2026.
254
+
255
+ To remove hooks that an earlier subc installed, open ~/.cursor/hooks.json
256
+ and delete the subconscious-hook entries under beforeSubmitPrompt and
257
+ preCompact. Then delete these files:
258
+
259
+ ~/.cursor/hooks/subconscious-hook.sh
260
+ ~/.cursor/subconscious-hooks.env
261
+
262
+ On Windows, the files are under %USERPROFILE%\\.cursor instead:
263
+
264
+ %USERPROFILE%\\.cursor\\hooks.json
265
+ %USERPROFILE%\\.cursor\\hooks\\subconscious-hook.cjs
266
+ %USERPROFILE%\\.cursor\\subconscious-windows.json
267
+ `);
268
+ }
269
+
251
270
  const authCommands = {
252
271
  login: loginCommand,
253
272
  'update-key': updateApiKeyCommand,
@@ -408,6 +427,11 @@ async function main() {
408
427
  printHelp();
409
428
  return;
410
429
  }
430
+ if (args[1] === 'cursor') {
431
+ printRetiredCursor();
432
+ process.exitCode = 1;
433
+ return;
434
+ }
411
435
  const agent = resolveAgent(args[1]);
412
436
  if (!agent) throw new Error(`Unknown coding agent: ${args[1]}`);
413
437
  const profile = await loadProfile(profileName);
@@ -416,7 +440,8 @@ async function main() {
416
440
  }
417
441
 
418
442
  if (command === 'setup') {
419
- printRetiredSetup(args.slice(1));
443
+ if (args[1] === 'cursor') printRetiredCursor();
444
+ else printRetiredSetup(args.slice(1));
420
445
  process.exitCode = 1;
421
446
  return;
422
447
  }
@@ -427,6 +452,12 @@ async function main() {
427
452
  return;
428
453
  }
429
454
 
455
+ if (command === 'cursor') {
456
+ printRetiredCursor();
457
+ process.exitCode = 1;
458
+ return;
459
+ }
460
+
430
461
  const authHandler = authCommands[command];
431
462
  if (authHandler) {
432
463
  if (isHelpArg(args[1])) {