ccprofiles-core 0.10.2 → 0.11.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 CHANGED
@@ -8,6 +8,9 @@ It provides the discovery, manifest, secrets, apply, and LAN-sync logic used by
8
8
  - Applies profile config (launchers, env, links, MCP servers) with surgical, backed-up edits
9
9
  - Reads/writes secrets across macOS Keychain, libsecret, and encrypted-file backends
10
10
  - Computes MCP server drift across profiles and both agents (Claude + Codex)
11
+ - Builds headless launches that run one profile's agent as a subagent of another session, stripping the caller's provider env so a gateway profile cannot re-point a Claude delegate (`delegate.ts`)
12
+ - Checks which files a delegate touched against a declared path lane, counting created, edited, deleted, reverted, and committed work (`scope.ts`)
13
+ - Mirrors one `statusLine` block into every claude profile, since Claude Code reads it per `CLAUDE_CONFIG_DIR`
11
14
  - Drives encrypted, PIN-paired LAN replication between machines
12
15
 
13
16
  Repo: [github.com/tmfadhlul/claude-code-profile](https://github.com/tmfadhlul/claude-code-profile)
package/dist/apply.js CHANGED
@@ -116,7 +116,9 @@ export function planApply(m, live, p, resolvedSettingsEnv, sharedRoot = join(p.h
116
116
  actions.push({ kind: 'unshare-session-dir', from, to });
117
117
  }
118
118
  const senv = pr.settingsEnv ?? {}; // literals in older tests may omit the field
119
- if (agent === 'claude' && Object.keys(senv).length > 0) {
119
+ // No length guard: an empty settingsEnv is a declaration ("no env"), so it must still clear
120
+ // whatever is live — otherwise `provider anthropic --login` leaves the old token in place.
121
+ if (agent === 'claude') {
120
122
  let desired = resolvedSettingsEnv?.[pr.name];
121
123
  if (!desired) {
122
124
  if (Object.values(senv).some(v => v.startsWith(SECRET_PREFIX)))
@@ -124,7 +126,10 @@ export function planApply(m, live, p, resolvedSettingsEnv, sharedRoot = join(p.h
124
126
  desired = senv;
125
127
  }
126
128
  const currentEnv = lp?.settingsEnv ?? null;
127
- if (!currentEnv || JSON.stringify(sortKeys(currentEnv)) !== JSON.stringify(sortKeys(desired))) {
129
+ const drift = currentEnv
130
+ ? JSON.stringify(sortKeys(currentEnv)) !== JSON.stringify(sortKeys(desired))
131
+ : Object.keys(desired).length > 0; // no live dir yet: don't create settings.json just to write {}
132
+ if (drift) {
128
133
  actions.push({ kind: 'set-settings-env', settingsPath: join(dir, 'settings.json'), env: desired });
129
134
  }
130
135
  }
@@ -226,6 +226,7 @@ export type McpServerDef = z.infer<typeof McpServerSchema>;
226
226
  export type ProfileDecl = z.infer<typeof ProfileSchema>;
227
227
  export type Manifest = z.infer<typeof ManifestSchema>;
228
228
  export type MarketplaceDef = z.infer<typeof MarketplaceSchema>;
229
+ export declare const SAFE_NAME: RegExp;
229
230
  /**
230
231
  * True if `yaml` (raw manifest.yaml text, or any string) contains a plaintext provider
231
232
  * token. saveManifest() uses this to decide whether to skip the git commit; doctor checks
package/dist/manifest.js CHANGED
@@ -45,7 +45,9 @@ const ManifestSchema = z.object({
45
45
  // identifiers that get interpolated into shell launcher code must be injection-safe
46
46
  // leading '-' is forbidden (but internal '-' still allowed) so a value can never be parsed as a
47
47
  // CLI flag when passed as a positional argv entry (e.g. `claude plugin install <id>`)
48
- const SAFE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; // profile names, launcher names, secret refs
48
+ // exported: SecretsStore.set() rejects names this forbids, so a secret can never be stored under
49
+ // a name no manifest could ever reference (the failure used to surface only at attach time)
50
+ export const SAFE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; // profile names, launcher names, secret refs
49
51
  const SAFE_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; // POSIX/PowerShell env var names
50
52
  const SAFE_LINK_ENTRY = /^[A-Za-z0-9._-]+$/; // one profile-dir child; never a path
51
53
  const SAFE_SOURCE = /^[A-Za-z0-9._@:/][A-Za-z0-9._/@:-]*$/; // marketplace source (interpolated into `claude plugin` shell-out); no leading '-'
@@ -181,7 +183,9 @@ export async function saveManifest(root, m) {
181
183
  /** True for the two git outcomes saveManifest treats as expected/benign, not a real failure. */
182
184
  function isBenignGitError(e) {
183
185
  const text = gitErrorMessage(e);
184
- return /nothing to commit/i.test(text) || /not a git repository/i.test(text);
186
+ // "no changes added to commit" is git's wording when the only dirty paths are ones `add -A`
187
+ // cannot stage (nested repos) — still nothing for us to commit.
188
+ return /nothing to commit/i.test(text) || /no changes added to commit/i.test(text) || /not a git repository/i.test(text);
185
189
  }
186
190
  function gitErrorMessage(e) {
187
191
  const err = e;
package/dist/plugins.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export interface PluginRunner {
2
2
  marketplaceAdd(configDir: string, source: string): Promise<void>;
3
+ /** `claude plugin marketplace update` — refetch the marketplace so "latest" is actually latest.
4
+ * Without this, `update` is a silent no-op in any profile whose marketplace clone is stale. */
5
+ marketplaceUpdate(configDir: string, name: string): Promise<void>;
3
6
  install(configDir: string, id: string): Promise<void>;
4
7
  uninstall(configDir: string, id: string): Promise<void>;
5
8
  /** `claude plugin update` — moves a plugin to the marketplace's latest. There is deliberately no
@@ -76,3 +79,23 @@ export declare function claudeMemCacheDirs(configDir: string): Promise<string[]>
76
79
  export declare function pruneStaleVersionDirs(cacheDir: string): Promise<string[]>;
77
80
  /** If a profile's plugins/ is a legacy symlink into the old shared pool, restore it to a real dir. */
78
81
  export declare function restoreLegacyPluginSymlink(pluginsDir: string): Promise<boolean>;
82
+ /**
83
+ * Repair installed_plugins.json entries whose installPath no longer exists on disk.
84
+ *
85
+ * This is the other half of pruneStaleVersionDirs. Pruning removes superseded cache dirs but leaves
86
+ * the bookkeeping untouched, and installed_plugins.json holds one entry PER SCOPE — a `user` entry
87
+ * plus a `project` entry for every directory Claude was ever launched from. `claude plugin update`
88
+ * only moves the user-scope pin, so the project-scope entries stay on an old version, and the next
89
+ * prune deletes the dir underneath them. Claude Code then refuses to load their hooks outright:
90
+ * "Plugin directory does not exist: .../claude-mem/13.16.1 — run /plugin to reinstall", on every
91
+ * Stop hook, forever. Six of eight profiles were in that state on 2026-08-30.
92
+ *
93
+ * Re-point rather than drop where a sibling version dir survived: the surviving dir is the one
94
+ * prune kept, i.e. the latest, which is exactly where `claude plugin update` would have moved the
95
+ * entry anyway — so the repair converges scopes onto one version instead of disabling the plugin.
96
+ * Only numeric version dirs qualify; a versionless plugin's sha/"unknown" siblings are unrelated
97
+ * builds, so those entries are dropped and left for `plugins apply` to reinstall.
98
+ */
99
+ export declare function repairInstalledPlugins(configDir: string, opts?: {
100
+ dryRun?: boolean;
101
+ }): Promise<string[]>;
package/dist/plugins.js CHANGED
@@ -1,6 +1,7 @@
1
- import { cp, lstat, mkdir, readdir, readlink, rm, unlink } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
- import { join } from 'node:path';
1
+ import { cp, lstat, mkdir, readdir, readFile, readlink, rm, unlink } from 'node:fs/promises';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { atomicWrite } from './fsutil.js';
4
5
  export function marketplaceOf(id) {
5
6
  const at = id.lastIndexOf('@');
6
7
  return at > 0 ? id.slice(at + 1) : null;
@@ -67,6 +68,18 @@ export async function reconcileProfilePlugins(opts) {
67
68
  await opts.runner.install(opts.configDir, id);
68
69
  log.push(`install ${id}`);
69
70
  }
71
+ // `claude plugin update` moves to the marketplace clone's latest, and that clone is per-profile
72
+ // and only refreshed on demand. A profile whose clone is months old therefore "updates" to the
73
+ // version it already has, and cross-profile drift never converges — refetch first.
74
+ const refreshed = new Set();
75
+ for (const id of update) {
76
+ const mkt = marketplaceOf(id);
77
+ if (mkt && !refreshed.has(mkt)) {
78
+ await opts.runner.marketplaceUpdate(opts.configDir, mkt);
79
+ refreshed.add(mkt);
80
+ log.push(`marketplace update ${mkt}`);
81
+ }
82
+ }
70
83
  for (const id of update) {
71
84
  await opts.runner.update(opts.configDir, id);
72
85
  log.push(`update ${id}`);
@@ -166,3 +179,75 @@ export async function restoreLegacyPluginSymlink(pluginsDir) {
166
179
  await cp(target, pluginsDir, { recursive: true, force: false, errorOnExist: false });
167
180
  return true;
168
181
  }
182
+ /** Highest-semver cache dir that still exists beside a missing one, or null if none survived. */
183
+ function survivingVersionDir(missingPath) {
184
+ const parent = dirname(missingPath);
185
+ let names;
186
+ try {
187
+ names = readdirSync(parent);
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ const versions = names.filter(n => /^\d/.test(n) && existsSync(join(parent, n))).sort(compareVersionDir);
193
+ return versions.length ? join(parent, versions[versions.length - 1]) : null;
194
+ }
195
+ /**
196
+ * Repair installed_plugins.json entries whose installPath no longer exists on disk.
197
+ *
198
+ * This is the other half of pruneStaleVersionDirs. Pruning removes superseded cache dirs but leaves
199
+ * the bookkeeping untouched, and installed_plugins.json holds one entry PER SCOPE — a `user` entry
200
+ * plus a `project` entry for every directory Claude was ever launched from. `claude plugin update`
201
+ * only moves the user-scope pin, so the project-scope entries stay on an old version, and the next
202
+ * prune deletes the dir underneath them. Claude Code then refuses to load their hooks outright:
203
+ * "Plugin directory does not exist: .../claude-mem/13.16.1 — run /plugin to reinstall", on every
204
+ * Stop hook, forever. Six of eight profiles were in that state on 2026-08-30.
205
+ *
206
+ * Re-point rather than drop where a sibling version dir survived: the surviving dir is the one
207
+ * prune kept, i.e. the latest, which is exactly where `claude plugin update` would have moved the
208
+ * entry anyway — so the repair converges scopes onto one version instead of disabling the plugin.
209
+ * Only numeric version dirs qualify; a versionless plugin's sha/"unknown" siblings are unrelated
210
+ * builds, so those entries are dropped and left for `plugins apply` to reinstall.
211
+ */
212
+ export async function repairInstalledPlugins(configDir, opts = {}) {
213
+ const file = join(configDir, 'plugins', 'installed_plugins.json');
214
+ let doc;
215
+ // A corrupt file must not be rewritten from a half-parse — bail and leave it for a human.
216
+ try {
217
+ doc = JSON.parse(await readFile(file, 'utf8'));
218
+ }
219
+ catch {
220
+ return [];
221
+ }
222
+ if (!doc?.plugins || typeof doc.plugins !== 'object')
223
+ return [];
224
+ const log = [];
225
+ for (const [id, entries] of Object.entries(doc.plugins)) {
226
+ if (!Array.isArray(entries))
227
+ continue;
228
+ const kept = [];
229
+ for (const e of entries) {
230
+ const path = e?.installPath;
231
+ if (typeof path !== 'string' || existsSync(path)) {
232
+ kept.push(e);
233
+ continue;
234
+ }
235
+ const scope = e.scope ?? '?';
236
+ const survivor = survivingVersionDir(path);
237
+ if (survivor) {
238
+ kept.push({ ...e, installPath: survivor, version: basename(survivor) });
239
+ log.push(`${id} (${scope}): ${e.version} → ${basename(survivor)} — cache dir was pruned`);
240
+ }
241
+ else {
242
+ log.push(`${id} (${scope}): dropped — ${path} is gone and no version survived`);
243
+ }
244
+ }
245
+ if (kept.length)
246
+ doc.plugins[id] = kept;
247
+ else
248
+ delete doc.plugins[id];
249
+ }
250
+ if (log.length && !opts.dryRun)
251
+ await atomicWrite(file, JSON.stringify(doc, null, 2) + '\n');
252
+ return log;
253
+ }
package/dist/secrets.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync } from 'node:fs';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { atomicWrite } from './fsutil.js';
7
+ import { SAFE_NAME } from './manifest.js';
7
8
  export class FileBackend {
8
9
  filePath;
9
10
  passphrase;
@@ -176,6 +177,8 @@ export class SecretsStore {
176
177
  get backendName() { return this.backend.name; }
177
178
  async get(key) { return this.backend.get(key); }
178
179
  async set(key, value) {
180
+ if (!SAFE_NAME.test(key))
181
+ throw new Error(`unsafe secret name: ${JSON.stringify(key)} (allowed: letters, digits, - _; must not start with -)`);
179
182
  await this.backend.set(key, value);
180
183
  await this.writeIndex([...(await this.readIndex()), key]);
181
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccprofiles-core",
3
- "version": "0.10.2",
3
+ "version": "0.11.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",