ccprofiles-core 0.4.0 → 0.5.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/dist/adopt.d.ts +1 -1
- package/dist/adopt.js +9 -7
- package/dist/apply.d.ts +10 -1
- package/dist/apply.js +90 -19
- package/dist/assets.js +51 -12
- package/dist/codex.d.ts +10 -0
- package/dist/codex.js +51 -0
- package/dist/discovery.d.ts +3 -0
- package/dist/discovery.js +58 -18
- package/dist/fsutil.d.ts +1 -0
- package/dist/fsutil.js +10 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/links.d.ts +7 -0
- package/dist/links.js +11 -0
- package/dist/manifest.d.ts +16 -0
- package/dist/manifest.js +7 -0
- package/dist/rcblock.js +10 -4
- package/dist/sessions.d.ts +25 -0
- package/dist/sessions.js +202 -0
- package/package.json +2 -1
package/dist/adopt.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type LiveProfile } from './discovery.js';
|
|
2
2
|
import type { Manifest } from './manifest.js';
|
|
3
3
|
import { type Platform } from './platform.js';
|
|
4
4
|
export declare function buildManifest(live: LiveProfile[], platform: Platform): Manifest;
|
package/dist/adopt.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
+
import { liveProfileName } from './discovery.js';
|
|
1
2
|
import { toTemplate } from './platform.js';
|
|
2
|
-
function profileName(dirName) {
|
|
3
|
-
return dirName === '.claude' ? 'default' : dirName.slice('.claude-'.length);
|
|
4
|
-
}
|
|
5
3
|
export function buildManifest(live, platform) {
|
|
6
4
|
const mcpServers = {};
|
|
7
5
|
for (const lp of live)
|
|
@@ -16,24 +14,28 @@ export function buildManifest(live, platform) {
|
|
|
16
14
|
linkVotes.set(owner.dirName, (linkVotes.get(owner.dirName) ?? 0) + 1);
|
|
17
15
|
}
|
|
18
16
|
const hubDirName = [...linkVotes.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
19
|
-
const
|
|
17
|
+
const hubLive = hubDirName ? live.find(lp => lp.dirName === hubDirName) : null;
|
|
18
|
+
const hub = hubLive ? liveProfileName(hubLive) : null;
|
|
20
19
|
const profiles = live.map(lp => {
|
|
21
|
-
const name =
|
|
20
|
+
const name = liveProfileName(lp);
|
|
22
21
|
const links = {};
|
|
23
22
|
for (const [entry, target] of Object.entries(lp.links)) {
|
|
24
23
|
const isHubLink = hubDirName && target.startsWith(live.find(o => o.dirName === hubDirName).dir);
|
|
25
24
|
links[entry] = isHubLink ? 'hub' : toTemplate(target, platform);
|
|
26
25
|
}
|
|
27
26
|
return {
|
|
27
|
+
agent: lp.agent,
|
|
28
28
|
name,
|
|
29
29
|
dir: toTemplate(lp.dir, platform),
|
|
30
|
-
launcher:
|
|
31
|
-
|
|
30
|
+
launcher: lp.dirName === '.claude' || lp.dirName === '.codex' ? null
|
|
31
|
+
: lp.agent === 'codex' ? `cx-${lp.dirName.slice('.codex-'.length)}` : `cl-${name}`,
|
|
32
|
+
auth: lp.account || lp.authenticated ? 'oauth' : 'env',
|
|
32
33
|
env: {},
|
|
33
34
|
links,
|
|
34
35
|
mcp: Object.keys(lp.mcpServers).sort(),
|
|
35
36
|
settingsEnv: lp.settingsEnv,
|
|
36
37
|
skipPermissions: false,
|
|
38
|
+
sharedSessions: false,
|
|
37
39
|
};
|
|
38
40
|
});
|
|
39
41
|
return { version: 1, hub, profiles, mcpServers };
|
package/dist/apply.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { LiveProfile } from './discovery.js';
|
|
|
3
3
|
import { type Platform } from './platform.js';
|
|
4
4
|
export type ApplyAction = {
|
|
5
5
|
kind: 'set-mcp-servers';
|
|
6
|
+
agent: 'claude' | 'codex';
|
|
6
7
|
configPath: string;
|
|
7
8
|
servers: Record<string, McpServerDef>;
|
|
8
9
|
} | {
|
|
@@ -20,10 +21,18 @@ export type ApplyAction = {
|
|
|
20
21
|
kind: 'set-settings-env';
|
|
21
22
|
settingsPath: string;
|
|
22
23
|
env: Record<string, string>;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'share-session-dir';
|
|
26
|
+
from: string;
|
|
27
|
+
to: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'unshare-session-dir';
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
23
32
|
};
|
|
24
33
|
/** Resolve secret:// refs in every profile's settingsEnv. Throws on a missing secret. */
|
|
25
34
|
export declare function resolveSettingsEnv(m: Manifest, getSecret: (name: string) => Promise<string | null>): Promise<Record<string, Record<string, string>>>;
|
|
26
|
-
export declare function planApply(m: Manifest, live: LiveProfile[], p: Platform, resolvedSettingsEnv?: Record<string, Record<string, string
|
|
35
|
+
export declare function planApply(m: Manifest, live: LiveProfile[], p: Platform, resolvedSettingsEnv?: Record<string, Record<string, string>>, sharedRoot?: string): ApplyAction[];
|
|
27
36
|
export declare function executeApply(actions: ApplyAction[], opts: {
|
|
28
37
|
backupRoot: string;
|
|
29
38
|
stamp: string;
|
package/dist/apply.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
import { lstat, mkdir, readFile, symlink, unlink } from 'node:fs/promises';
|
|
1
|
+
import { cp, lstat, mkdir, readFile, rm, symlink, unlink } from 'node:fs/promises';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { join, dirname } from 'node:path';
|
|
3
|
+
import { join, dirname, isAbsolute, relative } from 'node:path';
|
|
4
4
|
import { renderPath } from './platform.js';
|
|
5
5
|
import { renderRcBlock, upsertManagedBlock } from './rcblock.js';
|
|
6
|
-
import { atomicWrite, backupFiles } from './fsutil.js';
|
|
7
|
-
|
|
6
|
+
import { atomicWrite, backupFiles, backupTree } from './fsutil.js';
|
|
7
|
+
import { writeCodexMcpServers } from './codex.js';
|
|
8
|
+
import { physicalLinkEntry } from './links.js';
|
|
9
|
+
function configPathFor(dir, home, agent) {
|
|
10
|
+
if (agent === 'codex')
|
|
11
|
+
return join(dir, 'config.toml');
|
|
8
12
|
return dir === join(home, '.claude') ? join(home, '.claude.json') : join(dir, '.claude.json');
|
|
9
13
|
}
|
|
10
14
|
function sortKeys(o) {
|
|
11
15
|
return Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
|
|
12
16
|
}
|
|
13
17
|
const SECRET_PREFIX = 'secret://';
|
|
18
|
+
const CLAUDE_SHARED_ENTRIES = ['projects', 'todos', 'shell-snapshots'];
|
|
19
|
+
function isWithin(parent, child) {
|
|
20
|
+
const rel = relative(parent, child);
|
|
21
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) && !isAbsolute(rel));
|
|
22
|
+
}
|
|
14
23
|
/** Resolve secret:// refs in every profile's settingsEnv. Throws on a missing secret. */
|
|
15
24
|
export async function resolveSettingsEnv(m, getSecret) {
|
|
16
25
|
const out = {};
|
|
@@ -31,10 +40,11 @@ export async function resolveSettingsEnv(m, getSecret) {
|
|
|
31
40
|
}
|
|
32
41
|
return out;
|
|
33
42
|
}
|
|
34
|
-
export function planApply(m, live, p, resolvedSettingsEnv) {
|
|
43
|
+
export function planApply(m, live, p, resolvedSettingsEnv, sharedRoot = join(p.home, '.ccprofiles', 'shared')) {
|
|
35
44
|
const actions = [];
|
|
36
45
|
const hubProfile = m.profiles.find(x => x.name === m.hub) ?? null;
|
|
37
46
|
for (const pr of m.profiles) {
|
|
47
|
+
const agent = pr.agent ?? 'claude';
|
|
38
48
|
const dir = renderPath(pr.dir, p);
|
|
39
49
|
const lp = live.find(l => l.dir === dir) ?? null;
|
|
40
50
|
const desired = {};
|
|
@@ -44,19 +54,34 @@ export function planApply(m, live, p, resolvedSettingsEnv) {
|
|
|
44
54
|
actions.push({ kind: 'create-profile-dir', dir });
|
|
45
55
|
const current = lp?.mcpServers ?? null;
|
|
46
56
|
if (!current || JSON.stringify(sortKeys(current)) !== JSON.stringify(sortKeys(desired))) {
|
|
47
|
-
actions.push({ kind: 'set-mcp-servers', configPath: configPathFor(dir, p.home), servers: desired });
|
|
57
|
+
actions.push({ kind: 'set-mcp-servers', agent, configPath: configPathFor(dir, p.home, agent), servers: desired });
|
|
48
58
|
}
|
|
49
59
|
for (const [entry, target] of Object.entries(pr.links)) {
|
|
60
|
+
const physicalEntry = physicalLinkEntry(agent, entry);
|
|
50
61
|
const to = target === 'hub' && hubProfile
|
|
51
|
-
? join(renderPath(hubProfile.dir, p), entry)
|
|
62
|
+
? join(renderPath(hubProfile.dir, p), physicalLinkEntry(hubProfile.agent ?? 'claude', entry))
|
|
52
63
|
: renderPath(target, p);
|
|
53
|
-
const from = join(dir,
|
|
64
|
+
const from = join(dir, physicalEntry);
|
|
65
|
+
if (from === to)
|
|
66
|
+
continue;
|
|
67
|
+
if (isWithin(from, to) || isWithin(to, from))
|
|
68
|
+
throw new Error(`unsafe link topology: ${from} -> ${to} (source and target must not contain each other)`);
|
|
54
69
|
if (lp?.links[entry] === to)
|
|
55
70
|
continue;
|
|
56
71
|
actions.push({ kind: 'link', from, to });
|
|
57
72
|
}
|
|
73
|
+
const sharedEntries = agent === 'codex' ? ['sessions'] : CLAUDE_SHARED_ENTRIES;
|
|
74
|
+
for (const entry of sharedEntries) {
|
|
75
|
+
const from = join(dir, entry);
|
|
76
|
+
const to = join(sharedRoot, entry);
|
|
77
|
+
const linkedToPool = lp?.links[entry] === to;
|
|
78
|
+
if (pr.sharedSessions && !linkedToPool)
|
|
79
|
+
actions.push({ kind: 'share-session-dir', from, to });
|
|
80
|
+
else if (!pr.sharedSessions && linkedToPool)
|
|
81
|
+
actions.push({ kind: 'unshare-session-dir', from, to });
|
|
82
|
+
}
|
|
58
83
|
const senv = pr.settingsEnv ?? {}; // literals in older tests may omit the field
|
|
59
|
-
if (Object.keys(senv).length > 0) {
|
|
84
|
+
if (agent === 'claude' && Object.keys(senv).length > 0) {
|
|
60
85
|
let desired = resolvedSettingsEnv?.[pr.name];
|
|
61
86
|
if (!desired) {
|
|
62
87
|
if (Object.values(senv).some(v => v.startsWith(SECRET_PREFIX)))
|
|
@@ -87,12 +112,17 @@ export async function executeApply(actions, opts) {
|
|
|
87
112
|
: a.kind === 'rc-block' ? [a.rcFile]
|
|
88
113
|
: a.kind === 'set-settings-env' ? [a.settingsPath]
|
|
89
114
|
: []);
|
|
90
|
-
|
|
115
|
+
let backupDir = touched.length ? await backupFiles(touched, opts.backupRoot, opts.stamp) : null;
|
|
91
116
|
for (const a of actions) {
|
|
92
117
|
if (a.kind === 'create-profile-dir') {
|
|
93
118
|
await mkdir(a.dir, { recursive: true });
|
|
94
119
|
}
|
|
95
120
|
else if (a.kind === 'set-mcp-servers') {
|
|
121
|
+
if (a.agent === 'codex') {
|
|
122
|
+
await mkdir(dirname(a.configPath), { recursive: true });
|
|
123
|
+
await writeCodexMcpServers(a.configPath, a.servers);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
96
126
|
let cfg = {};
|
|
97
127
|
try {
|
|
98
128
|
cfg = JSON.parse(await readFile(a.configPath, 'utf8'));
|
|
@@ -103,18 +133,26 @@ export async function executeApply(actions, opts) {
|
|
|
103
133
|
await atomicWrite(a.configPath, JSON.stringify(cfg, null, 2));
|
|
104
134
|
}
|
|
105
135
|
else if (a.kind === 'link') {
|
|
136
|
+
let st = null;
|
|
106
137
|
try {
|
|
107
|
-
|
|
108
|
-
if (!st.isSymbolicLink())
|
|
109
|
-
throw new Error(`refusing to replace non-symlink: ${a.from}`);
|
|
110
|
-
await unlink(a.from);
|
|
138
|
+
st = await lstat(a.from);
|
|
111
139
|
}
|
|
112
|
-
catch
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
catch { /* absent */ }
|
|
141
|
+
if (st && !st.isSymbolicLink()) {
|
|
142
|
+
// Preserve profile-local assets, then merge unique entries into shared target.
|
|
143
|
+
// Existing target files win name conflicts; complete source remains in backup.
|
|
144
|
+
const treeBackup = await backupTree(a.from, opts.backupRoot, opts.stamp);
|
|
145
|
+
if (treeBackup)
|
|
146
|
+
backupDir ??= dirname(treeBackup);
|
|
147
|
+
await mkdir(a.to, { recursive: true });
|
|
148
|
+
await cp(a.from, a.to, { recursive: true, force: false, errorOnExist: false });
|
|
149
|
+
await rm(a.from, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
else if (st) {
|
|
152
|
+
await unlink(a.from);
|
|
117
153
|
}
|
|
154
|
+
await mkdir(a.to, { recursive: true });
|
|
155
|
+
await mkdir(dirname(a.from), { recursive: true });
|
|
118
156
|
await symlink(a.to, a.from, process.platform === 'win32' ? 'junction' : 'dir');
|
|
119
157
|
}
|
|
120
158
|
else if (a.kind === 'rc-block') {
|
|
@@ -136,6 +174,37 @@ export async function executeApply(actions, opts) {
|
|
|
136
174
|
await mkdir(dirname(a.settingsPath), { recursive: true });
|
|
137
175
|
await atomicWrite(a.settingsPath, JSON.stringify(cfg, null, 2));
|
|
138
176
|
}
|
|
177
|
+
else if (a.kind === 'share-session-dir') {
|
|
178
|
+
await mkdir(a.to, { recursive: true });
|
|
179
|
+
let st = null;
|
|
180
|
+
try {
|
|
181
|
+
st = await lstat(a.from);
|
|
182
|
+
}
|
|
183
|
+
catch { /* absent */ }
|
|
184
|
+
if (st && !st.isSymbolicLink()) {
|
|
185
|
+
const treeBackup = await backupTree(a.from, opts.backupRoot, opts.stamp);
|
|
186
|
+
if (treeBackup)
|
|
187
|
+
backupDir ??= dirname(treeBackup);
|
|
188
|
+
await cp(a.from, a.to, { recursive: true, force: false, errorOnExist: false });
|
|
189
|
+
await rm(a.from, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
else if (st && st.isSymbolicLink()) {
|
|
192
|
+
await unlink(a.from);
|
|
193
|
+
}
|
|
194
|
+
await mkdir(dirname(a.from), { recursive: true });
|
|
195
|
+
await symlink(a.to, a.from, process.platform === 'win32' ? 'junction' : 'dir');
|
|
196
|
+
}
|
|
197
|
+
else if (a.kind === 'unshare-session-dir') {
|
|
198
|
+
try {
|
|
199
|
+
const st = await lstat(a.from);
|
|
200
|
+
if (st.isSymbolicLink())
|
|
201
|
+
await unlink(a.from);
|
|
202
|
+
}
|
|
203
|
+
catch { /* absent */ }
|
|
204
|
+
await mkdir(a.from, { recursive: true });
|
|
205
|
+
if (existsSync(a.to))
|
|
206
|
+
await cp(a.to, a.from, { recursive: true, force: false, errorOnExist: false });
|
|
207
|
+
}
|
|
139
208
|
}
|
|
140
209
|
return { backupDir, performed };
|
|
141
210
|
}
|
|
@@ -146,5 +215,7 @@ function describe(a) {
|
|
|
146
215
|
case 'link': return `link ${a.from} -> ${a.to}`;
|
|
147
216
|
case 'rc-block': return `update managed block in ${a.rcFile}`;
|
|
148
217
|
case 'set-settings-env': return `set settings env (${Object.keys(a.env).length}) in ${a.settingsPath}`;
|
|
218
|
+
case 'share-session-dir': return `share ${a.from} -> ${a.to}`;
|
|
219
|
+
case 'unshare-session-dir': return `unshare ${a.from} (seed from ${a.to})`;
|
|
149
220
|
}
|
|
150
221
|
}
|
package/dist/assets.js
CHANGED
|
@@ -1,9 +1,33 @@
|
|
|
1
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
1
|
+
import { lstat, readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
import { renderPath } from './platform.js';
|
|
5
5
|
import { atomicWrite } from './fsutil.js';
|
|
6
|
-
|
|
6
|
+
import { physicalLinkEntry } from './links.js';
|
|
7
|
+
const HUB_DIRS = ['skills', 'commands', 'plugins'];
|
|
8
|
+
function safeParts(rel) {
|
|
9
|
+
if (rel.includes('\\') || rel.includes('\0'))
|
|
10
|
+
throw new Error(`unsafe asset path: ${JSON.stringify(rel)}`);
|
|
11
|
+
const parts = rel.split('/');
|
|
12
|
+
if (parts.some(x => !x || x === '.' || x === '..'))
|
|
13
|
+
throw new Error(`unsafe asset path: ${JSON.stringify(rel)}`);
|
|
14
|
+
return parts;
|
|
15
|
+
}
|
|
16
|
+
async function assertNoSymlinkParents(root, segments) {
|
|
17
|
+
let current = root;
|
|
18
|
+
for (const segment of ['', ...segments.slice(0, -1)]) {
|
|
19
|
+
if (segment)
|
|
20
|
+
current = join(current, segment);
|
|
21
|
+
try {
|
|
22
|
+
if ((await lstat(current)).isSymbolicLink())
|
|
23
|
+
throw new Error(`refusing to write asset through symlink: ${current}`);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (e.code !== 'ENOENT')
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
7
31
|
async function walk(dir) {
|
|
8
32
|
const out = [];
|
|
9
33
|
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
@@ -24,7 +48,7 @@ export async function collectAssets(m, p) {
|
|
|
24
48
|
if (hub) {
|
|
25
49
|
const hubDir = renderPath(hub.dir, p);
|
|
26
50
|
for (const sub of HUB_DIRS) {
|
|
27
|
-
const root = join(hubDir, sub);
|
|
51
|
+
const root = join(hubDir, physicalLinkEntry(hub.agent ?? 'claude', sub));
|
|
28
52
|
if (!existsSync(root))
|
|
29
53
|
continue;
|
|
30
54
|
for (const f of await walk(root)) {
|
|
@@ -33,9 +57,10 @@ export async function collectAssets(m, p) {
|
|
|
33
57
|
}
|
|
34
58
|
}
|
|
35
59
|
for (const pr of m.profiles) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
60
|
+
const guidanceName = (pr.agent ?? 'claude') === 'codex' ? 'AGENTS.md' : 'CLAUDE.md';
|
|
61
|
+
const guidance = join(renderPath(pr.dir, p), guidanceName);
|
|
62
|
+
if (existsSync(guidance))
|
|
63
|
+
assets[`profiles/${pr.name}/${guidanceName}`] = await readFile(guidance, 'utf8');
|
|
39
64
|
}
|
|
40
65
|
return assets;
|
|
41
66
|
}
|
|
@@ -43,14 +68,28 @@ export async function collectAssets(m, p) {
|
|
|
43
68
|
export async function writeAssets(assets, m, p) {
|
|
44
69
|
const hub = m.profiles.find(x => x.name === m.hub);
|
|
45
70
|
for (const [rel, content] of Object.entries(assets)) {
|
|
46
|
-
|
|
47
|
-
|
|
71
|
+
const parts = safeParts(rel);
|
|
72
|
+
if (parts[0] === 'hub' && hub) {
|
|
73
|
+
const [, logicalEntry, ...rest] = parts;
|
|
74
|
+
if (!HUB_DIRS.includes(logicalEntry) || rest.length === 0)
|
|
75
|
+
throw new Error(`unsafe asset path: ${JSON.stringify(rel)}`);
|
|
76
|
+
const root = join(renderPath(hub.dir, p), physicalLinkEntry(hub.agent ?? 'claude', logicalEntry));
|
|
77
|
+
await assertNoSymlinkParents(root, rest);
|
|
78
|
+
await atomicWrite(join(root, ...rest), content);
|
|
48
79
|
}
|
|
49
|
-
else if (
|
|
50
|
-
const [, name, ...rest] =
|
|
80
|
+
else if (parts[0] === 'profiles') {
|
|
81
|
+
const [, name, ...rest] = parts;
|
|
51
82
|
const pr = m.profiles.find(x => x.name === name);
|
|
52
|
-
if (pr)
|
|
53
|
-
|
|
83
|
+
if (!pr)
|
|
84
|
+
throw new Error(`asset references unknown profile: ${name}`);
|
|
85
|
+
const guidanceName = (pr.agent ?? 'claude') === 'codex' ? 'AGENTS.md' : 'CLAUDE.md';
|
|
86
|
+
if (rest.length !== 1 || rest[0] !== guidanceName)
|
|
87
|
+
throw new Error(`unsafe profile asset path: ${JSON.stringify(rel)}`);
|
|
88
|
+
const root = renderPath(pr.dir, p);
|
|
89
|
+
await assertNoSymlinkParents(root, rest);
|
|
90
|
+
await atomicWrite(join(root, guidanceName), content);
|
|
54
91
|
}
|
|
92
|
+
else
|
|
93
|
+
throw new Error(`unsafe asset path: ${JSON.stringify(rel)}`);
|
|
55
94
|
}
|
|
56
95
|
}
|
package/dist/codex.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { McpServerDef } from './manifest.js';
|
|
2
|
+
/**
|
|
3
|
+
* Codex keeps every MCP server in one flat global `mcp_servers` table with no scope
|
|
4
|
+
* marker, so per-project launchers (e.g. CCE's `cce serve --project-dir <path>`) sit
|
|
5
|
+
* alongside genuine user-scope servers. clp manages user scope only: it neither surfaces
|
|
6
|
+
* nor manages these project launchers — and (see writeCodexMcpServers) never deletes them.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isProjectScopedMcpServer(def: McpServerDef): boolean;
|
|
9
|
+
export declare function readCodexMcpServers(configPath: string): Promise<Record<string, McpServerDef>>;
|
|
10
|
+
export declare function writeCodexMcpServers(configPath: string, servers: Record<string, McpServerDef>): Promise<void>;
|
package/dist/codex.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parse, stringify } from 'smol-toml';
|
|
3
|
+
import { atomicWrite } from './fsutil.js';
|
|
4
|
+
/**
|
|
5
|
+
* Codex keeps every MCP server in one flat global `mcp_servers` table with no scope
|
|
6
|
+
* marker, so per-project launchers (e.g. CCE's `cce serve --project-dir <path>`) sit
|
|
7
|
+
* alongside genuine user-scope servers. clp manages user scope only: it neither surfaces
|
|
8
|
+
* nor manages these project launchers — and (see writeCodexMcpServers) never deletes them.
|
|
9
|
+
*/
|
|
10
|
+
export function isProjectScopedMcpServer(def) {
|
|
11
|
+
return !!def.args?.some(a => a === '--project-dir' || a.startsWith('--project-dir='));
|
|
12
|
+
}
|
|
13
|
+
function isMcpServerTable(value) {
|
|
14
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
15
|
+
&& (typeof value.command === 'string' || typeof value.url === 'string');
|
|
16
|
+
}
|
|
17
|
+
export async function readCodexMcpServers(configPath) {
|
|
18
|
+
let config;
|
|
19
|
+
try {
|
|
20
|
+
config = parse(await readFile(configPath, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
const raw = config.mcp_servers;
|
|
26
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
27
|
+
return {};
|
|
28
|
+
return Object.fromEntries(Object.entries(raw)
|
|
29
|
+
.filter((entry) => isMcpServerTable(entry[1]))
|
|
30
|
+
// user scope only — hide per-project launchers from the drift matrix, adopt, and sync
|
|
31
|
+
.filter(([, def]) => !isProjectScopedMcpServer(def)));
|
|
32
|
+
}
|
|
33
|
+
export async function writeCodexMcpServers(configPath, servers) {
|
|
34
|
+
let config = {};
|
|
35
|
+
try {
|
|
36
|
+
config = parse(await readFile(configPath, 'utf8'));
|
|
37
|
+
}
|
|
38
|
+
catch { /* new config */ }
|
|
39
|
+
// Preserve project-scoped launchers already in the file: clp hides them from management,
|
|
40
|
+
// so a plain replace would silently delete them. Only user-scope servers are clp-managed.
|
|
41
|
+
const existing = config.mcp_servers && typeof config.mcp_servers === 'object' && !Array.isArray(config.mcp_servers)
|
|
42
|
+
? config.mcp_servers : {};
|
|
43
|
+
const preserved = Object.fromEntries(Object.entries(existing)
|
|
44
|
+
.filter((e) => isMcpServerTable(e[1]) && isProjectScopedMcpServer(e[1])));
|
|
45
|
+
const managed = Object.fromEntries(Object.entries(servers).map(([name, server]) => {
|
|
46
|
+
const { type: _claudeTransportType, ...codexServer } = server;
|
|
47
|
+
return [name, codexServer];
|
|
48
|
+
}));
|
|
49
|
+
config.mcp_servers = { ...preserved, ...managed };
|
|
50
|
+
await atomicWrite(configPath, stringify(config));
|
|
51
|
+
}
|
package/dist/discovery.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { McpServerDef } from './manifest.js';
|
|
2
2
|
export interface LiveProfile {
|
|
3
|
+
agent: 'claude' | 'codex';
|
|
3
4
|
dirName: string;
|
|
4
5
|
dir: string;
|
|
5
6
|
configPath: string;
|
|
6
7
|
account: string | null;
|
|
8
|
+
authenticated?: boolean;
|
|
7
9
|
mcpServers: Record<string, McpServerDef>;
|
|
8
10
|
links: Record<string, string>;
|
|
9
11
|
settingsEnv: Record<string, string>;
|
|
10
12
|
}
|
|
11
13
|
export declare function discoverProfiles(home: string): Promise<LiveProfile[]>;
|
|
14
|
+
export declare function liveProfileName(lp: Pick<LiveProfile, 'agent' | 'dirName'>): string;
|
package/dist/discovery.js
CHANGED
|
@@ -1,49 +1,89 @@
|
|
|
1
|
-
import { readdir, readFile, readlink } from 'node:fs/promises';
|
|
1
|
+
import { access, readdir, readFile, readlink } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { readCodexMcpServers } from './codex.js';
|
|
4
|
+
import { logicalLinkEntry } from './links.js';
|
|
3
5
|
export async function discoverProfiles(home) {
|
|
4
6
|
const entries = await readdir(home, { withFileTypes: true });
|
|
5
7
|
const out = [];
|
|
6
8
|
for (const e of entries) {
|
|
7
|
-
if (!e.isDirectory()
|
|
9
|
+
if (!e.isDirectory())
|
|
10
|
+
continue;
|
|
11
|
+
const agent = e.name === '.claude' || e.name.startsWith('.claude-') ? 'claude'
|
|
12
|
+
: e.name === '.codex' || e.name.startsWith('.codex-') ? 'codex'
|
|
13
|
+
: null;
|
|
14
|
+
if (!agent)
|
|
8
15
|
continue;
|
|
9
16
|
const dir = join(home, e.name);
|
|
10
|
-
const configPath =
|
|
17
|
+
const configPath = agent === 'codex' ? join(dir, 'config.toml')
|
|
18
|
+
: e.name === '.claude' ? join(home, '.claude.json') : join(dir, '.claude.json');
|
|
11
19
|
let cfg;
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
if (agent === 'claude') {
|
|
21
|
+
try {
|
|
22
|
+
cfg = JSON.parse(await readFile(configPath, 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
14
27
|
}
|
|
15
|
-
|
|
16
|
-
|
|
28
|
+
else {
|
|
29
|
+
let authenticated = false;
|
|
30
|
+
try {
|
|
31
|
+
await access(configPath);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
try {
|
|
35
|
+
await access(join(dir, 'auth.json'));
|
|
36
|
+
authenticated = true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await access(join(dir, 'auth.json'));
|
|
44
|
+
authenticated = true;
|
|
45
|
+
}
|
|
46
|
+
catch { /* config-only profile */ }
|
|
47
|
+
cfg = {};
|
|
48
|
+
cfg.authenticated = authenticated;
|
|
17
49
|
}
|
|
18
50
|
const links = {};
|
|
19
51
|
for (const child of await readdir(dir, { withFileTypes: true })) {
|
|
20
52
|
if (child.isSymbolicLink()) {
|
|
21
53
|
try {
|
|
22
54
|
// windows junctions read back as \\?\C:\... with a trailing separator — normalize
|
|
23
|
-
links[child.name] = (await readlink(join(dir, child.name)))
|
|
55
|
+
links[logicalLinkEntry(agent, child.name)] = (await readlink(join(dir, child.name)))
|
|
24
56
|
.replace(/^\\\\\?\\/, '').replace(/([\\/])+$/, '');
|
|
25
57
|
}
|
|
26
58
|
catch { /* skip */ }
|
|
27
59
|
}
|
|
28
60
|
}
|
|
29
61
|
const settingsEnv = {};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
62
|
+
if (agent === 'claude')
|
|
63
|
+
try {
|
|
64
|
+
const s = JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8'));
|
|
65
|
+
if (s && typeof s.env === 'object' && s.env !== null)
|
|
66
|
+
for (const [k, v] of Object.entries(s.env))
|
|
67
|
+
if (typeof v === 'string')
|
|
68
|
+
settingsEnv[k] = v;
|
|
69
|
+
}
|
|
70
|
+
catch { /* no settings.json */ }
|
|
38
71
|
out.push({
|
|
72
|
+
agent,
|
|
39
73
|
dirName: e.name,
|
|
40
74
|
dir,
|
|
41
75
|
configPath,
|
|
42
|
-
account: cfg?.oauthAccount?.emailAddress ?? null,
|
|
43
|
-
|
|
76
|
+
account: agent === 'claude' ? cfg?.oauthAccount?.emailAddress ?? null : null,
|
|
77
|
+
authenticated: agent === 'codex' ? cfg.authenticated : !!cfg?.oauthAccount,
|
|
78
|
+
mcpServers: agent === 'claude' ? cfg?.mcpServers ?? {} : await readCodexMcpServers(configPath),
|
|
44
79
|
links,
|
|
45
80
|
settingsEnv,
|
|
46
81
|
});
|
|
47
82
|
}
|
|
48
83
|
return out.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
49
84
|
}
|
|
85
|
+
export function liveProfileName(lp) {
|
|
86
|
+
if (lp.agent === 'codex')
|
|
87
|
+
return lp.dirName === '.codex' ? 'codex' : `codex-${lp.dirName.slice('.codex-'.length)}`;
|
|
88
|
+
return lp.dirName === '.claude' ? 'default' : lp.dirName.slice('.claude-'.length);
|
|
89
|
+
}
|
package/dist/fsutil.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export declare function atomicWrite(filePath: string, content: string): Promise<void>;
|
|
2
2
|
export declare function backupFiles(files: string[], backupRoot: string, stamp: string): Promise<string>;
|
|
3
|
+
export declare function backupTree(src: string, backupRoot: string, stamp: string): Promise<string | null>;
|
package/dist/fsutil.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { copyFile, mkdir, rename, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { copyFile, cp, mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
export async function atomicWrite(filePath, content) {
|
|
@@ -20,3 +20,12 @@ export async function backupFiles(files, backupRoot, stamp) {
|
|
|
20
20
|
}
|
|
21
21
|
return dir;
|
|
22
22
|
}
|
|
23
|
+
export async function backupTree(src, backupRoot, stamp) {
|
|
24
|
+
if (!existsSync(src))
|
|
25
|
+
return null;
|
|
26
|
+
const dir = join(backupRoot, stamp);
|
|
27
|
+
const dest = join(dir, sanitize(src));
|
|
28
|
+
await mkdir(dir, { recursive: true });
|
|
29
|
+
await cp(src, dest, { recursive: true });
|
|
30
|
+
return dest;
|
|
31
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './platform.js';
|
|
2
2
|
export * from './manifest.js';
|
|
3
3
|
export * from './discovery.js';
|
|
4
|
+
export * from './codex.js';
|
|
4
5
|
export * from './adopt.js';
|
|
5
6
|
export * from './rcblock.js';
|
|
6
7
|
export * from './fsutil.js';
|
|
@@ -8,7 +9,9 @@ export * from './secrets.js';
|
|
|
8
9
|
export * from './apply.js';
|
|
9
10
|
export * from './crypto.js';
|
|
10
11
|
export * from './assets.js';
|
|
12
|
+
export * from './links.js';
|
|
11
13
|
export * from './devices.js';
|
|
12
14
|
export * from './syncserver.js';
|
|
13
15
|
export * from './syncclient.js';
|
|
14
16
|
export * from './bundle.js';
|
|
17
|
+
export * from './sessions.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './platform.js';
|
|
2
2
|
export * from './manifest.js';
|
|
3
3
|
export * from './discovery.js';
|
|
4
|
+
export * from './codex.js';
|
|
4
5
|
export * from './adopt.js';
|
|
5
6
|
export * from './rcblock.js';
|
|
6
7
|
export * from './fsutil.js';
|
|
@@ -8,7 +9,9 @@ export * from './secrets.js';
|
|
|
8
9
|
export * from './apply.js';
|
|
9
10
|
export * from './crypto.js';
|
|
10
11
|
export * from './assets.js';
|
|
12
|
+
export * from './links.js';
|
|
11
13
|
export * from './devices.js';
|
|
12
14
|
export * from './syncserver.js';
|
|
13
15
|
export * from './syncclient.js';
|
|
14
16
|
export * from './bundle.js';
|
|
17
|
+
export * from './sessions.js';
|
package/dist/links.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest links use agent-neutral names. Translate only at filesystem edge so
|
|
3
|
+
* one `commands: hub` declaration works for Claude Code and Codex profiles.
|
|
4
|
+
*/
|
|
5
|
+
export declare function physicalLinkEntry(agent: 'claude' | 'codex', entry: string): string;
|
|
6
|
+
/** Convert discovered filesystem entry back to agent-neutral manifest name. */
|
|
7
|
+
export declare function logicalLinkEntry(agent: 'claude' | 'codex', entry: string): string;
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest links use agent-neutral names. Translate only at filesystem edge so
|
|
3
|
+
* one `commands: hub` declaration works for Claude Code and Codex profiles.
|
|
4
|
+
*/
|
|
5
|
+
export function physicalLinkEntry(agent, entry) {
|
|
6
|
+
return agent === 'codex' && entry === 'commands' ? 'prompts' : entry;
|
|
7
|
+
}
|
|
8
|
+
/** Convert discovered filesystem entry back to agent-neutral manifest name. */
|
|
9
|
+
export function logicalLinkEntry(agent, entry) {
|
|
10
|
+
return agent === 'codex' && entry === 'prompts' ? 'commands' : entry;
|
|
11
|
+
}
|
package/dist/manifest.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ declare const McpServerSchema: z.ZodEffects<z.ZodObject<{
|
|
|
33
33
|
url: z.ZodOptional<z.ZodString>;
|
|
34
34
|
}, z.ZodTypeAny, "passthrough">>;
|
|
35
35
|
declare const ProfileSchema: z.ZodObject<{
|
|
36
|
+
agent: z.ZodOptional<z.ZodEnum<["claude", "codex"]>>;
|
|
36
37
|
name: z.ZodString;
|
|
37
38
|
dir: z.ZodString;
|
|
38
39
|
launcher: z.ZodNullable<z.ZodString>;
|
|
@@ -42,6 +43,7 @@ declare const ProfileSchema: z.ZodObject<{
|
|
|
42
43
|
mcp: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
43
44
|
settingsEnv: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
44
45
|
skipPermissions: z.ZodDefault<z.ZodBoolean>;
|
|
46
|
+
sharedSessions: z.ZodDefault<z.ZodBoolean>;
|
|
45
47
|
}, "strip", z.ZodTypeAny, {
|
|
46
48
|
env: Record<string, string>;
|
|
47
49
|
name: string;
|
|
@@ -52,21 +54,26 @@ declare const ProfileSchema: z.ZodObject<{
|
|
|
52
54
|
mcp: string[];
|
|
53
55
|
settingsEnv: Record<string, string>;
|
|
54
56
|
skipPermissions: boolean;
|
|
57
|
+
sharedSessions: boolean;
|
|
58
|
+
agent?: "claude" | "codex" | undefined;
|
|
55
59
|
}, {
|
|
56
60
|
name: string;
|
|
57
61
|
dir: string;
|
|
58
62
|
launcher: string | null;
|
|
59
63
|
auth: "env" | "oauth" | "api-key";
|
|
60
64
|
env?: Record<string, string> | undefined;
|
|
65
|
+
agent?: "claude" | "codex" | undefined;
|
|
61
66
|
links?: Record<string, string> | undefined;
|
|
62
67
|
mcp?: string[] | undefined;
|
|
63
68
|
settingsEnv?: Record<string, string> | undefined;
|
|
64
69
|
skipPermissions?: boolean | undefined;
|
|
70
|
+
sharedSessions?: boolean | undefined;
|
|
65
71
|
}>;
|
|
66
72
|
declare const ManifestSchema: z.ZodObject<{
|
|
67
73
|
version: z.ZodLiteral<1>;
|
|
68
74
|
hub: z.ZodNullable<z.ZodString>;
|
|
69
75
|
profiles: z.ZodArray<z.ZodObject<{
|
|
76
|
+
agent: z.ZodOptional<z.ZodEnum<["claude", "codex"]>>;
|
|
70
77
|
name: z.ZodString;
|
|
71
78
|
dir: z.ZodString;
|
|
72
79
|
launcher: z.ZodNullable<z.ZodString>;
|
|
@@ -76,6 +83,7 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
76
83
|
mcp: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
77
84
|
settingsEnv: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
78
85
|
skipPermissions: z.ZodDefault<z.ZodBoolean>;
|
|
86
|
+
sharedSessions: z.ZodDefault<z.ZodBoolean>;
|
|
79
87
|
}, "strip", z.ZodTypeAny, {
|
|
80
88
|
env: Record<string, string>;
|
|
81
89
|
name: string;
|
|
@@ -86,16 +94,20 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
86
94
|
mcp: string[];
|
|
87
95
|
settingsEnv: Record<string, string>;
|
|
88
96
|
skipPermissions: boolean;
|
|
97
|
+
sharedSessions: boolean;
|
|
98
|
+
agent?: "claude" | "codex" | undefined;
|
|
89
99
|
}, {
|
|
90
100
|
name: string;
|
|
91
101
|
dir: string;
|
|
92
102
|
launcher: string | null;
|
|
93
103
|
auth: "env" | "oauth" | "api-key";
|
|
94
104
|
env?: Record<string, string> | undefined;
|
|
105
|
+
agent?: "claude" | "codex" | undefined;
|
|
95
106
|
links?: Record<string, string> | undefined;
|
|
96
107
|
mcp?: string[] | undefined;
|
|
97
108
|
settingsEnv?: Record<string, string> | undefined;
|
|
98
109
|
skipPermissions?: boolean | undefined;
|
|
110
|
+
sharedSessions?: boolean | undefined;
|
|
99
111
|
}>, "many">;
|
|
100
112
|
mcpServers: z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodObject<{
|
|
101
113
|
command: z.ZodOptional<z.ZodString>;
|
|
@@ -141,6 +153,8 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
141
153
|
mcp: string[];
|
|
142
154
|
settingsEnv: Record<string, string>;
|
|
143
155
|
skipPermissions: boolean;
|
|
156
|
+
sharedSessions: boolean;
|
|
157
|
+
agent?: "claude" | "codex" | undefined;
|
|
144
158
|
}[];
|
|
145
159
|
mcpServers: Record<string, z.objectOutputType<{
|
|
146
160
|
command: z.ZodOptional<z.ZodString>;
|
|
@@ -158,10 +172,12 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
158
172
|
launcher: string | null;
|
|
159
173
|
auth: "env" | "oauth" | "api-key";
|
|
160
174
|
env?: Record<string, string> | undefined;
|
|
175
|
+
agent?: "claude" | "codex" | undefined;
|
|
161
176
|
links?: Record<string, string> | undefined;
|
|
162
177
|
mcp?: string[] | undefined;
|
|
163
178
|
settingsEnv?: Record<string, string> | undefined;
|
|
164
179
|
skipPermissions?: boolean | undefined;
|
|
180
|
+
sharedSessions?: boolean | undefined;
|
|
165
181
|
}[];
|
|
166
182
|
mcpServers: Record<string, z.objectInputType<{
|
|
167
183
|
command: z.ZodOptional<z.ZodString>;
|
package/dist/manifest.js
CHANGED
|
@@ -15,6 +15,7 @@ const McpServerSchema = z.object({
|
|
|
15
15
|
url: z.string().optional(),
|
|
16
16
|
}).passthrough().refine(s => s.command !== undefined || s.url !== undefined, { message: 'mcp server must have either "command" (local) or "url" (remote)' });
|
|
17
17
|
const ProfileSchema = z.object({
|
|
18
|
+
agent: z.enum(['claude', 'codex']).optional(),
|
|
18
19
|
name: z.string().min(1),
|
|
19
20
|
dir: z.string().min(1),
|
|
20
21
|
launcher: z.string().nullable(),
|
|
@@ -24,6 +25,7 @@ const ProfileSchema = z.object({
|
|
|
24
25
|
mcp: z.array(z.string()).default([]),
|
|
25
26
|
settingsEnv: z.record(z.string()).default({}),
|
|
26
27
|
skipPermissions: z.boolean().default(false),
|
|
28
|
+
sharedSessions: z.boolean().default(false),
|
|
27
29
|
});
|
|
28
30
|
const ManifestSchema = z.object({
|
|
29
31
|
version: z.literal(1),
|
|
@@ -34,6 +36,7 @@ const ManifestSchema = z.object({
|
|
|
34
36
|
// identifiers that get interpolated into shell launcher code must be injection-safe
|
|
35
37
|
const SAFE_NAME = /^[A-Za-z0-9_-]+$/; // profile names, launcher names, secret refs
|
|
36
38
|
const SAFE_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; // POSIX/PowerShell env var names
|
|
39
|
+
const SAFE_LINK_ENTRY = /^[A-Za-z0-9._-]+$/; // one profile-dir child; never a path
|
|
37
40
|
const SHELL_META = /["`$;|&()\n\r<>]/; // chars that could break out of a quoted shell context
|
|
38
41
|
const SECRET_PREFIX = 'secret://';
|
|
39
42
|
/**
|
|
@@ -67,6 +70,10 @@ export function assertSafeManifest(m) {
|
|
|
67
70
|
throw new ManifestError(`unsafe secret reference in profile "${p.name}": ${JSON.stringify(v)}`);
|
|
68
71
|
}
|
|
69
72
|
}
|
|
73
|
+
for (const entry of Object.keys(p.links)) {
|
|
74
|
+
if (!SAFE_LINK_ENTRY.test(entry) || entry === '.' || entry === '..')
|
|
75
|
+
throw new ManifestError(`unsafe link entry in profile "${p.name}": ${JSON.stringify(entry)} (must be one file or directory name)`);
|
|
76
|
+
}
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
79
|
export function parseManifest(text) {
|
package/dist/rcblock.js
CHANGED
|
@@ -23,8 +23,11 @@ function renderPosix(pr, p) {
|
|
|
23
23
|
? ` export ${k}="$(ccprofiles secrets get ${v.slice(SECRET_PREFIX.length)})"`
|
|
24
24
|
: ` export ${k}="${escapePosix(v)}"`);
|
|
25
25
|
}
|
|
26
|
-
const
|
|
27
|
-
|
|
26
|
+
const codex = (pr.agent ?? 'claude') === 'codex';
|
|
27
|
+
const flag = pr.skipPermissions ? codex ? ' --dangerously-bypass-approvals-and-sandbox' : ' --dangerously-skip-permissions' : '';
|
|
28
|
+
const home = codex ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR';
|
|
29
|
+
const command = codex ? 'codex' : 'claude';
|
|
30
|
+
lines.push(` ${home}="${profileDirExpr(pr, p)}" ${command}${flag} "$@"`, '}');
|
|
28
31
|
return lines.join('\n');
|
|
29
32
|
}
|
|
30
33
|
function renderPwsh(pr, p) {
|
|
@@ -34,8 +37,11 @@ function renderPwsh(pr, p) {
|
|
|
34
37
|
? ` $env:${k} = (ccprofiles secrets get ${v.slice(SECRET_PREFIX.length)})`
|
|
35
38
|
: ` $env:${k} = "${escapePwsh(v)}"`);
|
|
36
39
|
}
|
|
37
|
-
const
|
|
38
|
-
|
|
40
|
+
const codex = (pr.agent ?? 'claude') === 'codex';
|
|
41
|
+
const flag = pr.skipPermissions ? codex ? ' --dangerously-bypass-approvals-and-sandbox' : ' --dangerously-skip-permissions' : '';
|
|
42
|
+
const home = codex ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR';
|
|
43
|
+
const command = codex ? 'codex' : 'claude';
|
|
44
|
+
lines.push(` $env:${home} = "${profileDirExpr(pr, p)}"`, ` ${command}${flag} @args`, '}');
|
|
39
45
|
return lines.join('\n');
|
|
40
46
|
}
|
|
41
47
|
export function renderRcBlock(m, p) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface SessionMeta {
|
|
2
|
+
id: string;
|
|
3
|
+
mtime: number;
|
|
4
|
+
messageCount: number;
|
|
5
|
+
firstPrompt: string | null;
|
|
6
|
+
gitBranch: string | null;
|
|
7
|
+
model: string | null;
|
|
8
|
+
sizeBytes: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ProjectSessions {
|
|
11
|
+
agent: 'claude' | 'codex';
|
|
12
|
+
scope: string;
|
|
13
|
+
project: string;
|
|
14
|
+
sessions: SessionMeta[];
|
|
15
|
+
}
|
|
16
|
+
/** Best-effort decode of Claude Code's project dir name (cwd from a record is preferred). */
|
|
17
|
+
export declare function decodeProjectDir(name: string): string;
|
|
18
|
+
export declare function scanSessions(opts: {
|
|
19
|
+
sharedRoot: string;
|
|
20
|
+
profiles: {
|
|
21
|
+
name: string;
|
|
22
|
+
dir: string;
|
|
23
|
+
agent?: 'claude' | 'codex';
|
|
24
|
+
}[];
|
|
25
|
+
}): Promise<ProjectSessions[]>;
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readdir, readFile, stat, lstat } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** Best-effort decode of Claude Code's project dir name (cwd from a record is preferred). */
|
|
5
|
+
export function decodeProjectDir(name) {
|
|
6
|
+
return name.replace(/^-/, '/').replace(/-/g, '/');
|
|
7
|
+
}
|
|
8
|
+
async function parseClaudeSession(file) {
|
|
9
|
+
let raw;
|
|
10
|
+
try {
|
|
11
|
+
raw = await readFile(file, 'utf8');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const st = await stat(file);
|
|
17
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
18
|
+
let firstPrompt = null, gitBranch = null, model = null, cwd = null;
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
let rec;
|
|
21
|
+
try {
|
|
22
|
+
rec = JSON.parse(line);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (cwd === null && typeof rec.cwd === 'string')
|
|
28
|
+
cwd = rec.cwd;
|
|
29
|
+
if (gitBranch === null && typeof rec.gitBranch === 'string')
|
|
30
|
+
gitBranch = rec.gitBranch;
|
|
31
|
+
if (firstPrompt === null && rec.type === 'user') {
|
|
32
|
+
const c = rec.message?.content;
|
|
33
|
+
const text = typeof c === 'string' ? c : Array.isArray(c) ? c.find((x) => x?.type === 'text')?.text : null;
|
|
34
|
+
if (typeof text === 'string' && text.trim() && !text.trimStart().startsWith('<'))
|
|
35
|
+
firstPrompt = text.trim().slice(0, 200);
|
|
36
|
+
}
|
|
37
|
+
if (model === null && typeof rec.message?.model === 'string')
|
|
38
|
+
model = rec.message.model;
|
|
39
|
+
if (firstPrompt && model && gitBranch && cwd)
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
const id = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
|
|
43
|
+
return { meta: { id, mtime: st.mtimeMs, messageCount: lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
|
|
44
|
+
}
|
|
45
|
+
async function scanProjectsDir(projectsDir, scope) {
|
|
46
|
+
if (!existsSync(projectsDir))
|
|
47
|
+
return [];
|
|
48
|
+
const out = [];
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = await readdir(projectsDir, { withFileTypes: true });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
for (const proj of entries) {
|
|
57
|
+
if (!proj.isDirectory())
|
|
58
|
+
continue;
|
|
59
|
+
const pdir = join(projectsDir, proj.name);
|
|
60
|
+
const sessions = [];
|
|
61
|
+
let cwd = null;
|
|
62
|
+
let files;
|
|
63
|
+
try {
|
|
64
|
+
files = await readdir(pdir);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
if (!f.endsWith('.jsonl'))
|
|
71
|
+
continue;
|
|
72
|
+
const parsed = await parseClaudeSession(join(pdir, f));
|
|
73
|
+
if (!parsed)
|
|
74
|
+
continue;
|
|
75
|
+
sessions.push(parsed.meta);
|
|
76
|
+
if (cwd === null)
|
|
77
|
+
cwd = parsed.cwd;
|
|
78
|
+
}
|
|
79
|
+
if (!sessions.length)
|
|
80
|
+
continue;
|
|
81
|
+
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
82
|
+
out.push({ agent: 'claude', scope, project: cwd ?? decodeProjectDir(proj.name), sessions });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function messageText(content) {
|
|
87
|
+
if (typeof content === 'string')
|
|
88
|
+
return content;
|
|
89
|
+
if (!Array.isArray(content))
|
|
90
|
+
return null;
|
|
91
|
+
const item = content.find((x) => x?.type === 'input_text' || x?.type === 'text');
|
|
92
|
+
return typeof item?.text === 'string' ? item.text : null;
|
|
93
|
+
}
|
|
94
|
+
async function parseCodexSession(file) {
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
raw = await readFile(file, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const st = await stat(file);
|
|
103
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
104
|
+
let id = null, firstPrompt = null, gitBranch = null;
|
|
105
|
+
let model = null, cwd = null, messageCount = 0;
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
let rec;
|
|
108
|
+
try {
|
|
109
|
+
rec = JSON.parse(line);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const payload = rec?.payload;
|
|
115
|
+
if (rec?.type === 'session_meta') {
|
|
116
|
+
if (typeof payload?.id === 'string')
|
|
117
|
+
id = payload.id;
|
|
118
|
+
else if (typeof payload?.session_id === 'string')
|
|
119
|
+
id = payload.session_id;
|
|
120
|
+
if (typeof payload?.cwd === 'string')
|
|
121
|
+
cwd = payload.cwd;
|
|
122
|
+
if (typeof payload?.git?.branch === 'string')
|
|
123
|
+
gitBranch = payload.git.branch;
|
|
124
|
+
}
|
|
125
|
+
if (rec?.type === 'turn_context') {
|
|
126
|
+
if (cwd === null && typeof payload?.cwd === 'string')
|
|
127
|
+
cwd = payload.cwd;
|
|
128
|
+
if (model === null && typeof payload?.model === 'string')
|
|
129
|
+
model = payload.model;
|
|
130
|
+
}
|
|
131
|
+
if (rec?.type === 'response_item' && payload?.type === 'message'
|
|
132
|
+
&& (payload?.role === 'user' || payload?.role === 'assistant')) {
|
|
133
|
+
messageCount++;
|
|
134
|
+
if (firstPrompt === null && payload.role === 'user') {
|
|
135
|
+
const text = messageText(payload.content);
|
|
136
|
+
if (text?.trim() && !text.trimStart().startsWith('<'))
|
|
137
|
+
firstPrompt = text.trim().slice(0, 200);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (firstPrompt === null && rec?.type === 'event_msg' && payload?.type === 'user_message'
|
|
141
|
+
&& typeof payload?.message === 'string' && payload.message.trim()) {
|
|
142
|
+
firstPrompt = payload.message.trim().slice(0, 200);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const filename = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
|
|
146
|
+
id ??= filename.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? filename;
|
|
147
|
+
return { meta: { id, mtime: st.mtimeMs, messageCount: messageCount || lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
|
|
148
|
+
}
|
|
149
|
+
async function codexSessionFiles(dir) {
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
const path = join(dir, entry.name);
|
|
160
|
+
if (entry.isDirectory())
|
|
161
|
+
out.push(...await codexSessionFiles(path));
|
|
162
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl'))
|
|
163
|
+
out.push(path);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
async function scanCodexSessionsDir(sessionsDir, scope) {
|
|
168
|
+
const byProject = new Map();
|
|
169
|
+
for (const file of await codexSessionFiles(sessionsDir)) {
|
|
170
|
+
const parsed = await parseCodexSession(file);
|
|
171
|
+
if (!parsed)
|
|
172
|
+
continue;
|
|
173
|
+
const project = parsed.cwd ?? '(unknown project)';
|
|
174
|
+
const sessions = byProject.get(project) ?? [];
|
|
175
|
+
sessions.push(parsed.meta);
|
|
176
|
+
byProject.set(project, sessions);
|
|
177
|
+
}
|
|
178
|
+
return [...byProject.entries()].map(([project, sessions]) => ({
|
|
179
|
+
agent: 'codex', scope, project,
|
|
180
|
+
sessions: sessions.sort((a, b) => b.mtime - a.mtime),
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
export async function scanSessions(opts) {
|
|
184
|
+
const out = [];
|
|
185
|
+
out.push(...await scanProjectsDir(join(opts.sharedRoot, 'projects'), 'shared'));
|
|
186
|
+
out.push(...await scanCodexSessionsDir(join(opts.sharedRoot, 'sessions'), 'shared'));
|
|
187
|
+
for (const p of opts.profiles) {
|
|
188
|
+
const agent = p.agent ?? 'claude';
|
|
189
|
+
const sessionDir = join(p.dir, agent === 'codex' ? 'sessions' : 'projects');
|
|
190
|
+
try {
|
|
191
|
+
if ((await lstat(sessionDir)).isSymbolicLink())
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
out.push(...agent === 'codex'
|
|
198
|
+
? await scanCodexSessionsDir(sessionDir, p.name)
|
|
199
|
+
: await scanProjectsDir(sessionDir, p.name));
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccprofiles-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Core library for ccprofiles — discovery, manifest, secrets, apply, and LAN sync for Claude Code multi-account setups",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"node": ">=20"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"smol-toml": "^1.7.0",
|
|
30
31
|
"yaml": "^2.5.0",
|
|
31
32
|
"zod": "^3.23.0"
|
|
32
33
|
}
|