ccprofiles-core 0.6.0 → 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 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
- export function buildManifest(live, platform) {
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: false,
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, isAbsolute, relative } from 'node:path';
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
- 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));
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
- desired[name] = m.mcpServers[name];
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' ? ['sessions'] : CLAUDE_SHARED_ENTRIES;
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 { /* new file */ }
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 { /* new file */ }
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
- await atomicWrite(a.settingsPath, JSON.stringify(cfg, null, 2));
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
- /** Write an asset map back to the live layout. */
6
- export declare function writeAssets(assets: Record<string, string>, m: Manifest, p: Platform): Promise<void>;
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
- /** Write an asset map back to the live layout. */
68
- export async function writeAssets(assets, m, p) {
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
- await atomicWrite(join(root, ...rest), content);
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
- await atomicWrite(join(root, guidanceName), content);
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>): Promise<void>;
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 { /* new config */ }
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) {
@@ -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): Promise<void>;
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
- await mkdir(dirname(filePath), { recursive: true });
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
- await writeFile(tmp, content, 'utf8');
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
- await mkdir(dir, { recursive: true });
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
+ };
@@ -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
@@ -15,3 +15,6 @@ export * from './syncserver.js';
15
15
  export * from './syncclient.js';
16
16
  export * from './bundle.js';
17
17
  export * from './sessions.js';
18
+ export * from './handoff.js';
19
+ export * from './plugins.js';
20
+ export * from './provider.js';
package/dist/index.js CHANGED
@@ -15,3 +15,6 @@ export * from './syncserver.js';
15
15
  export * from './syncclient.js';
16
16
  export * from './bundle.js';
17
17
  export * from './sessions.js';
18
+ export * from './handoff.js';
19
+ export * from './plugins.js';
20
+ export * from './provider.js';
@@ -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