ccprofiles-core 0.8.0 → 0.9.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
@@ -8,5 +8,9 @@ export declare function buildManifest(live: LiveProfile[], platform: Platform, s
8
8
  * re-leak a previously migrated token as plaintext. For each profile that existed before,
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
+ *
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.
11
15
  */
12
16
  export declare function preserveSecretRefs(newM: Manifest, oldM: Manifest, getSecret: (name: string) => Promise<string | null>): Promise<void>;
package/dist/adopt.js CHANGED
@@ -67,12 +67,19 @@ export function buildManifest(live, platform, sharedRoot = join(platform.home, '
67
67
  * re-leak a previously migrated token as plaintext. For each profile that existed before,
68
68
  * if the old manifest had a secret:// ref whose resolved value matches the newly-discovered
69
69
  * plaintext value, restore the ref instead of the plaintext.
70
+ *
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.
70
74
  */
71
75
  export async function preserveSecretRefs(newM, oldM, getSecret) {
72
76
  for (const pr of newM.profiles) {
73
77
  const old = oldM.profiles.find(p => p.name === pr.name);
74
78
  if (!old)
75
79
  continue;
80
+ pr.skipPermissions = old.skipPermissions;
81
+ if (Object.keys(pr.env).length === 0)
82
+ pr.env = old.env;
76
83
  for (const [k, v] of Object.entries(old.settingsEnv ?? {})) {
77
84
  if (!v.startsWith('secret://'))
78
85
  continue;
@@ -24,24 +24,19 @@ export interface LiveProfile {
24
24
  }
25
25
  /**
26
26
  * Pull a comparable installed version out of each entry of installed_plugins.json's `plugins` map.
27
+ * The value is an ARRAY of install records (one per scope), not an object.
27
28
  *
28
- * Real-world shapes to survive (all four seen on one machine):
29
- * - the value is an ARRAY of install records (one per scope: user/project/local), not an object
30
- * - a released plugin has a real `version` ('13.11.0') alongside a `gitCommitSha`; the two move
31
- * together, so either identifies the code — but the version reads better in a warning
32
- * - a plugin pinned by git sha rather than a release has no real version, and Claude Code spells
33
- * that inconsistently: the literal 'unknown' in some releases, the SHORT sha in others, for the
34
- * very same commit ('unknown' vs 'e14e8fe2c1fc', both with sha 'e14e8fe2c1fca591…')
35
- * - so a short-sha `version` must not be compared against a full `gitCommitSha` either — same
36
- * commit, different string
37
- *
38
- * Hence: use `version` only when it is a real version, i.e. not missing, not 'unknown', and not
39
- * merely a prefix of the entry's own sha. Otherwise use the full `gitCommitSha`, which is the one
40
- * canonical spelling every profile agrees on.
41
- *
42
- * ponytail: takes the first scope's version when a plugin is installed at several scopes at once.
43
- * Per-scope drift is a different (rarer) bug than the cross-profile drift this exists to catch;
44
- * widen to Record<id, Record<scope, version>> if that ever bites.
29
+ * ONLY a real release `version` counts. `gitCommitSha` looks like a tempting fallback for the
30
+ * versionless plugins (Claude Code writes `"version": "unknown"` for those) but it is unusable as
31
+ * an identity, and comparing it produced drift that `fix` could never clear:
32
+ * - it is the sha of the whole MARKETPLACE repo, not of the plugin — two profiles whose plugin
33
+ * files are byte-identical still hold different shas if they installed at different times
34
+ * - Claude Code never refreshes it when `claude plugin update` finds nothing to do, so the stale
35
+ * value outlives every update. Seen on a real machine: context7 at 205b6e0b in one profile and
36
+ * e14e8fe2 in three others, identical file contents, identical marketplace snapshot, and
37
+ * `clp fix` looping forever because update kept (correctly) no-opping.
38
+ * A versionless plugin therefore contributes no drift signal at all. Detecting nothing beats
39
+ * detecting noise that has no remedy.
45
40
  */
46
41
  export declare function readInstalledPluginVersions(plugins: Record<string, unknown>): Record<string, string>;
47
42
  export declare function discoverProfiles(home: string): Promise<LiveProfile[]>;
package/dist/discovery.js CHANGED
@@ -2,38 +2,38 @@ import { access, readdir, readFile, readlink } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { readCodexMcpServers } from './codex.js';
4
4
  import { logicalLinkEntry } from './links.js';
5
+ /**
6
+ * ccprofiles manages a profile's USER-scope plugins. A project-scope install belongs to some project
7
+ * directory, not to the profile, and `claude plugin update/uninstall` only ever act on user scope —
8
+ * counting one made those calls fail with `Plugin "x" is not installed at scope user`. Entries
9
+ * predating the `scope` field are treated as user.
10
+ */
11
+ function userScopeEntries(entries) {
12
+ return (Array.isArray(entries) ? entries : [entries])
13
+ .filter((e) => e && (e.scope ?? 'user') === 'user');
14
+ }
5
15
  /**
6
16
  * Pull a comparable installed version out of each entry of installed_plugins.json's `plugins` map.
17
+ * The value is an ARRAY of install records (one per scope), not an object.
7
18
  *
8
- * Real-world shapes to survive (all four seen on one machine):
9
- * - the value is an ARRAY of install records (one per scope: user/project/local), not an object
10
- * - a released plugin has a real `version` ('13.11.0') alongside a `gitCommitSha`; the two move
11
- * together, so either identifies the code — but the version reads better in a warning
12
- * - a plugin pinned by git sha rather than a release has no real version, and Claude Code spells
13
- * that inconsistently: the literal 'unknown' in some releases, the SHORT sha in others, for the
14
- * very same commit ('unknown' vs 'e14e8fe2c1fc', both with sha 'e14e8fe2c1fca591…')
15
- * - so a short-sha `version` must not be compared against a full `gitCommitSha` either — same
16
- * commit, different string
17
- *
18
- * Hence: use `version` only when it is a real version, i.e. not missing, not 'unknown', and not
19
- * merely a prefix of the entry's own sha. Otherwise use the full `gitCommitSha`, which is the one
20
- * canonical spelling every profile agrees on.
21
- *
22
- * ponytail: takes the first scope's version when a plugin is installed at several scopes at once.
23
- * Per-scope drift is a different (rarer) bug than the cross-profile drift this exists to catch;
24
- * widen to Record<id, Record<scope, version>> if that ever bites.
19
+ * ONLY a real release `version` counts. `gitCommitSha` looks like a tempting fallback for the
20
+ * versionless plugins (Claude Code writes `"version": "unknown"` for those) but it is unusable as
21
+ * an identity, and comparing it produced drift that `fix` could never clear:
22
+ * - it is the sha of the whole MARKETPLACE repo, not of the plugin — two profiles whose plugin
23
+ * files are byte-identical still hold different shas if they installed at different times
24
+ * - Claude Code never refreshes it when `claude plugin update` finds nothing to do, so the stale
25
+ * value outlives every update. Seen on a real machine: context7 at 205b6e0b in one profile and
26
+ * e14e8fe2 in three others, identical file contents, identical marketplace snapshot, and
27
+ * `clp fix` looping forever because update kept (correctly) no-opping.
28
+ * A versionless plugin therefore contributes no drift signal at all. Detecting nothing beats
29
+ * detecting noise that has no remedy.
25
30
  */
26
31
  export function readInstalledPluginVersions(plugins) {
27
32
  const out = {};
28
33
  for (const [id, entries] of Object.entries(plugins)) {
29
- for (const e of Array.isArray(entries) ? entries : [entries]) {
30
- const { version, gitCommitSha } = (e ?? {});
31
- const sha = typeof gitCommitSha === 'string' && gitCommitSha ? gitCommitSha : null;
32
- const ver = typeof version === 'string' && version && version !== 'unknown' ? version : null;
33
- const isShaSpelling = !!ver && !!sha && sha.startsWith(ver);
34
- const v = ver && !isShaSpelling ? ver : sha ?? ver;
35
- if (v) {
36
- out[id] = v;
34
+ for (const { version } of userScopeEntries(entries)) {
35
+ if (typeof version === 'string' && version && version !== 'unknown') {
36
+ out[id] = version;
37
37
  break;
38
38
  }
39
39
  }
@@ -129,7 +129,7 @@ export async function discoverProfiles(home) {
129
129
  try {
130
130
  const ip = JSON.parse(await readFile(join(dir, 'plugins', 'installed_plugins.json'), 'utf8'));
131
131
  if (ip && typeof ip.plugins === 'object' && ip.plugins !== null) {
132
- installedPlugins.push(...Object.keys(ip.plugins));
132
+ installedPlugins.push(...Object.entries(ip.plugins).filter(([, v]) => userScopeEntries(v).length).map(([id]) => id));
133
133
  installedPluginVersions = readInstalledPluginVersions(ip.plugins);
134
134
  }
135
135
  }
package/dist/plugins.d.ts CHANGED
@@ -50,5 +50,29 @@ export declare function reconcileProfilePlugins(opts: {
50
50
  /** ids drifting across profiles — update them here so every profile lands on the same latest. */
51
51
  updateIds?: string[];
52
52
  }): Promise<string[]>;
53
+ /**
54
+ * Existing claude-mem cache dirs under a profile (`plugins/cache/<owner>/claude-mem`, any owner).
55
+ * Disk is the ground truth on purpose: a PROJECT-scope claude-mem install is invisible to
56
+ * installed_plugins.json's user-scope view (discovery's installedPlugins misses it), yet it still
57
+ * leaks the same way — `.claude-staffinc` was exactly this case on 2026-07-23. Gating the prune on
58
+ * install bookkeeping would skip the one profile that needed it most.
59
+ */
60
+ export declare function claudeMemCacheDirs(configDir: string): Promise<string[]>;
61
+ /**
62
+ * Keep only the highest-semver version dir under `cacheDir`, delete the rest. Returns removed names.
63
+ *
64
+ * Why this is the real fix for the claude-mem leak: the plugin's session-init hook resolves which
65
+ * worker to launch with `ls -dt <cache>/[0-9]*` — the newest dir by MTIME — and ignores the pinned
66
+ * version in installed_plugins.json entirely. So a leftover OLD version dir with a fresher mtime is
67
+ * launched instead of the pinned latest, reviving the shared-`~/.claude-mem`-worker fight even when
68
+ * every profile's pin is already correct. On 2026-07-23 this ran `.claude-data-plb` (pinned 13.12.1)
69
+ * up to 187 procs / 20GB swap, all from a stale 13.11.0 dir. Version *parity* (planPluginVersionDrift
70
+ * + `clp fix`) is necessary but not sufficient; the ambiguity only goes away when one dir remains.
71
+ *
72
+ * "Keep highest semver" == keep the intended active version, because `claude plugin update` only ever
73
+ * moves to the marketplace's latest — there is no way to pin an older one. Non-numeric dirs
74
+ * (git-sha/"unknown" caches of versionless plugins) are left untouched.
75
+ */
76
+ export declare function pruneStaleVersionDirs(cacheDir: string): Promise<string[]>;
53
77
  /** If a profile's plugins/ is a legacy symlink into the old shared pool, restore it to a real dir. */
54
78
  export declare function restoreLegacyPluginSymlink(pluginsDir: string): Promise<boolean>;
package/dist/plugins.js CHANGED
@@ -1,5 +1,6 @@
1
- import { cp, lstat, mkdir, readlink, unlink } from 'node:fs/promises';
1
+ import { cp, lstat, mkdir, readdir, readlink, rm, unlink } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
3
4
  export function marketplaceOf(id) {
4
5
  const at = id.lastIndexOf('@');
5
6
  return at > 0 ? id.slice(at + 1) : null;
@@ -72,6 +73,76 @@ export async function reconcileProfilePlugins(opts) {
72
73
  }
73
74
  return log;
74
75
  }
76
+ /**
77
+ * Existing claude-mem cache dirs under a profile (`plugins/cache/<owner>/claude-mem`, any owner).
78
+ * Disk is the ground truth on purpose: a PROJECT-scope claude-mem install is invisible to
79
+ * installed_plugins.json's user-scope view (discovery's installedPlugins misses it), yet it still
80
+ * leaks the same way — `.claude-staffinc` was exactly this case on 2026-07-23. Gating the prune on
81
+ * install bookkeeping would skip the one profile that needed it most.
82
+ */
83
+ export async function claudeMemCacheDirs(configDir) {
84
+ const cacheRoot = join(configDir, 'plugins', 'cache');
85
+ let owners;
86
+ try {
87
+ owners = await readdir(cacheRoot);
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ const out = [];
93
+ for (const owner of owners) {
94
+ const d = join(cacheRoot, owner, 'claude-mem');
95
+ if (existsSync(d))
96
+ out.push(d);
97
+ }
98
+ return out;
99
+ }
100
+ /** Compare dotted numeric version names ("13.9.2" < "13.11.0"). Missing parts count as 0. */
101
+ function compareVersionDir(a, b) {
102
+ const pa = a.split('.'), pb = b.split('.');
103
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
104
+ const x = Number(pa[i] ?? 0), y = Number(pb[i] ?? 0);
105
+ if (x !== y)
106
+ return x - y;
107
+ }
108
+ return 0;
109
+ }
110
+ /**
111
+ * Keep only the highest-semver version dir under `cacheDir`, delete the rest. Returns removed names.
112
+ *
113
+ * Why this is the real fix for the claude-mem leak: the plugin's session-init hook resolves which
114
+ * worker to launch with `ls -dt <cache>/[0-9]*` — the newest dir by MTIME — and ignores the pinned
115
+ * version in installed_plugins.json entirely. So a leftover OLD version dir with a fresher mtime is
116
+ * launched instead of the pinned latest, reviving the shared-`~/.claude-mem`-worker fight even when
117
+ * every profile's pin is already correct. On 2026-07-23 this ran `.claude-data-plb` (pinned 13.12.1)
118
+ * up to 187 procs / 20GB swap, all from a stale 13.11.0 dir. Version *parity* (planPluginVersionDrift
119
+ * + `clp fix`) is necessary but not sufficient; the ambiguity only goes away when one dir remains.
120
+ *
121
+ * "Keep highest semver" == keep the intended active version, because `claude plugin update` only ever
122
+ * moves to the marketplace's latest — there is no way to pin an older one. Non-numeric dirs
123
+ * (git-sha/"unknown" caches of versionless plugins) are left untouched.
124
+ */
125
+ export async function pruneStaleVersionDirs(cacheDir) {
126
+ let names;
127
+ try {
128
+ names = await readdir(cacheDir);
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ const versions = names.filter(n => /^\d/.test(n)).sort(compareVersionDir);
134
+ if (versions.length <= 1)
135
+ return [];
136
+ const keep = versions[versions.length - 1];
137
+ const removed = [];
138
+ for (const v of versions) {
139
+ if (v === keep)
140
+ continue;
141
+ await rm(join(cacheDir, v), { recursive: true, force: true });
142
+ removed.push(v);
143
+ }
144
+ return removed;
145
+ }
75
146
  /** If a profile's plugins/ is a legacy symlink into the old shared pool, restore it to a real dir. */
76
147
  export async function restoreLegacyPluginSymlink(pluginsDir) {
77
148
  let st = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccprofiles-core",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",