ccprofiles-core 0.9.0 → 0.10.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 CHANGED
@@ -9,8 +9,8 @@ export declare function buildManifest(live: LiveProfile[], platform: Platform, s
9
9
  * if the old manifest had a secret:// ref whose resolved value matches the newly-discovered
10
10
  * plaintext value, restore the ref instead of the plaintext.
11
11
  *
12
- * Also carries forward declared-only fields (skipPermissions, launcher env) that live
13
- * state cannot represent — buildManifest resets them to defaults, so without this every
14
- * snapshot/adopt would silently turn them off.
12
+ * Also carries forward declared-only fields (skipPermissions, launcher name, launcher env)
13
+ * that live state cannot represent — buildManifest resets them to defaults, so without this
14
+ * every snapshot/adopt would silently turn them off.
15
15
  */
16
16
  export declare function preserveSecretRefs(newM: Manifest, oldM: Manifest, getSecret: (name: string) => Promise<string | null>): Promise<void>;
package/dist/adopt.js CHANGED
@@ -59,7 +59,11 @@ export function buildManifest(live, platform, sharedRoot = join(platform.home, '
59
59
  .map(([k]) => k).sort(),
60
60
  };
61
61
  });
62
- return { version: 1, hub, profiles, mcpServers, marketplaces };
62
+ // Whichever profile already has a statusline wins; ~/.claude first, since that is where a
63
+ // `ccstatusline`-style installer writes when run without CLAUDE_CONFIG_DIR set.
64
+ const statusLine = (live.find(lp => lp.dirName === '.claude' && lp.statusLine) ?? live.find(lp => lp.statusLine))
65
+ ?.statusLine;
66
+ return { version: 1, hub, profiles, mcpServers, marketplaces, ...(statusLine ? { statusLine } : {}) };
63
67
  }
64
68
  /**
65
69
  * Re-migrate secret refs after a snapshot/adopt rebuilds the manifest from live state.
@@ -68,9 +72,9 @@ export function buildManifest(live, platform, sharedRoot = join(platform.home, '
68
72
  * if the old manifest had a secret:// ref whose resolved value matches the newly-discovered
69
73
  * plaintext value, restore the ref instead of the plaintext.
70
74
  *
71
- * Also carries forward declared-only fields (skipPermissions, launcher env) that live
72
- * state cannot represent — buildManifest resets them to defaults, so without this every
73
- * snapshot/adopt would silently turn them off.
75
+ * Also carries forward declared-only fields (skipPermissions, launcher name, launcher env)
76
+ * that live state cannot represent — buildManifest resets them to defaults, so without this
77
+ * every snapshot/adopt would silently turn them off.
74
78
  */
75
79
  export async function preserveSecretRefs(newM, oldM, getSecret) {
76
80
  for (const pr of newM.profiles) {
@@ -78,6 +82,9 @@ export async function preserveSecretRefs(newM, oldM, getSecret) {
78
82
  if (!old)
79
83
  continue;
80
84
  pr.skipPermissions = old.skipPermissions;
85
+ // buildManifest derives `cl-<dirname>`, which silently renames a hand-picked alias
86
+ // (cl-plb -> cl-data-plb) and makes the next apply rewrite the user's rc block.
87
+ pr.launcher = old.launcher;
81
88
  if (Object.keys(pr.env).length === 0)
82
89
  pr.env = old.env;
83
90
  for (const [k, v] of Object.entries(old.settingsEnv ?? {})) {
package/dist/apply.d.ts CHANGED
@@ -21,6 +21,10 @@ export type ApplyAction = {
21
21
  kind: 'set-settings-env';
22
22
  settingsPath: string;
23
23
  env: Record<string, string>;
24
+ } | {
25
+ kind: 'set-status-line';
26
+ settingsPath: string;
27
+ statusLine: Record<string, unknown>;
24
28
  } | {
25
29
  kind: 'share-session-dir';
26
30
  from: string;
package/dist/apply.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cp, lstat, mkdir, readFile, rm, symlink, unlink } from 'node:fs/promises';
1
+ import { cp, lstat, mkdir, readFile, rm, stat, symlink, unlink } from 'node:fs/promises';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join, dirname, posix as posixPath, win32 as win32Path } from 'node:path';
4
4
  import { renderPath } from './platform.js';
@@ -128,6 +128,14 @@ export function planApply(m, live, p, resolvedSettingsEnv, sharedRoot = join(p.h
128
128
  actions.push({ kind: 'set-settings-env', settingsPath: join(dir, 'settings.json'), env: desired });
129
129
  }
130
130
  }
131
+ // Same normalization as the mcp check — comparing raw JSON would re-emit this action on
132
+ // every apply whenever key order differed, which is the unfixable-drift-loop shape.
133
+ if (agent === 'claude' && m.statusLine) {
134
+ const currentSl = lp?.statusLine ?? null;
135
+ if (!currentSl || JSON.stringify(sortKeys(currentSl)) !== JSON.stringify(sortKeys(m.statusLine))) {
136
+ actions.push({ kind: 'set-status-line', settingsPath: join(dir, 'settings.json'), statusLine: m.statusLine });
137
+ }
138
+ }
131
139
  }
132
140
  const block = renderRcBlock(m, p);
133
141
  let rcCurrent = '';
@@ -146,7 +154,8 @@ export async function executeApply(actions, opts) {
146
154
  const touched = actions.flatMap(a => a.kind === 'set-mcp-servers' ? [a.configPath]
147
155
  : a.kind === 'rc-block' ? [a.rcFile]
148
156
  : a.kind === 'set-settings-env' ? [a.settingsPath]
149
- : []);
157
+ : a.kind === 'set-status-line' ? [a.settingsPath]
158
+ : []);
150
159
  let backupDir = touched.length ? await backupFiles(touched, opts.backupRoot, opts.stamp) : null;
151
160
  for (const a of actions) {
152
161
  if (a.kind === 'create-profile-dir') {
@@ -222,6 +231,29 @@ export async function executeApply(actions, opts) {
222
231
  // Resolved secret:// refs land here as plaintext env values — keep it 0600.
223
232
  await atomicWrite(a.settingsPath, JSON.stringify(cfg, null, 2), { mode: 0o600 });
224
233
  }
234
+ else if (a.kind === 'set-status-line') {
235
+ let cfg = {};
236
+ try {
237
+ cfg = JSON.parse(await readFile(a.settingsPath, 'utf8'));
238
+ }
239
+ catch (e) {
240
+ if (e.code !== 'ENOENT') {
241
+ await backupFiles([a.settingsPath], opts.backupRoot, opts.stamp);
242
+ throw new Error(`refusing to overwrite unreadable ${a.settingsPath} — back it up and fix it, then re-apply (${e.message})`);
243
+ }
244
+ // ENOENT: genuinely new file, cfg stays {}
245
+ }
246
+ cfg.statusLine = a.statusLine;
247
+ // Preserve the existing mode. set-settings-env may have just written this same file at
248
+ // 0600 because it holds resolved secrets; atomicWrite's 0644 default would widen it.
249
+ let mode = 0o644;
250
+ try {
251
+ mode = (await stat(a.settingsPath)).mode & 0o777;
252
+ }
253
+ catch { /* new file: 0644 */ }
254
+ await mkdir(dirname(a.settingsPath), { recursive: true });
255
+ await atomicWrite(a.settingsPath, JSON.stringify(cfg, null, 2), { mode });
256
+ }
225
257
  else if (a.kind === 'share-session-dir') {
226
258
  await mkdir(a.to, { recursive: true });
227
259
  let st = null;
@@ -263,6 +295,7 @@ function describe(a) {
263
295
  case 'link': return `link ${a.from} -> ${a.to}`;
264
296
  case 'rc-block': return `update managed block in ${a.rcFile}`;
265
297
  case 'set-settings-env': return `set settings env (${Object.keys(a.env).length}) in ${a.settingsPath}`;
298
+ case 'set-status-line': return `set statusLine in ${a.settingsPath}`;
266
299
  case 'share-session-dir': return `share ${a.from} -> ${a.to}`;
267
300
  case 'unshare-session-dir': return `unshare ${a.from} (seed from ${a.to})`;
268
301
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Run another profile's agent headlessly as a subagent of the current session.
3
+ *
4
+ * `handoff` moves you to another profile interactively; this keeps you where you are and
5
+ * calls the other profile for one scoped task, returning its output. That is the only way
6
+ * to reach a different provider from inside a session: ANTHROPIC_BASE_URL / _AUTH_TOKEN are
7
+ * process-wide, and a native subagent's `model` is validated against the *current* provider,
8
+ * so a Claude session cannot dispatch a native subagent onto Kimi, GLM, or Codex.
9
+ */
10
+ /** Env that pins a session to one account/provider. Must never survive into a delegate. */
11
+ export declare const PROVIDER_ENV_KEYS: readonly ["CLAUDE_CONFIG_DIR", "CODEX_HOME", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL"];
12
+ export interface DelegateLaunch {
13
+ command: string;
14
+ args: string[];
15
+ env: Record<string, string>;
16
+ cwd: string;
17
+ }
18
+ /**
19
+ * Parent env minus every provider pin, plus the target's own.
20
+ *
21
+ * Without the strip, delegating from a gateway profile (kimi/z/mimo, which export
22
+ * ANTHROPIC_BASE_URL) to a Claude profile would leave the gateway's base URL in place: the
23
+ * target would authenticate as the Claude profile but send its traffic to the gateway. The
24
+ * delegate would appear to work while silently running on the wrong model.
25
+ */
26
+ export declare function delegateEnv(parentEnv: Record<string, string | undefined>, homeVar: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME', targetDir: string, targetEnv: Record<string, string>): Record<string, string>;
27
+ export declare function buildDelegateLaunch(opts: {
28
+ targetAgent: 'claude' | 'codex';
29
+ targetDir: string;
30
+ targetEnv: Record<string, string>;
31
+ skipPermissions: boolean;
32
+ prompt: string;
33
+ cwd: string;
34
+ model?: string | null;
35
+ json?: boolean;
36
+ parentEnv?: Record<string, string | undefined>;
37
+ }): DelegateLaunch;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Run another profile's agent headlessly as a subagent of the current session.
3
+ *
4
+ * `handoff` moves you to another profile interactively; this keeps you where you are and
5
+ * calls the other profile for one scoped task, returning its output. That is the only way
6
+ * to reach a different provider from inside a session: ANTHROPIC_BASE_URL / _AUTH_TOKEN are
7
+ * process-wide, and a native subagent's `model` is validated against the *current* provider,
8
+ * so a Claude session cannot dispatch a native subagent onto Kimi, GLM, or Codex.
9
+ */
10
+ /** Env that pins a session to one account/provider. Must never survive into a delegate. */
11
+ export const PROVIDER_ENV_KEYS = [
12
+ 'CLAUDE_CONFIG_DIR',
13
+ 'CODEX_HOME',
14
+ 'ANTHROPIC_API_KEY',
15
+ 'ANTHROPIC_AUTH_TOKEN',
16
+ 'ANTHROPIC_BASE_URL',
17
+ 'ANTHROPIC_MODEL',
18
+ 'ANTHROPIC_SMALL_FAST_MODEL',
19
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
20
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
21
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
22
+ ];
23
+ /**
24
+ * Parent env minus every provider pin, plus the target's own.
25
+ *
26
+ * Without the strip, delegating from a gateway profile (kimi/z/mimo, which export
27
+ * ANTHROPIC_BASE_URL) to a Claude profile would leave the gateway's base URL in place: the
28
+ * target would authenticate as the Claude profile but send its traffic to the gateway. The
29
+ * delegate would appear to work while silently running on the wrong model.
30
+ */
31
+ export function delegateEnv(parentEnv, homeVar, targetDir, targetEnv) {
32
+ const out = {};
33
+ for (const [k, v] of Object.entries(parentEnv)) {
34
+ if (v === undefined)
35
+ continue;
36
+ if (PROVIDER_ENV_KEYS.includes(k))
37
+ continue;
38
+ out[k] = v;
39
+ }
40
+ out[homeVar] = targetDir;
41
+ return { ...out, ...targetEnv };
42
+ }
43
+ export function buildDelegateLaunch(opts) {
44
+ const codex = opts.targetAgent === 'codex';
45
+ const homeVar = codex ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR';
46
+ const args = codex ? ['exec'] : ['-p'];
47
+ if (opts.model)
48
+ args.push(codex ? '-m' : '--model', opts.model);
49
+ // codex exec has no --output-format; the caller is told rather than silently given text
50
+ if (opts.json && !codex)
51
+ args.push('--output-format', 'json');
52
+ if (opts.skipPermissions) {
53
+ args.push(codex ? '--dangerously-bypass-approvals-and-sandbox' : '--dangerously-skip-permissions');
54
+ }
55
+ // codex resolves its workspace from --cd, not the spawn cwd
56
+ if (codex)
57
+ args.push('-C', opts.cwd);
58
+ args.push(opts.prompt);
59
+ return {
60
+ command: codex ? 'codex' : 'claude',
61
+ args,
62
+ env: delegateEnv(opts.parentEnv ?? {}, homeVar, opts.targetDir, opts.targetEnv),
63
+ cwd: opts.cwd,
64
+ };
65
+ }
@@ -9,6 +9,9 @@ export interface LiveProfile {
9
9
  mcpServers: Record<string, McpServerDef>;
10
10
  links: Record<string, string>;
11
11
  settingsEnv: Record<string, string>;
12
+ /** settings.json `statusLine`, verbatim. Claude Code reads it from the profile's own
13
+ * settings.json, so configuring it once in ~/.claude leaves every other profile bare. */
14
+ statusLine?: unknown;
12
15
  enabledPlugins: Record<string, boolean>;
13
16
  /** Plugin ids actually installed (keys of plugins/installed_plugins.json) — the ground truth
14
17
  * for "present". enabledPlugins alone can be stale: an enabled-but-never-installed entry
package/dist/discovery.js CHANGED
@@ -98,6 +98,7 @@ export async function discoverProfiles(home) {
98
98
  }
99
99
  const settingsEnv = {};
100
100
  const enabledPlugins = {};
101
+ let statusLine;
101
102
  if (agent === 'claude')
102
103
  try {
103
104
  const s = JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8'));
@@ -109,6 +110,8 @@ export async function discoverProfiles(home) {
109
110
  for (const [k, v] of Object.entries(s.enabledPlugins))
110
111
  if (typeof v === 'boolean')
111
112
  enabledPlugins[k] = v;
113
+ if (s && typeof s.statusLine === 'object' && s.statusLine !== null)
114
+ statusLine = s.statusLine;
112
115
  }
113
116
  catch { /* no settings.json */ }
114
117
  const marketplaces = {};
@@ -144,6 +147,7 @@ export async function discoverProfiles(home) {
144
147
  mcpServers: agent === 'claude' ? cfg?.mcpServers ?? {} : await readCodexMcpServers(configPath),
145
148
  links,
146
149
  settingsEnv,
150
+ statusLine,
147
151
  enabledPlugins,
148
152
  installedPlugins,
149
153
  installedPluginVersions,
package/dist/index.d.ts CHANGED
@@ -16,5 +16,6 @@ export * from './syncclient.js';
16
16
  export * from './bundle.js';
17
17
  export * from './sessions.js';
18
18
  export * from './handoff.js';
19
+ export * from './delegate.js';
19
20
  export * from './plugins.js';
20
21
  export * from './provider.js';
package/dist/index.js CHANGED
@@ -16,5 +16,6 @@ export * from './syncclient.js';
16
16
  export * from './bundle.js';
17
17
  export * from './sessions.js';
18
18
  export * from './handoff.js';
19
+ export * from './delegate.js';
19
20
  export * from './plugins.js';
20
21
  export * from './provider.js';
@@ -160,6 +160,11 @@ declare const ManifestSchema: z.ZodObject<{
160
160
  }, {
161
161
  source: string;
162
162
  }>>>;
163
+ /** One settings.json `statusLine` block mirrored into every claude profile. Claude Code
164
+ * reads it per-CLAUDE_CONFIG_DIR, so without this a statusline configured in one profile
165
+ * is simply absent in the others. Shared, not per-profile: the point is that every
166
+ * profile gets the same line (it renders per-profile data at runtime). */
167
+ statusLine: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
163
168
  }, "strip", z.ZodTypeAny, {
164
169
  version: 1;
165
170
  hub: string | null;
@@ -187,6 +192,7 @@ declare const ManifestSchema: z.ZodObject<{
187
192
  marketplaces: Record<string, {
188
193
  source: string;
189
194
  }>;
195
+ statusLine?: Record<string, unknown> | undefined;
190
196
  }, {
191
197
  version: 1;
192
198
  hub: string | null;
@@ -214,6 +220,7 @@ declare const ManifestSchema: z.ZodObject<{
214
220
  marketplaces?: Record<string, {
215
221
  source: string;
216
222
  }> | undefined;
223
+ statusLine?: Record<string, unknown> | undefined;
217
224
  }>;
218
225
  export type McpServerDef = z.infer<typeof McpServerSchema>;
219
226
  export type ProfileDecl = z.infer<typeof ProfileSchema>;
package/dist/manifest.js CHANGED
@@ -36,6 +36,11 @@ const ManifestSchema = z.object({
36
36
  profiles: z.array(ProfileSchema),
37
37
  mcpServers: z.record(McpServerSchema),
38
38
  marketplaces: z.record(MarketplaceSchema).default({}),
39
+ /** One settings.json `statusLine` block mirrored into every claude profile. Claude Code
40
+ * reads it per-CLAUDE_CONFIG_DIR, so without this a statusline configured in one profile
41
+ * is simply absent in the others. Shared, not per-profile: the point is that every
42
+ * profile gets the same line (it renders per-profile data at runtime). */
43
+ statusLine: z.record(z.unknown()).optional(),
39
44
  });
40
45
  // identifiers that get interpolated into shell launcher code must be injection-safe
41
46
  // leading '-' is forbidden (but internal '-' still allowed) so a value can never be parsed as a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccprofiles-core",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",