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 +10 -18
- package/bin/agent-launch.js +37 -4
- package/bin/agents.js +18 -0
- package/bin/auth.js +1 -1
- package/bin/claude-config-drift.js +58 -0
- package/bin/claude-config-plugins.js +103 -0
- package/bin/claude-config.js +624 -0
- package/bin/cli.js +35 -4
- package/bin/native/subc-tui-darwin-amd64 +0 -0
- package/bin/native/subc-tui-darwin-arm64 +0 -0
- package/bin/native/subc-tui-linux-amd64 +0 -0
- package/bin/native/subc-tui-linux-arm64 +0 -0
- package/bin/native/subc-tui-windows-amd64.exe +0 -0
- package/bin/native/subc-tui-windows-arm64.exe +0 -0
- package/bin/profiles.js +4 -0
- package/bin/runbook/README.md +2 -3
- package/bin/runbook/claude-code/agent.json +25 -1
- package/bin/runbook/shared.json +1 -2
- package/bin/sessions.js +5 -3
- package/bin/windows/hook.cjs +4 -10
- package/bin/windows/launch.js +22 -9
- package/bin/windows/setup.js +2 -8
- package/package.json +2 -3
- package/bin/runbook/cursor/agent.json +0 -138
- package/bin/runbook/cursor/hook.sh +0 -145
- package/bin/runbook/cursor/hooks.json +0 -17
- package/bin/runbook/cursor/install.sh +0 -259
|
@@ -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
|
|
119
|
-
${c.dim}$${c.reset} subc
|
|
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) : '
|
|
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
|
-
|
|
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])) {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|