ccprofiles-core 0.7.3 → 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/discovery.d.ts +26 -0
- package/dist/discovery.js +43 -1
- package/dist/plugins.d.ts +34 -1
- package/dist/plugins.js +45 -4
- package/package.json +1 -1
package/dist/discovery.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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