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 +3 -0
- package/dist/apply.js +7 -2
- package/dist/manifest.d.ts +1 -0
- package/dist/manifest.js +6 -2
- package/dist/plugins.d.ts +23 -0
- package/dist/plugins.js +88 -3
- package/dist/secrets.js +3 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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