ccprofiles-core 0.7.3 → 0.8.1

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.
@@ -14,9 +14,30 @@ export interface LiveProfile {
14
14
  * for "present". enabledPlugins alone can be stale: an enabled-but-never-installed entry
15
15
  * (e.g. written by the retired union-sharing feature) must still trigger an install. */
16
16
  installedPlugins: string[];
17
+ /** Comparable installed version per plugin id — a release version where there is one, else the
18
+ * git sha (see readInstalledPluginVersions for why those are interchangeable here). Ids with no
19
+ * version at all are absent. Feeds cross-profile drift detection (planPluginVersionDrift). */
20
+ installedPluginVersions: Record<string, string>;
17
21
  marketplaces: Record<string, {
18
22
  source: string;
19
23
  }>;
20
24
  }
25
+ /**
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.
28
+ *
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.
40
+ */
41
+ export declare function readInstalledPluginVersions(plugins: Record<string, unknown>): Record<string, string>;
21
42
  export declare function discoverProfiles(home: string): Promise<LiveProfile[]>;
22
43
  export declare function liveProfileName(lp: Pick<LiveProfile, 'agent' | 'dirName'>): string;
package/dist/discovery.js CHANGED
@@ -2,6 +2,44 @@ 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
+ }
15
+ /**
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.
18
+ *
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.
30
+ */
31
+ export function readInstalledPluginVersions(plugins) {
32
+ const out = {};
33
+ for (const [id, entries] of Object.entries(plugins)) {
34
+ for (const { version } of userScopeEntries(entries)) {
35
+ if (typeof version === 'string' && version && version !== 'unknown') {
36
+ out[id] = version;
37
+ break;
38
+ }
39
+ }
40
+ }
41
+ return out;
42
+ }
5
43
  export async function discoverProfiles(home) {
6
44
  const entries = await readdir(home, { withFileTypes: true });
7
45
  const out = [];
@@ -86,11 +124,14 @@ export async function discoverProfiles(home) {
86
124
  }
87
125
  catch { /* no plugins/known_marketplaces.json */ }
88
126
  const installedPlugins = [];
127
+ let installedPluginVersions = {};
89
128
  if (agent === 'claude')
90
129
  try {
91
130
  const ip = JSON.parse(await readFile(join(dir, 'plugins', 'installed_plugins.json'), 'utf8'));
92
- if (ip && typeof ip.plugins === 'object' && ip.plugins !== null)
93
- installedPlugins.push(...Object.keys(ip.plugins));
131
+ if (ip && typeof ip.plugins === 'object' && ip.plugins !== null) {
132
+ installedPlugins.push(...Object.entries(ip.plugins).filter(([, v]) => userScopeEntries(v).length).map(([id]) => id));
133
+ installedPluginVersions = readInstalledPluginVersions(ip.plugins);
134
+ }
94
135
  }
95
136
  catch { /* no plugins/installed_plugins.json */ }
96
137
  out.push({
@@ -105,6 +146,7 @@ export async function discoverProfiles(home) {
105
146
  settingsEnv,
106
147
  enabledPlugins,
107
148
  installedPlugins,
149
+ installedPluginVersions,
108
150
  marketplaces,
109
151
  });
110
152
  }
package/dist/plugins.d.ts CHANGED
@@ -2,11 +2,42 @@ export interface PluginRunner {
2
2
  marketplaceAdd(configDir: string, source: string): Promise<void>;
3
3
  install(configDir: string, id: string): Promise<void>;
4
4
  uninstall(configDir: string, id: string): Promise<void>;
5
+ /** `claude plugin update` — moves a plugin to the marketplace's latest. There is deliberately no
6
+ * version argument: the underlying CLI has none (see planPluginVersionDrift). */
7
+ update(configDir: string, id: string): Promise<void>;
5
8
  }
6
9
  export declare function marketplaceOf(id: string): string | null;
7
- export declare function planPluginReconcile(desired: string[], current: string[]): {
10
+ /** One plugin installed at more than one version across profiles. */
11
+ export interface PluginVersionDrift {
12
+ id: string;
13
+ /** profile name -> installed version, for the profiles that have this plugin at a known version */
14
+ byProfile: Record<string, string>;
15
+ }
16
+ /**
17
+ * Find plugins installed at DIFFERENT versions across profiles.
18
+ *
19
+ * Why this exists: profiles are separate config dirs, but some plugins keep global singleton state
20
+ * outside them and are therefore shared by every profile at once. claude-mem is the motivating case
21
+ * — all profiles share one `~/.claude-mem/` worker, and its hooks compare their own plugin version
22
+ * against the running worker's. When two profiles sit at different versions, each session decides
23
+ * the other's worker is stale, kills it and respawns its own, forever. On 2026-07-16 that loop
24
+ * orphaned 1,246 chroma-mcp processes and exhausted 40GB of swap. Presence parity (which this tool
25
+ * already enforced) is not enough; version parity is the real invariant.
26
+ *
27
+ * A profile missing the plugin entirely is NOT drift — that's planPluginReconcile's job. Only ids
28
+ * present at 2+ distinct known versions are reported.
29
+ */
30
+ export declare function planPluginVersionDrift(profiles: {
31
+ name: string;
32
+ versions: Record<string, string>;
33
+ }[]): PluginVersionDrift[];
34
+ export declare function planPluginReconcile(desired: string[], current: string[],
35
+ /** ids to re-level to the marketplace's latest (from planPluginVersionDrift); ignored unless the
36
+ * id is both desired and already installed — installing/uninstalling takes precedence. */
37
+ updateIds?: string[]): {
8
38
  install: string[];
9
39
  uninstall: string[];
40
+ update: string[];
10
41
  };
11
42
  export declare function reconcileProfilePlugins(opts: {
12
43
  configDir: string;
@@ -16,6 +47,8 @@ export declare function reconcileProfilePlugins(opts: {
16
47
  source: string;
17
48
  }>;
18
49
  runner: PluginRunner;
50
+ /** ids drifting across profiles — update them here so every profile lands on the same latest. */
51
+ updateIds?: string[];
19
52
  }): Promise<string[]>;
20
53
  /** If a profile's plugins/ is a legacy symlink into the old shared pool, restore it to a real dir. */
21
54
  export declare function restoreLegacyPluginSymlink(pluginsDir: string): Promise<boolean>;
package/dist/plugins.js CHANGED
@@ -4,13 +4,50 @@ export function marketplaceOf(id) {
4
4
  const at = id.lastIndexOf('@');
5
5
  return at > 0 ? id.slice(at + 1) : null;
6
6
  }
7
- export function planPluginReconcile(desired, current) {
8
- const d = new Set(desired), c = new Set(current);
9
- return { install: desired.filter(id => !c.has(id)), uninstall: current.filter(id => !d.has(id)) };
7
+ /**
8
+ * Find plugins installed at DIFFERENT versions across profiles.
9
+ *
10
+ * Why this exists: profiles are separate config dirs, but some plugins keep global singleton state
11
+ * outside them and are therefore shared by every profile at once. claude-mem is the motivating case
12
+ * — all profiles share one `~/.claude-mem/` worker, and its hooks compare their own plugin version
13
+ * against the running worker's. When two profiles sit at different versions, each session decides
14
+ * the other's worker is stale, kills it and respawns its own, forever. On 2026-07-16 that loop
15
+ * orphaned 1,246 chroma-mcp processes and exhausted 40GB of swap. Presence parity (which this tool
16
+ * already enforced) is not enough; version parity is the real invariant.
17
+ *
18
+ * A profile missing the plugin entirely is NOT drift — that's planPluginReconcile's job. Only ids
19
+ * present at 2+ distinct known versions are reported.
20
+ */
21
+ export function planPluginVersionDrift(profiles) {
22
+ const byId = new Map();
23
+ for (const p of profiles) {
24
+ for (const [id, version] of Object.entries(p.versions)) {
25
+ if (!byId.has(id))
26
+ byId.set(id, {});
27
+ byId.get(id)[p.name] = version;
28
+ }
29
+ }
30
+ const out = [];
31
+ for (const [id, byProfile] of byId) {
32
+ if (new Set(Object.values(byProfile)).size > 1)
33
+ out.push({ id, byProfile });
34
+ }
35
+ return out.sort((a, b) => a.id.localeCompare(b.id));
36
+ }
37
+ export function planPluginReconcile(desired, current,
38
+ /** ids to re-level to the marketplace's latest (from planPluginVersionDrift); ignored unless the
39
+ * id is both desired and already installed — installing/uninstalling takes precedence. */
40
+ updateIds = []) {
41
+ const d = new Set(desired), c = new Set(current), u = new Set(updateIds);
42
+ return {
43
+ install: desired.filter(id => !c.has(id)),
44
+ uninstall: current.filter(id => !d.has(id)),
45
+ update: desired.filter(id => c.has(id) && u.has(id)),
46
+ };
10
47
  }
11
48
  export async function reconcileProfilePlugins(opts) {
12
49
  const log = [];
13
- const { install, uninstall } = planPluginReconcile(opts.desired, opts.current);
50
+ const { install, uninstall, update } = planPluginReconcile(opts.desired, opts.current, opts.updateIds);
14
51
  for (const id of uninstall) {
15
52
  await opts.runner.uninstall(opts.configDir, id);
16
53
  log.push(`uninstall ${id}`);
@@ -29,6 +66,10 @@ export async function reconcileProfilePlugins(opts) {
29
66
  await opts.runner.install(opts.configDir, id);
30
67
  log.push(`install ${id}`);
31
68
  }
69
+ for (const id of update) {
70
+ await opts.runner.update(opts.configDir, id);
71
+ log.push(`update ${id}`);
72
+ }
32
73
  return log;
33
74
  }
34
75
  /** If a profile's plugins/ is a legacy symlink into the old shared pool, restore it to a real dir. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccprofiles-core",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
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",