ccprofiles-core 0.7.2 → 0.8.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/apply.js CHANGED
@@ -11,8 +11,26 @@ function configPathFor(dir, home, agent) {
11
11
  return join(dir, 'config.toml');
12
12
  return dir === join(home, '.claude') ? join(home, '.claude.json') : join(dir, '.claude.json');
13
13
  }
14
- function sortKeys(o) {
15
- return Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
14
+ /**
15
+ * Deep, order-insensitive normalization for drift comparison. Plain-object keys are sorted
16
+ * recursively at every depth (array element order is preserved — arrays like `args` are
17
+ * order-significant). Without recursing into nested objects, two semantically identical
18
+ * server defs could compare unequal purely from key-insertion order — which genuinely
19
+ * happens: Zod's `.passthrough()` always places schema-declared keys before passthrough
20
+ * (unknown) keys in its output, e.g. Codex's `startup_timeout_sec` (not in McpServerSchema)
21
+ * ends up after `env` once a manifest round-trips through the schema, while the live
22
+ * TOML file has it before `env` in source order — a manifest reload would otherwise never
23
+ * converge to "in sync" for any Codex server combining an `env` table with a vendor field.
24
+ */
25
+ function sortKeys(value) {
26
+ if (Array.isArray(value))
27
+ return value.map(sortKeys);
28
+ if (value && typeof value === 'object') {
29
+ return Object.fromEntries(Object.entries(value)
30
+ .map(([k, v]) => [k, sortKeys(v)])
31
+ .sort(([a], [b]) => a.localeCompare(b)));
32
+ }
33
+ return value;
16
34
  }
17
35
  const SECRET_PREFIX = 'secret://';
18
36
  // exported so adopt.ts (buildManifest) can recognize pooled session-dir symlinks with the
@@ -14,9 +14,35 @@ 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
+ *
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.
45
+ */
46
+ export declare function readInstalledPluginVersions(plugins: Record<string, unknown>): Record<string, string>;
21
47
  export declare function discoverProfiles(home: string): Promise<LiveProfile[]>;
22
48
  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
+ * Pull a comparable installed version out of each entry of installed_plugins.json's `plugins` map.
7
+ *
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.
25
+ */
26
+ export function readInstalledPluginVersions(plugins) {
27
+ const out = {};
28
+ 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;
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)
131
+ if (ip && typeof ip.plugins === 'object' && ip.plugins !== null) {
93
132
  installedPlugins.push(...Object.keys(ip.plugins));
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.2",
3
+ "version": "0.8.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",