ccprofiles-core 0.5.1 → 0.7.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 +15 -0
- package/dist/adopt.d.ts +1 -1
- package/dist/adopt.js +24 -3
- package/dist/apply.d.ts +4 -1
- package/dist/apply.js +43 -13
- package/dist/assets.d.ts +10 -2
- package/dist/assets.js +15 -5
- package/dist/codex.d.ts +4 -1
- package/dist/codex.js +10 -3
- package/dist/devices.js +1 -1
- package/dist/discovery.d.ts +4 -0
- package/dist/discovery.js +19 -0
- package/dist/fsutil.d.ts +3 -1
- package/dist/fsutil.js +14 -6
- package/dist/handoff.d.ts +21 -0
- package/dist/handoff.js +38 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/manifest.d.ts +35 -0
- package/dist/manifest.js +74 -5
- package/dist/platform.js +8 -1
- package/dist/plugins.d.ts +21 -0
- package/dist/plugins.js +56 -0
- package/dist/provider.d.ts +10 -0
- package/dist/provider.js +30 -0
- package/dist/rcblock.js +2 -0
- package/dist/secrets.js +5 -5
- package/dist/sessions.d.ts +35 -0
- package/dist/sessions.js +291 -67
- package/package.json +12 -2
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ccprofiles-core
|
|
2
|
+
|
|
3
|
+
Core library powering [`claude-account-sync`](https://www.npmjs.com/package/claude-account-sync) — the `clp` / `ccprofiles` CLI for managing multiple Claude Code and OpenAI Codex accounts.
|
|
4
|
+
|
|
5
|
+
It provides the discovery, manifest, secrets, apply, and LAN-sync logic used by the CLI and its dashboard.
|
|
6
|
+
|
|
7
|
+
- Adopts existing `.claude*` / `.codex*` homes into a declarative manifest
|
|
8
|
+
- Applies profile config (launchers, env, links, MCP servers) with surgical, backed-up edits
|
|
9
|
+
- Reads/writes secrets across macOS Keychain, libsecret, and encrypted-file backends
|
|
10
|
+
- Computes MCP server drift across profiles and both agents (Claude + Codex)
|
|
11
|
+
- Drives encrypted, PIN-paired LAN replication between machines
|
|
12
|
+
|
|
13
|
+
Repo: [github.com/tmfadhlul/claude-code-profile](https://github.com/tmfadhlul/claude-code-profile)
|
|
14
|
+
|
|
15
|
+
Most users want the CLI, not this library — install `claude-account-sync` (`npm install -g claude-account-sync`) instead unless you're building on top of ccprofiles' internals.
|
package/dist/adopt.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type LiveProfile } from './discovery.js';
|
|
2
2
|
import type { Manifest } from './manifest.js';
|
|
3
3
|
import { type Platform } from './platform.js';
|
|
4
|
-
export declare function buildManifest(live: LiveProfile[], platform: Platform): Manifest;
|
|
4
|
+
export declare function buildManifest(live: LiveProfile[], platform: Platform, sharedRoot?: string): Manifest;
|
|
5
5
|
/**
|
|
6
6
|
* Re-migrate secret refs after a snapshot/adopt rebuilds the manifest from live state.
|
|
7
7
|
* Live settings.json holds resolved plaintext values, so a freshly built manifest would
|
package/dist/adopt.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import { liveProfileName } from './discovery.js';
|
|
2
3
|
import { toTemplate } from './platform.js';
|
|
3
|
-
|
|
4
|
+
import { CLAUDE_SHARED_ENTRIES, CODEX_SHARED_ENTRIES } from './apply.js';
|
|
5
|
+
export function buildManifest(live, platform, sharedRoot = join(platform.home, '.ccprofiles', 'shared')) {
|
|
6
|
+
// Same pool root planApply uses by default (join(home, '.ccprofiles', 'shared')), but
|
|
7
|
+
// callers with a custom CCPROFILES_HOME should pass join(ctx.manifestRoot, 'shared')
|
|
8
|
+
// instead. A profile-dir entry symlinked to <sharedRoot>/<entry> is a pooled session
|
|
9
|
+
// dir, not a plain link — recording it as a link would make the next apply think
|
|
10
|
+
// sharing is off and "unshare" it, copying the whole shared pool back into this one
|
|
11
|
+
// profile's dir.
|
|
4
12
|
const mcpServers = {};
|
|
5
13
|
for (const lp of live)
|
|
6
14
|
for (const [name, def] of Object.entries(lp.mcpServers))
|
|
7
15
|
mcpServers[name] ??= def;
|
|
16
|
+
const marketplaces = {};
|
|
17
|
+
for (const lp of live)
|
|
18
|
+
for (const [name, def] of Object.entries(lp.marketplaces ?? {}))
|
|
19
|
+
marketplaces[name] ??= def;
|
|
8
20
|
// hub = profile whose dir is the most common link target prefix
|
|
9
21
|
const linkVotes = new Map();
|
|
10
22
|
for (const lp of live)
|
|
@@ -18,8 +30,14 @@ export function buildManifest(live, platform) {
|
|
|
18
30
|
const hub = hubLive ? liveProfileName(hubLive) : null;
|
|
19
31
|
const profiles = live.map(lp => {
|
|
20
32
|
const name = liveProfileName(lp);
|
|
33
|
+
const sessionEntries = lp.agent === 'codex' ? CODEX_SHARED_ENTRIES : CLAUDE_SHARED_ENTRIES;
|
|
21
34
|
const links = {};
|
|
35
|
+
let sharedSessions = false;
|
|
22
36
|
for (const [entry, target] of Object.entries(lp.links)) {
|
|
37
|
+
if (sessionEntries.includes(entry) && target === join(sharedRoot, entry)) {
|
|
38
|
+
sharedSessions = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
23
41
|
const isHubLink = hubDirName && target.startsWith(live.find(o => o.dirName === hubDirName).dir);
|
|
24
42
|
links[entry] = isHubLink ? 'hub' : toTemplate(target, platform);
|
|
25
43
|
}
|
|
@@ -35,10 +53,13 @@ export function buildManifest(live, platform) {
|
|
|
35
53
|
mcp: Object.keys(lp.mcpServers).sort(),
|
|
36
54
|
settingsEnv: lp.settingsEnv,
|
|
37
55
|
skipPermissions: false,
|
|
38
|
-
sharedSessions
|
|
56
|
+
sharedSessions,
|
|
57
|
+
plugins: Object.entries(lp.enabledPlugins ?? {})
|
|
58
|
+
.filter(([k, v]) => v && marketplaces[k.slice(k.lastIndexOf('@') + 1)])
|
|
59
|
+
.map(([k]) => k).sort(),
|
|
39
60
|
};
|
|
40
61
|
});
|
|
41
|
-
return { version: 1, hub, profiles, mcpServers };
|
|
62
|
+
return { version: 1, hub, profiles, mcpServers, marketplaces };
|
|
42
63
|
}
|
|
43
64
|
/**
|
|
44
65
|
* Re-migrate secret refs after a snapshot/adopt rebuilds the manifest from live state.
|
package/dist/apply.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Manifest, McpServerDef } from './manifest.js';
|
|
2
2
|
import type { LiveProfile } from './discovery.js';
|
|
3
|
-
import { type Platform } from './platform.js';
|
|
3
|
+
import { type Platform, type OsKind } from './platform.js';
|
|
4
4
|
export type ApplyAction = {
|
|
5
5
|
kind: 'set-mcp-servers';
|
|
6
6
|
agent: 'claude' | 'codex';
|
|
@@ -30,6 +30,9 @@ export type ApplyAction = {
|
|
|
30
30
|
from: string;
|
|
31
31
|
to: string;
|
|
32
32
|
};
|
|
33
|
+
export declare const CLAUDE_SHARED_ENTRIES: readonly ["projects", "todos", "shell-snapshots"];
|
|
34
|
+
export declare const CODEX_SHARED_ENTRIES: readonly ["sessions"];
|
|
35
|
+
export declare function isWithin(parent: string, child: string, osKind: OsKind): boolean;
|
|
33
36
|
/** Resolve secret:// refs in every profile's settingsEnv. Throws on a missing secret. */
|
|
34
37
|
export declare function resolveSettingsEnv(m: Manifest, getSecret: (name: string) => Promise<string | null>): Promise<Record<string, Record<string, string>>>;
|
|
35
38
|
export declare function planApply(m: Manifest, live: LiveProfile[], p: Platform, resolvedSettingsEnv?: Record<string, Record<string, string>>, sharedRoot?: string): ApplyAction[];
|
package/dist/apply.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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,
|
|
3
|
+
import { join, dirname, posix as posixPath, win32 as win32Path } from 'node:path';
|
|
4
4
|
import { renderPath } from './platform.js';
|
|
5
5
|
import { renderRcBlock, upsertManagedBlock } from './rcblock.js';
|
|
6
6
|
import { atomicWrite, backupFiles, backupTree } from './fsutil.js';
|
|
@@ -15,10 +15,17 @@ function sortKeys(o) {
|
|
|
15
15
|
return Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
|
|
16
16
|
}
|
|
17
17
|
const SECRET_PREFIX = 'secret://';
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// exported so adopt.ts (buildManifest) can recognize pooled session-dir symlinks with the
|
|
19
|
+
// exact same entry list planApply uses to decide share/unshare — keeps both sides in lockstep.
|
|
20
|
+
export const CLAUDE_SHARED_ENTRIES = ['projects', 'todos', 'shell-snapshots'];
|
|
21
|
+
export const CODEX_SHARED_ENTRIES = ['sessions'];
|
|
22
|
+
// exported for direct unit testing of the win32-vs-posix separator handling below
|
|
23
|
+
export function isWithin(parent, child, osKind) {
|
|
24
|
+
// use the *target* platform's path implementation (osKind), not the host's — planApply is
|
|
25
|
+
// routinely called with an osKind that differs from process.platform (cross-platform plan/test)
|
|
26
|
+
const pathImpl = osKind === 'win32' ? win32Path : posixPath;
|
|
27
|
+
const rel = pathImpl.relative(parent, child);
|
|
28
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${pathImpl.sep}`) && !pathImpl.isAbsolute(rel));
|
|
22
29
|
}
|
|
23
30
|
/** Resolve secret:// refs in every profile's settingsEnv. Throws on a missing secret. */
|
|
24
31
|
export async function resolveSettingsEnv(m, getSecret) {
|
|
@@ -48,8 +55,18 @@ export function planApply(m, live, p, resolvedSettingsEnv, sharedRoot = join(p.h
|
|
|
48
55
|
const dir = renderPath(pr.dir, p);
|
|
49
56
|
const lp = live.find(l => l.dir === dir) ?? null;
|
|
50
57
|
const desired = {};
|
|
51
|
-
for (const name of pr.mcp)
|
|
52
|
-
|
|
58
|
+
for (const name of pr.mcp) {
|
|
59
|
+
const def = m.mcpServers[name];
|
|
60
|
+
// codex has no per-server transport-type field (writeCodexMcpServers strips it on write);
|
|
61
|
+
// keep desired in the same shape as `current` here or the drift check never converges.
|
|
62
|
+
if (agent === 'codex') {
|
|
63
|
+
const { type: _claudeTransportType, ...codexDef } = def;
|
|
64
|
+
desired[name] = codexDef;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
desired[name] = def;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
53
70
|
if (!lp)
|
|
54
71
|
actions.push({ kind: 'create-profile-dir', dir });
|
|
55
72
|
const current = lp?.mcpServers ?? null;
|
|
@@ -64,13 +81,13 @@ export function planApply(m, live, p, resolvedSettingsEnv, sharedRoot = join(p.h
|
|
|
64
81
|
const from = join(dir, physicalEntry);
|
|
65
82
|
if (from === to)
|
|
66
83
|
continue;
|
|
67
|
-
if (isWithin(from, to) || isWithin(to, from))
|
|
84
|
+
if (isWithin(from, to, p.os) || isWithin(to, from, p.os))
|
|
68
85
|
throw new Error(`unsafe link topology: ${from} -> ${to} (source and target must not contain each other)`);
|
|
69
86
|
if (lp?.links[entry] === to)
|
|
70
87
|
continue;
|
|
71
88
|
actions.push({ kind: 'link', from, to });
|
|
72
89
|
}
|
|
73
|
-
const sharedEntries = agent === 'codex' ?
|
|
90
|
+
const sharedEntries = agent === 'codex' ? CODEX_SHARED_ENTRIES : CLAUDE_SHARED_ENTRIES;
|
|
74
91
|
for (const entry of sharedEntries) {
|
|
75
92
|
const from = join(dir, entry);
|
|
76
93
|
const to = join(sharedRoot, entry);
|
|
@@ -120,14 +137,20 @@ export async function executeApply(actions, opts) {
|
|
|
120
137
|
else if (a.kind === 'set-mcp-servers') {
|
|
121
138
|
if (a.agent === 'codex') {
|
|
122
139
|
await mkdir(dirname(a.configPath), { recursive: true });
|
|
123
|
-
await writeCodexMcpServers(a.configPath, a.servers);
|
|
140
|
+
await writeCodexMcpServers(a.configPath, a.servers, { backupRoot: opts.backupRoot, stamp: opts.stamp });
|
|
124
141
|
continue;
|
|
125
142
|
}
|
|
126
143
|
let cfg = {};
|
|
127
144
|
try {
|
|
128
145
|
cfg = JSON.parse(await readFile(a.configPath, 'utf8'));
|
|
129
146
|
}
|
|
130
|
-
catch {
|
|
147
|
+
catch (e) {
|
|
148
|
+
if (e.code !== 'ENOENT') {
|
|
149
|
+
await backupFiles([a.configPath], opts.backupRoot, opts.stamp);
|
|
150
|
+
throw new Error(`refusing to overwrite unreadable ${a.configPath} — back it up and fix it, then re-apply (${e.message})`);
|
|
151
|
+
}
|
|
152
|
+
// ENOENT: genuinely new file, cfg stays {}
|
|
153
|
+
}
|
|
131
154
|
cfg.mcpServers = a.servers;
|
|
132
155
|
await mkdir(dirname(a.configPath), { recursive: true });
|
|
133
156
|
await atomicWrite(a.configPath, JSON.stringify(cfg, null, 2));
|
|
@@ -169,10 +192,17 @@ export async function executeApply(actions, opts) {
|
|
|
169
192
|
try {
|
|
170
193
|
cfg = JSON.parse(await readFile(a.settingsPath, 'utf8'));
|
|
171
194
|
}
|
|
172
|
-
catch {
|
|
195
|
+
catch (e) {
|
|
196
|
+
if (e.code !== 'ENOENT') {
|
|
197
|
+
await backupFiles([a.settingsPath], opts.backupRoot, opts.stamp);
|
|
198
|
+
throw new Error(`refusing to overwrite unreadable ${a.settingsPath} — back it up and fix it, then re-apply (${e.message})`);
|
|
199
|
+
}
|
|
200
|
+
// ENOENT: genuinely new file, cfg stays {}
|
|
201
|
+
}
|
|
173
202
|
cfg.env = a.env;
|
|
174
203
|
await mkdir(dirname(a.settingsPath), { recursive: true });
|
|
175
|
-
|
|
204
|
+
// Resolved secret:// refs land here as plaintext env values — keep it 0600.
|
|
205
|
+
await atomicWrite(a.settingsPath, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
176
206
|
}
|
|
177
207
|
else if (a.kind === 'share-session-dir') {
|
|
178
208
|
await mkdir(a.to, { recursive: true });
|
package/dist/assets.d.ts
CHANGED
|
@@ -2,5 +2,13 @@ import type { Manifest } from './manifest.js';
|
|
|
2
2
|
import { type Platform } from './platform.js';
|
|
3
3
|
/** Collect shareable assets as a relative-path → utf8-content map. */
|
|
4
4
|
export declare function collectAssets(m: Manifest, p: Platform): Promise<Record<string, string>>;
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
export interface WriteAssetsBackup {
|
|
6
|
+
backupRoot: string;
|
|
7
|
+
stamp: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Write an asset map back to the live layout. Callers with an existing hub — i.e. `sync
|
|
11
|
+
* --from`/`bundle import` overwriting local skills/commands/guidance — should pass `backup`
|
|
12
|
+
* so any pre-existing target is preserved under `<backupRoot>/<stamp>/` before it's clobbered.
|
|
13
|
+
*/
|
|
14
|
+
export declare function writeAssets(assets: Record<string, string>, m: Manifest, p: Platform, backup?: WriteAssetsBackup): Promise<void>;
|
package/dist/assets.js
CHANGED
|
@@ -2,7 +2,7 @@ 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
|
-
import { atomicWrite } from './fsutil.js';
|
|
5
|
+
import { atomicWrite, backupFiles } from './fsutil.js';
|
|
6
6
|
import { physicalLinkEntry } from './links.js';
|
|
7
7
|
const HUB_DIRS = ['skills', 'commands', 'plugins'];
|
|
8
8
|
function safeParts(rel) {
|
|
@@ -64,8 +64,12 @@ export async function collectAssets(m, p) {
|
|
|
64
64
|
}
|
|
65
65
|
return assets;
|
|
66
66
|
}
|
|
67
|
-
/**
|
|
68
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Write an asset map back to the live layout. Callers with an existing hub — i.e. `sync
|
|
69
|
+
* --from`/`bundle import` overwriting local skills/commands/guidance — should pass `backup`
|
|
70
|
+
* so any pre-existing target is preserved under `<backupRoot>/<stamp>/` before it's clobbered.
|
|
71
|
+
*/
|
|
72
|
+
export async function writeAssets(assets, m, p, backup) {
|
|
69
73
|
const hub = m.profiles.find(x => x.name === m.hub);
|
|
70
74
|
for (const [rel, content] of Object.entries(assets)) {
|
|
71
75
|
const parts = safeParts(rel);
|
|
@@ -75,7 +79,10 @@ export async function writeAssets(assets, m, p) {
|
|
|
75
79
|
throw new Error(`unsafe asset path: ${JSON.stringify(rel)}`);
|
|
76
80
|
const root = join(renderPath(hub.dir, p), physicalLinkEntry(hub.agent ?? 'claude', logicalEntry));
|
|
77
81
|
await assertNoSymlinkParents(root, rest);
|
|
78
|
-
|
|
82
|
+
const target = join(root, ...rest);
|
|
83
|
+
if (backup && existsSync(target))
|
|
84
|
+
await backupFiles([target], backup.backupRoot, backup.stamp);
|
|
85
|
+
await atomicWrite(target, content);
|
|
79
86
|
}
|
|
80
87
|
else if (parts[0] === 'profiles') {
|
|
81
88
|
const [, name, ...rest] = parts;
|
|
@@ -87,7 +94,10 @@ export async function writeAssets(assets, m, p) {
|
|
|
87
94
|
throw new Error(`unsafe profile asset path: ${JSON.stringify(rel)}`);
|
|
88
95
|
const root = renderPath(pr.dir, p);
|
|
89
96
|
await assertNoSymlinkParents(root, rest);
|
|
90
|
-
|
|
97
|
+
const target = join(root, guidanceName);
|
|
98
|
+
if (backup && existsSync(target))
|
|
99
|
+
await backupFiles([target], backup.backupRoot, backup.stamp);
|
|
100
|
+
await atomicWrite(target, content);
|
|
91
101
|
}
|
|
92
102
|
else
|
|
93
103
|
throw new Error(`unsafe asset path: ${JSON.stringify(rel)}`);
|
package/dist/codex.d.ts
CHANGED
|
@@ -7,4 +7,7 @@ import type { McpServerDef } from './manifest.js';
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function isProjectScopedMcpServer(def: McpServerDef): boolean;
|
|
9
9
|
export declare function readCodexMcpServers(configPath: string): Promise<Record<string, McpServerDef>>;
|
|
10
|
-
export declare function writeCodexMcpServers(configPath: string, servers: Record<string, McpServerDef
|
|
10
|
+
export declare function writeCodexMcpServers(configPath: string, servers: Record<string, McpServerDef>, backup?: {
|
|
11
|
+
backupRoot: string;
|
|
12
|
+
stamp: string;
|
|
13
|
+
}): Promise<void>;
|
package/dist/codex.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { parse, stringify } from 'smol-toml';
|
|
3
|
-
import { atomicWrite } from './fsutil.js';
|
|
3
|
+
import { atomicWrite, backupFiles } from './fsutil.js';
|
|
4
4
|
/**
|
|
5
5
|
* Codex keeps every MCP server in one flat global `mcp_servers` table with no scope
|
|
6
6
|
* marker, so per-project launchers (e.g. CCE's `cce serve --project-dir <path>`) sit
|
|
@@ -30,12 +30,19 @@ export async function readCodexMcpServers(configPath) {
|
|
|
30
30
|
// user scope only — hide per-project launchers from the drift matrix, adopt, and sync
|
|
31
31
|
.filter(([, def]) => !isProjectScopedMcpServer(def)));
|
|
32
32
|
}
|
|
33
|
-
export async function writeCodexMcpServers(configPath, servers) {
|
|
33
|
+
export async function writeCodexMcpServers(configPath, servers, backup) {
|
|
34
34
|
let config = {};
|
|
35
35
|
try {
|
|
36
36
|
config = parse(await readFile(configPath, 'utf8'));
|
|
37
37
|
}
|
|
38
|
-
catch {
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (e.code !== 'ENOENT') {
|
|
40
|
+
if (backup)
|
|
41
|
+
await backupFiles([configPath], backup.backupRoot, backup.stamp);
|
|
42
|
+
throw new Error(`refusing to overwrite unreadable ${configPath} — back it up and fix it, then re-apply (${e.message})`);
|
|
43
|
+
}
|
|
44
|
+
// ENOENT: genuinely new config, config stays {}
|
|
45
|
+
}
|
|
39
46
|
// Preserve project-scoped launchers already in the file: clp hides them from management,
|
|
40
47
|
// so a plain replace would silently delete them. Only user-scope servers are clp-managed.
|
|
41
48
|
const existing = config.mcp_servers && typeof config.mcp_servers === 'object' && !Array.isArray(config.mcp_servers)
|
package/dist/devices.js
CHANGED
|
@@ -10,7 +10,7 @@ export async function loadDevices(root) {
|
|
|
10
10
|
return JSON.parse(await readFile(f, 'utf8'));
|
|
11
11
|
}
|
|
12
12
|
export async function saveDevices(root, list) {
|
|
13
|
-
await atomicWrite(join(root, 'devices.json'), JSON.stringify(list, null, 2));
|
|
13
|
+
await atomicWrite(join(root, 'devices.json'), JSON.stringify(list, null, 2), { mode: 0o600 });
|
|
14
14
|
}
|
|
15
15
|
/** Keep secret-bearing files out of the manifest repo's auto-commits. */
|
|
16
16
|
export async function ensureRootGitignore(root) {
|
package/dist/discovery.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ export interface LiveProfile {
|
|
|
9
9
|
mcpServers: Record<string, McpServerDef>;
|
|
10
10
|
links: Record<string, string>;
|
|
11
11
|
settingsEnv: Record<string, string>;
|
|
12
|
+
enabledPlugins: Record<string, boolean>;
|
|
13
|
+
marketplaces: Record<string, {
|
|
14
|
+
source: string;
|
|
15
|
+
}>;
|
|
12
16
|
}
|
|
13
17
|
export declare function discoverProfiles(home: string): Promise<LiveProfile[]>;
|
|
14
18
|
export declare function liveProfileName(lp: Pick<LiveProfile, 'agent' | 'dirName'>): string;
|
package/dist/discovery.js
CHANGED
|
@@ -59,6 +59,7 @@ export async function discoverProfiles(home) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
const settingsEnv = {};
|
|
62
|
+
const enabledPlugins = {};
|
|
62
63
|
if (agent === 'claude')
|
|
63
64
|
try {
|
|
64
65
|
const s = JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8'));
|
|
@@ -66,8 +67,24 @@ export async function discoverProfiles(home) {
|
|
|
66
67
|
for (const [k, v] of Object.entries(s.env))
|
|
67
68
|
if (typeof v === 'string')
|
|
68
69
|
settingsEnv[k] = v;
|
|
70
|
+
if (s && typeof s.enabledPlugins === 'object' && s.enabledPlugins !== null)
|
|
71
|
+
for (const [k, v] of Object.entries(s.enabledPlugins))
|
|
72
|
+
if (typeof v === 'boolean')
|
|
73
|
+
enabledPlugins[k] = v;
|
|
69
74
|
}
|
|
70
75
|
catch { /* no settings.json */ }
|
|
76
|
+
const marketplaces = {};
|
|
77
|
+
if (agent === 'claude')
|
|
78
|
+
try {
|
|
79
|
+
const km = JSON.parse(await readFile(join(dir, 'plugins', 'known_marketplaces.json'), 'utf8'));
|
|
80
|
+
if (km && typeof km === 'object')
|
|
81
|
+
for (const [name, v] of Object.entries(km)) {
|
|
82
|
+
const repo = v?.source?.repo;
|
|
83
|
+
if (typeof repo === 'string')
|
|
84
|
+
marketplaces[name] = { source: repo };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch { /* no plugins/known_marketplaces.json */ }
|
|
71
88
|
out.push({
|
|
72
89
|
agent,
|
|
73
90
|
dirName: e.name,
|
|
@@ -78,6 +95,8 @@ export async function discoverProfiles(home) {
|
|
|
78
95
|
mcpServers: agent === 'claude' ? cfg?.mcpServers ?? {} : await readCodexMcpServers(configPath),
|
|
79
96
|
links,
|
|
80
97
|
settingsEnv,
|
|
98
|
+
enabledPlugins,
|
|
99
|
+
marketplaces,
|
|
81
100
|
});
|
|
82
101
|
}
|
|
83
102
|
return out.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
package/dist/fsutil.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
export declare function atomicWrite(filePath: string, content: string
|
|
1
|
+
export declare function atomicWrite(filePath: string, content: string, opts?: {
|
|
2
|
+
mode?: number;
|
|
3
|
+
}): Promise<void>;
|
|
2
4
|
export declare function backupFiles(files: string[], backupRoot: string, stamp: string): Promise<string>;
|
|
3
5
|
export declare function backupTree(src: string, backupRoot: string, stamp: string): Promise<string | null>;
|
package/dist/fsutil.js
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
import { copyFile, cp, mkdir, rename, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { chmod, 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
|
-
export async function atomicWrite(filePath, content) {
|
|
5
|
-
|
|
4
|
+
export async function atomicWrite(filePath, content, opts) {
|
|
5
|
+
// Secret-bearing writes (opts.mode set) also lock down the containing dir — it may be
|
|
6
|
+
// manifestRoot itself if this is the first file ever written there (e.g. `secrets set`
|
|
7
|
+
// before `adopt`/`manifest init` has created it).
|
|
8
|
+
await mkdir(dirname(filePath), opts?.mode ? { recursive: true, mode: 0o700 } : { recursive: true });
|
|
6
9
|
const tmp = `${filePath}.ccp-tmp`;
|
|
7
|
-
|
|
10
|
+
const mode = opts?.mode ?? 0o644;
|
|
11
|
+
await writeFile(tmp, content, { encoding: 'utf8', mode });
|
|
8
12
|
await rename(tmp, filePath);
|
|
13
|
+
// rename preserves the temp file's mode, but chmod explicitly to sidestep umask surprises.
|
|
14
|
+
await chmod(filePath, mode);
|
|
9
15
|
}
|
|
10
16
|
function sanitize(absPath) {
|
|
11
17
|
return absPath.replace(/:/g, '').replace(/[\\/]+/g, '__').replace(/^__/, '');
|
|
12
18
|
}
|
|
13
19
|
export async function backupFiles(files, backupRoot, stamp) {
|
|
14
20
|
const dir = join(backupRoot, stamp);
|
|
15
|
-
|
|
21
|
+
// Backups can contain copies of secret-bearing files (e.g. settings.json with resolved
|
|
22
|
+
// tokens) — keep the backup dir as locked down as the originals.
|
|
23
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
16
24
|
for (const f of files) {
|
|
17
25
|
if (!existsSync(f))
|
|
18
26
|
continue;
|
|
@@ -25,7 +33,7 @@ export async function backupTree(src, backupRoot, stamp) {
|
|
|
25
33
|
return null;
|
|
26
34
|
const dir = join(backupRoot, stamp);
|
|
27
35
|
const dest = join(dir, sanitize(src));
|
|
28
|
-
await mkdir(dir, { recursive: true });
|
|
36
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
29
37
|
await cp(src, dest, { recursive: true });
|
|
30
38
|
return dest;
|
|
31
39
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ProjectSessions, SessionTranscript } from './sessions.js';
|
|
2
|
+
export declare function findLastSessionForCwd(scanned: ProjectSessions[], cwd: string, scope: string, agent: 'claude' | 'codex'): {
|
|
3
|
+
scope: string;
|
|
4
|
+
id: string;
|
|
5
|
+
} | null;
|
|
6
|
+
export declare function renderHandoffMarkdown(t: SessionTranscript): string;
|
|
7
|
+
export declare function buildHandoffLaunch(opts: {
|
|
8
|
+
targetAgent: 'claude' | 'codex';
|
|
9
|
+
targetDir: string;
|
|
10
|
+
targetEnv: Record<string, string>;
|
|
11
|
+
skipPermissions: boolean;
|
|
12
|
+
transcriptPath: string;
|
|
13
|
+
srcName: string;
|
|
14
|
+
srcAgent: 'claude' | 'codex';
|
|
15
|
+
cwd: string;
|
|
16
|
+
}): {
|
|
17
|
+
command: string;
|
|
18
|
+
args: string[];
|
|
19
|
+
env: Record<string, string>;
|
|
20
|
+
cwd: string;
|
|
21
|
+
};
|
package/dist/handoff.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function findLastSessionForCwd(scanned, cwd, scope, agent) {
|
|
2
|
+
let newest = null;
|
|
3
|
+
for (const p of scanned) {
|
|
4
|
+
if (p.project !== cwd || p.scope !== scope || p.agent !== agent)
|
|
5
|
+
continue;
|
|
6
|
+
for (const s of p.sessions)
|
|
7
|
+
if (!newest || s.mtime > newest.mtime)
|
|
8
|
+
newest = { id: s.id, mtime: s.mtime };
|
|
9
|
+
}
|
|
10
|
+
return newest ? { scope, id: newest.id } : null;
|
|
11
|
+
}
|
|
12
|
+
export function renderHandoffMarkdown(t) {
|
|
13
|
+
const lines = [
|
|
14
|
+
'# Session handoff', '',
|
|
15
|
+
`- **From:** ${t.agent}`,
|
|
16
|
+
`- **Project:** ${t.project}`,
|
|
17
|
+
`- **Session:** ${t.id}`,
|
|
18
|
+
'', '---', '',
|
|
19
|
+
];
|
|
20
|
+
for (const m of t.messages) {
|
|
21
|
+
const heading = m.role === 'tool'
|
|
22
|
+
? `## Tool${m.label ? ` — ${m.label}` : ''}`
|
|
23
|
+
: `## ${m.role === 'user' ? 'User' : 'Assistant'}`;
|
|
24
|
+
lines.push(heading, '', m.text, '');
|
|
25
|
+
}
|
|
26
|
+
return lines.join('\n');
|
|
27
|
+
}
|
|
28
|
+
export function buildHandoffLaunch(opts) {
|
|
29
|
+
const homeVar = opts.targetAgent === 'codex' ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR';
|
|
30
|
+
const command = opts.targetAgent === 'codex' ? 'codex' : 'claude';
|
|
31
|
+
const skipFlag = opts.skipPermissions
|
|
32
|
+
? (opts.targetAgent === 'codex' ? '--dangerously-bypass-approvals-and-sandbox' : '--dangerously-skip-permissions')
|
|
33
|
+
: null;
|
|
34
|
+
const seedPrompt = `Continuing a session handed off from '${opts.srcName}' (${opts.srcAgent}). `
|
|
35
|
+
+ `The full prior transcript is at ${opts.transcriptPath}. Read it, then pick up where it left off.`;
|
|
36
|
+
const args = skipFlag ? [skipFlag, seedPrompt] : [seedPrompt];
|
|
37
|
+
return { command, args, env: { [homeVar]: opts.targetDir, ...opts.targetEnv }, cwd: opts.cwd };
|
|
38
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/manifest.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ declare const ProfileSchema: z.ZodObject<{
|
|
|
44
44
|
settingsEnv: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
45
45
|
skipPermissions: z.ZodDefault<z.ZodBoolean>;
|
|
46
46
|
sharedSessions: z.ZodDefault<z.ZodBoolean>;
|
|
47
|
+
plugins: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
47
48
|
}, "strip", z.ZodTypeAny, {
|
|
48
49
|
env: Record<string, string>;
|
|
49
50
|
name: string;
|
|
@@ -55,6 +56,7 @@ declare const ProfileSchema: z.ZodObject<{
|
|
|
55
56
|
settingsEnv: Record<string, string>;
|
|
56
57
|
skipPermissions: boolean;
|
|
57
58
|
sharedSessions: boolean;
|
|
59
|
+
plugins: string[];
|
|
58
60
|
agent?: "claude" | "codex" | undefined;
|
|
59
61
|
}, {
|
|
60
62
|
name: string;
|
|
@@ -68,6 +70,14 @@ declare const ProfileSchema: z.ZodObject<{
|
|
|
68
70
|
settingsEnv?: Record<string, string> | undefined;
|
|
69
71
|
skipPermissions?: boolean | undefined;
|
|
70
72
|
sharedSessions?: boolean | undefined;
|
|
73
|
+
plugins?: string[] | undefined;
|
|
74
|
+
}>;
|
|
75
|
+
declare const MarketplaceSchema: z.ZodObject<{
|
|
76
|
+
source: z.ZodString;
|
|
77
|
+
}, "strip", z.ZodTypeAny, {
|
|
78
|
+
source: string;
|
|
79
|
+
}, {
|
|
80
|
+
source: string;
|
|
71
81
|
}>;
|
|
72
82
|
declare const ManifestSchema: z.ZodObject<{
|
|
73
83
|
version: z.ZodLiteral<1>;
|
|
@@ -84,6 +94,7 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
84
94
|
settingsEnv: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
85
95
|
skipPermissions: z.ZodDefault<z.ZodBoolean>;
|
|
86
96
|
sharedSessions: z.ZodDefault<z.ZodBoolean>;
|
|
97
|
+
plugins: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
87
98
|
}, "strip", z.ZodTypeAny, {
|
|
88
99
|
env: Record<string, string>;
|
|
89
100
|
name: string;
|
|
@@ -95,6 +106,7 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
95
106
|
settingsEnv: Record<string, string>;
|
|
96
107
|
skipPermissions: boolean;
|
|
97
108
|
sharedSessions: boolean;
|
|
109
|
+
plugins: string[];
|
|
98
110
|
agent?: "claude" | "codex" | undefined;
|
|
99
111
|
}, {
|
|
100
112
|
name: string;
|
|
@@ -108,6 +120,7 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
108
120
|
settingsEnv?: Record<string, string> | undefined;
|
|
109
121
|
skipPermissions?: boolean | undefined;
|
|
110
122
|
sharedSessions?: boolean | undefined;
|
|
123
|
+
plugins?: string[] | undefined;
|
|
111
124
|
}>, "many">;
|
|
112
125
|
mcpServers: z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodObject<{
|
|
113
126
|
command: z.ZodOptional<z.ZodString>;
|
|
@@ -140,6 +153,13 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
140
153
|
type: z.ZodOptional<z.ZodString>;
|
|
141
154
|
url: z.ZodOptional<z.ZodString>;
|
|
142
155
|
}, z.ZodTypeAny, "passthrough">>>;
|
|
156
|
+
marketplaces: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
157
|
+
source: z.ZodString;
|
|
158
|
+
}, "strip", z.ZodTypeAny, {
|
|
159
|
+
source: string;
|
|
160
|
+
}, {
|
|
161
|
+
source: string;
|
|
162
|
+
}>>>;
|
|
143
163
|
}, "strip", z.ZodTypeAny, {
|
|
144
164
|
version: 1;
|
|
145
165
|
hub: string | null;
|
|
@@ -154,6 +174,7 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
154
174
|
settingsEnv: Record<string, string>;
|
|
155
175
|
skipPermissions: boolean;
|
|
156
176
|
sharedSessions: boolean;
|
|
177
|
+
plugins: string[];
|
|
157
178
|
agent?: "claude" | "codex" | undefined;
|
|
158
179
|
}[];
|
|
159
180
|
mcpServers: Record<string, z.objectOutputType<{
|
|
@@ -163,6 +184,9 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
163
184
|
type: z.ZodOptional<z.ZodString>;
|
|
164
185
|
url: z.ZodOptional<z.ZodString>;
|
|
165
186
|
}, z.ZodTypeAny, "passthrough">>;
|
|
187
|
+
marketplaces: Record<string, {
|
|
188
|
+
source: string;
|
|
189
|
+
}>;
|
|
166
190
|
}, {
|
|
167
191
|
version: 1;
|
|
168
192
|
hub: string | null;
|
|
@@ -178,6 +202,7 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
178
202
|
settingsEnv?: Record<string, string> | undefined;
|
|
179
203
|
skipPermissions?: boolean | undefined;
|
|
180
204
|
sharedSessions?: boolean | undefined;
|
|
205
|
+
plugins?: string[] | undefined;
|
|
181
206
|
}[];
|
|
182
207
|
mcpServers: Record<string, z.objectInputType<{
|
|
183
208
|
command: z.ZodOptional<z.ZodString>;
|
|
@@ -186,10 +211,20 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
186
211
|
type: z.ZodOptional<z.ZodString>;
|
|
187
212
|
url: z.ZodOptional<z.ZodString>;
|
|
188
213
|
}, z.ZodTypeAny, "passthrough">>;
|
|
214
|
+
marketplaces?: Record<string, {
|
|
215
|
+
source: string;
|
|
216
|
+
}> | undefined;
|
|
189
217
|
}>;
|
|
190
218
|
export type McpServerDef = z.infer<typeof McpServerSchema>;
|
|
191
219
|
export type ProfileDecl = z.infer<typeof ProfileSchema>;
|
|
192
220
|
export type Manifest = z.infer<typeof ManifestSchema>;
|
|
221
|
+
export type MarketplaceDef = z.infer<typeof MarketplaceSchema>;
|
|
222
|
+
/**
|
|
223
|
+
* True if `yaml` (raw manifest.yaml text, or any string) contains a plaintext provider
|
|
224
|
+
* token. saveManifest() uses this to decide whether to skip the git commit; doctor checks
|
|
225
|
+
* (CLI + UI) use it to surface that skip as a visible problem instead of a stderr-only warning.
|
|
226
|
+
*/
|
|
227
|
+
export declare function manifestHasPlaintextSecret(yaml: string): boolean;
|
|
193
228
|
/**
|
|
194
229
|
* Reject manifests whose identifiers could break out of the shell launcher context.
|
|
195
230
|
* Applies to every manifest — an untrusted bundle/peer manifest reaches this before
|