ccprofiles-core 0.5.1 → 0.7.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/manifest.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  import YAML from 'yaml';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
+ import { atomicWrite } from './fsutil.js';
7
8
  const exec = promisify(execFile);
8
9
  export class ManifestError extends Error {
9
10
  }
@@ -26,19 +27,39 @@ const ProfileSchema = z.object({
26
27
  settingsEnv: z.record(z.string()).default({}),
27
28
  skipPermissions: z.boolean().default(false),
28
29
  sharedSessions: z.boolean().default(false),
30
+ plugins: z.array(z.string()).default([]),
29
31
  });
32
+ const MarketplaceSchema = z.object({ source: z.string().min(1) });
30
33
  const ManifestSchema = z.object({
31
34
  version: z.literal(1),
32
35
  hub: z.string().nullable(),
33
36
  profiles: z.array(ProfileSchema),
34
37
  mcpServers: z.record(McpServerSchema),
38
+ marketplaces: z.record(MarketplaceSchema).default({}),
35
39
  });
36
40
  // identifiers that get interpolated into shell launcher code must be injection-safe
37
- const SAFE_NAME = /^[A-Za-z0-9_-]+$/; // profile names, launcher names, secret refs
41
+ // leading '-' is forbidden (but internal '-' still allowed) so a value can never be parsed as a
42
+ // CLI flag when passed as a positional argv entry (e.g. `claude plugin install <id>`)
43
+ const SAFE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; // profile names, launcher names, secret refs
38
44
  const SAFE_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; // POSIX/PowerShell env var names
39
45
  const SAFE_LINK_ENTRY = /^[A-Za-z0-9._-]+$/; // one profile-dir child; never a path
46
+ const SAFE_SOURCE = /^[A-Za-z0-9._@:/][A-Za-z0-9._/@:-]*$/; // marketplace source (interpolated into `claude plugin` shell-out); no leading '-'
40
47
  const SHELL_META = /["`$;|&()\n\r<>]/; // chars that could break out of a quoted shell context
41
48
  const SECRET_PREFIX = 'secret://';
49
+ // plaintext provider tokens that must never be committed to manifest git history
50
+ const PLAINTEXT_TOKEN = /sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}/;
51
+ /**
52
+ * True if `yaml` (raw manifest.yaml text, or any string) contains a plaintext provider
53
+ * token. saveManifest() uses this to decide whether to skip the git commit; doctor checks
54
+ * (CLI + UI) use it to surface that skip as a visible problem instead of a stderr-only warning.
55
+ */
56
+ export function manifestHasPlaintextSecret(yaml) {
57
+ return PLAINTEXT_TOKEN.test(yaml);
58
+ }
59
+ /** Reject any path with a literal `..` path-traversal segment, on either `/` or `\` separators. */
60
+ function hasDotDotSegment(path) {
61
+ return path.split(/[/\\]/).includes('..');
62
+ }
42
63
  /**
43
64
  * Reject manifests whose identifiers could break out of the shell launcher context.
44
65
  * Applies to every manifest — an untrusted bundle/peer manifest reaches this before
@@ -52,6 +73,8 @@ export function assertSafeManifest(m) {
52
73
  throw new ManifestError(`unsafe launcher name in profile "${p.name}": ${JSON.stringify(p.launcher)}`);
53
74
  if (SHELL_META.test(p.dir))
54
75
  throw new ManifestError(`unsafe profile dir in profile "${p.name}": ${JSON.stringify(p.dir)}`);
76
+ if (hasDotDotSegment(p.dir))
77
+ throw new ManifestError(`unsafe profile dir in profile "${p.name}": ${JSON.stringify(p.dir)} (must not contain a ".." segment)`);
55
78
  for (const [k, v] of Object.entries(p.env)) {
56
79
  if (!SAFE_ENV_KEY.test(k))
57
80
  throw new ManifestError(`unsafe env var name in profile "${p.name}": ${JSON.stringify(k)}`);
@@ -75,6 +98,19 @@ export function assertSafeManifest(m) {
75
98
  throw new ManifestError(`unsafe link entry in profile "${p.name}": ${JSON.stringify(entry)} (must be one file or directory name)`);
76
99
  }
77
100
  }
101
+ for (const [name, mk] of Object.entries(m.marketplaces ?? {})) {
102
+ if (!SAFE_NAME.test(name))
103
+ throw new ManifestError(`unsafe marketplace name: ${JSON.stringify(name)}`);
104
+ if (!SAFE_SOURCE.test(mk.source))
105
+ throw new ManifestError(`unsafe marketplace source for "${name}": ${JSON.stringify(mk.source)}`);
106
+ }
107
+ for (const p of m.profiles)
108
+ for (const id of p.plugins) {
109
+ const at = id.lastIndexOf('@');
110
+ const nm = at > 0 ? id.slice(0, at) : id, mkt = at > 0 ? id.slice(at + 1) : '';
111
+ if (!SAFE_NAME.test(nm) || !SAFE_NAME.test(mkt))
112
+ throw new ManifestError(`unsafe plugin id in profile "${p.name}": ${JSON.stringify(id)}`);
113
+ }
78
114
  }
79
115
  export function parseManifest(text) {
80
116
  let raw;
@@ -92,6 +128,13 @@ export function parseManifest(text) {
92
128
  for (const name of p.mcp)
93
129
  if (!m.mcpServers[name])
94
130
  throw new ManifestError(`profile "${p.name}" references undefined mcp server "${name}"`);
131
+ for (const p of m.profiles)
132
+ for (const id of p.plugins) {
133
+ const at = id.lastIndexOf('@');
134
+ const mkt = at > 0 ? id.slice(at + 1) : '';
135
+ if (!mkt || !m.marketplaces[mkt])
136
+ throw new ManifestError(`profile "${p.name}" references undefined marketplace for plugin "${id}"`);
137
+ }
95
138
  assertSafeManifest(m);
96
139
  return m;
97
140
  }
@@ -104,12 +147,38 @@ export async function loadManifest(root) {
104
147
  export async function saveManifest(root, m) {
105
148
  const yaml = serializeManifest(m);
106
149
  parseManifest(yaml); // guard: never write a manifest that cannot be reloaded
107
- await mkdir(root, { recursive: true });
108
- await writeFile(join(root, 'manifest.yaml'), yaml, 'utf8');
150
+ // manifestRoot also holds secrets.enc/devices.json — lock it down here since this is often
151
+ // the first thing (adopt/manifest init) to create it.
152
+ await mkdir(root, { recursive: true, mode: 0o700 });
153
+ // settingsEnv can carry plaintext secrets until `secrets migrate` runs — write at 0600.
154
+ await atomicWrite(join(root, 'manifest.yaml'), yaml, { mode: 0o600 });
155
+ if (manifestHasPlaintextSecret(yaml)) {
156
+ // never let a plaintext token land in git history — the file is still written above
157
+ // (callers may still need it on disk), we just refuse to commit it.
158
+ process.stderr.write('warn: manifest contains a plaintext secret — skipping git commit; run: ccprofiles secrets migrate\n');
159
+ return;
160
+ }
109
161
  try {
110
162
  await exec('git', ['-C', root, 'rev-parse', '--git-dir']);
111
163
  await exec('git', ['-C', root, 'add', '-A']);
112
164
  await exec('git', ['-C', root, 'commit', '-m', 'ccprofiles: update manifest']);
113
165
  }
114
- catch { /* not a repo or nothing to commit — fine */ }
166
+ catch (e) {
167
+ if (!isBenignGitError(e)) {
168
+ // manifest.yaml is already written above — don't throw, but don't silently drop history
169
+ // either. Anything other than "not a repo" / "nothing to commit" is a real failure
170
+ // (hook rejection, disk full, corrupt index, ...) the user should know about.
171
+ process.stderr.write(`warn: manifest.yaml written but git commit failed: ${gitErrorMessage(e)}\n`);
172
+ }
173
+ // else: not a git repo, or nothing changed to commit — expected, nothing to report
174
+ }
175
+ }
176
+ /** True for the two git outcomes saveManifest treats as expected/benign, not a real failure. */
177
+ function isBenignGitError(e) {
178
+ const text = gitErrorMessage(e);
179
+ return /nothing to commit/i.test(text) || /not a git repository/i.test(text);
180
+ }
181
+ function gitErrorMessage(e) {
182
+ const err = e;
183
+ return [err?.stderr, err?.stdout, err?.message].filter(Boolean).join(' ').trim() || String(e);
115
184
  }
package/dist/platform.js CHANGED
@@ -16,5 +16,12 @@ export function renderPath(template, p) {
16
16
  export function toTemplate(absPath, p) {
17
17
  const norm = absPath.replaceAll('\\', '/');
18
18
  const home = p.home.replaceAll('\\', '/');
19
- return norm.startsWith(home) ? '{home}' + norm.slice(home.length) : norm;
19
+ // require a path-separator boundary — a raw startsWith would also match sibling dirs
20
+ // that merely share a string prefix with home (e.g. home "/Users/tm" matching
21
+ // "/Users/tmfadhlul-old/...")
22
+ if (norm === home)
23
+ return '{home}';
24
+ if (norm.startsWith(home + '/'))
25
+ return '{home}' + norm.slice(home.length);
26
+ return norm;
20
27
  }
@@ -0,0 +1,21 @@
1
+ export interface PluginRunner {
2
+ marketplaceAdd(configDir: string, source: string): Promise<void>;
3
+ install(configDir: string, id: string): Promise<void>;
4
+ uninstall(configDir: string, id: string): Promise<void>;
5
+ }
6
+ export declare function marketplaceOf(id: string): string | null;
7
+ export declare function planPluginReconcile(desired: string[], current: string[]): {
8
+ install: string[];
9
+ uninstall: string[];
10
+ };
11
+ export declare function reconcileProfilePlugins(opts: {
12
+ configDir: string;
13
+ desired: string[];
14
+ current: string[];
15
+ marketplaces: Record<string, {
16
+ source: string;
17
+ }>;
18
+ runner: PluginRunner;
19
+ }): Promise<string[]>;
20
+ /** If a profile's plugins/ is a legacy symlink into the old shared pool, restore it to a real dir. */
21
+ export declare function restoreLegacyPluginSymlink(pluginsDir: string): Promise<boolean>;
@@ -0,0 +1,56 @@
1
+ import { cp, lstat, mkdir, readlink, unlink } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ export function marketplaceOf(id) {
4
+ const at = id.lastIndexOf('@');
5
+ return at > 0 ? id.slice(at + 1) : null;
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)) };
10
+ }
11
+ export async function reconcileProfilePlugins(opts) {
12
+ const log = [];
13
+ const { install, uninstall } = planPluginReconcile(opts.desired, opts.current);
14
+ for (const id of uninstall) {
15
+ await opts.runner.uninstall(opts.configDir, id);
16
+ log.push(`uninstall ${id}`);
17
+ }
18
+ const added = new Set();
19
+ for (const id of install) {
20
+ const mkt = marketplaceOf(id);
21
+ if (mkt && !added.has(mkt)) {
22
+ const src = opts.marketplaces[mkt]?.source;
23
+ if (src) {
24
+ await opts.runner.marketplaceAdd(opts.configDir, src);
25
+ added.add(mkt);
26
+ log.push(`add ${src}`);
27
+ }
28
+ }
29
+ await opts.runner.install(opts.configDir, id);
30
+ log.push(`install ${id}`);
31
+ }
32
+ return log;
33
+ }
34
+ /** If a profile's plugins/ is a legacy symlink into the old shared pool, restore it to a real dir. */
35
+ export async function restoreLegacyPluginSymlink(pluginsDir) {
36
+ let st = null;
37
+ try {
38
+ st = await lstat(pluginsDir);
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ if (!st.isSymbolicLink())
44
+ return false;
45
+ const target = await readlink(pluginsDir);
46
+ // Never unlink-and-empty when the pool target is gone — that would silently leave the
47
+ // profile with a permanently empty plugins/ and no way to recover the original content.
48
+ // Leave the symlink in place and fail loudly instead.
49
+ if (!existsSync(target)) {
50
+ throw new Error(`cannot restore ${pluginsDir}: symlink target ${target} does not exist — leaving the symlink in place`);
51
+ }
52
+ await unlink(pluginsDir);
53
+ await mkdir(pluginsDir, { recursive: true });
54
+ await cp(target, pluginsDir, { recursive: true, force: false, errorOnExist: false });
55
+ return true;
56
+ }
@@ -0,0 +1,10 @@
1
+ export type AnthropicAuthMode = 'login' | 'api-key' | 'auth-token';
2
+ /** Derive the current Anthropic auth mode from a settingsEnv map. */
3
+ export declare function anthropicAuthMode(env: Record<string, string>): AnthropicAuthMode;
4
+ /**
5
+ * New settingsEnv with `mode` applied. Non-auth keys preserved. `login` removes
6
+ * both token vars; `api-key`/`auth-token` set that var to `tokenRef` and remove
7
+ * the other. Throws if env has a custom ANTHROPIC_BASE_URL (non-Anthropic provider)
8
+ * or if a token mode is chosen without a tokenRef.
9
+ */
10
+ export declare function setAnthropicAuthMode(env: Record<string, string>, mode: AnthropicAuthMode, tokenRef?: string): Record<string, string>;
@@ -0,0 +1,30 @@
1
+ const API_KEY = 'ANTHROPIC_API_KEY';
2
+ const AUTH_TOKEN = 'ANTHROPIC_AUTH_TOKEN';
3
+ const BASE_URL = 'ANTHROPIC_BASE_URL';
4
+ /** Derive the current Anthropic auth mode from a settingsEnv map. */
5
+ export function anthropicAuthMode(env) {
6
+ if (env[API_KEY] !== undefined)
7
+ return 'api-key';
8
+ if (env[AUTH_TOKEN] !== undefined)
9
+ return 'auth-token';
10
+ return 'login';
11
+ }
12
+ /**
13
+ * New settingsEnv with `mode` applied. Non-auth keys preserved. `login` removes
14
+ * both token vars; `api-key`/`auth-token` set that var to `tokenRef` and remove
15
+ * the other. Throws if env has a custom ANTHROPIC_BASE_URL (non-Anthropic provider)
16
+ * or if a token mode is chosen without a tokenRef.
17
+ */
18
+ export function setAnthropicAuthMode(env, mode, tokenRef) {
19
+ if (env[BASE_URL] !== undefined && env[BASE_URL].trim())
20
+ throw new Error(`profile uses a custom provider base URL (${env[BASE_URL]}) — manage its token in the Provider editor, not as an Anthropic auth mode`);
21
+ const out = { ...env };
22
+ delete out[API_KEY];
23
+ delete out[AUTH_TOKEN];
24
+ if (mode === 'login')
25
+ return out;
26
+ if (!tokenRef || !tokenRef.trim())
27
+ throw new Error(`${mode} mode requires a token reference`);
28
+ out[mode === 'api-key' ? API_KEY : AUTH_TOKEN] = tokenRef.trim();
29
+ return out;
30
+ }
package/dist/rcblock.js CHANGED
@@ -18,6 +18,7 @@ function profileDirExpr(pr, p) {
18
18
  }
19
19
  function renderPosix(pr, p) {
20
20
  const lines = [`${pr.launcher}() {`];
21
+ lines.push(` if [ "$1" = handoff ]; then shift; command ccprofiles handoff --from ${pr.name} --to "$1"; return; fi`);
21
22
  for (const [k, v] of Object.entries(pr.env)) {
22
23
  lines.push(v.startsWith(SECRET_PREFIX)
23
24
  ? ` export ${k}="$(ccprofiles secrets get ${v.slice(SECRET_PREFIX.length)})"`
@@ -32,6 +33,7 @@ function renderPosix(pr, p) {
32
33
  }
33
34
  function renderPwsh(pr, p) {
34
35
  const lines = [`function ${pr.launcher} {`];
36
+ lines.push(` if ($args[0] -eq 'handoff') { ccprofiles handoff --from ${pr.name} --to $args[1]; return }`);
35
37
  for (const [k, v] of Object.entries(pr.env)) {
36
38
  lines.push(v.startsWith(SECRET_PREFIX)
37
39
  ? ` $env:${k} = (ccprofiles secrets get ${v.slice(SECRET_PREFIX.length)})`
package/dist/secrets.js CHANGED
@@ -35,12 +35,12 @@ export class FileBackend {
35
35
  const c = createCipheriv('aes-256-gcm', this.key(f.salt), iv);
36
36
  const data = Buffer.concat([c.update(value, 'utf8'), c.final()]);
37
37
  f.entries[key] = { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64') };
38
- await atomicWrite(this.filePath, JSON.stringify(f));
38
+ await atomicWrite(this.filePath, JSON.stringify(f), { mode: 0o600 });
39
39
  }
40
40
  async delete(key) {
41
41
  const f = await this.load();
42
42
  delete f.entries[key];
43
- await atomicWrite(this.filePath, JSON.stringify(f));
43
+ await atomicWrite(this.filePath, JSON.stringify(f), { mode: 0o600 });
44
44
  }
45
45
  }
46
46
  const realExec = promisify(execFile);
@@ -150,12 +150,12 @@ export class DpapiBackend {
150
150
  async set(key, value) {
151
151
  const f = await this.load();
152
152
  f.entries[key] = await this.crypt.protect(value);
153
- await atomicWrite(this.filePath, JSON.stringify(f));
153
+ await atomicWrite(this.filePath, JSON.stringify(f), { mode: 0o600 });
154
154
  }
155
155
  async delete(key) {
156
156
  const f = await this.load();
157
157
  delete f.entries[key];
158
- await atomicWrite(this.filePath, JSON.stringify(f));
158
+ await atomicWrite(this.filePath, JSON.stringify(f), { mode: 0o600 });
159
159
  }
160
160
  }
161
161
  export class SecretsStore {
@@ -171,7 +171,7 @@ export class SecretsStore {
171
171
  return JSON.parse(await readFile(this.indexPath, 'utf8'));
172
172
  }
173
173
  async writeIndex(names) {
174
- await atomicWrite(this.indexPath, JSON.stringify([...new Set(names)].sort()));
174
+ await atomicWrite(this.indexPath, JSON.stringify([...new Set(names)].sort()), { mode: 0o600 });
175
175
  }
176
176
  get backendName() { return this.backend.name; }
177
177
  async get(key) { return this.backend.get(key); }
@@ -6,6 +6,9 @@ export interface SessionMeta {
6
6
  gitBranch: string | null;
7
7
  model: string | null;
8
8
  sizeBytes: number;
9
+ /** true when the transcript was too large to fully scan (per-file cap) or the per-request
10
+ * byte budget ran out before reaching it — metadata is best-effort / incomplete. */
11
+ truncated?: boolean;
9
12
  }
10
13
  export interface ProjectSessions {
11
14
  agent: 'claude' | 'codex';
@@ -13,6 +16,26 @@ export interface ProjectSessions {
13
16
  project: string;
14
17
  sessions: SessionMeta[];
15
18
  }
19
+ export interface TranscriptEntry {
20
+ id: string;
21
+ role: 'user' | 'assistant' | 'tool';
22
+ text: string;
23
+ label: string | null;
24
+ timestamp: string | null;
25
+ }
26
+ export interface SessionTranscript {
27
+ id: string;
28
+ agent: 'claude' | 'codex';
29
+ scope: string;
30
+ project: string;
31
+ messages: TranscriptEntry[];
32
+ }
33
+ /** Test/observability hook: cumulative cache hit/miss counts across all scanSessions() calls
34
+ * in this process. Not reset automatically — callers that need deltas should diff two reads. */
35
+ export declare function sessionScanCacheStats(): {
36
+ hits: number;
37
+ misses: number;
38
+ };
16
39
  /** Best-effort decode of Claude Code's project dir name (cwd from a record is preferred). */
17
40
  export declare function decodeProjectDir(name: string): string;
18
41
  export declare function scanSessions(opts: {
@@ -23,3 +46,15 @@ export declare function scanSessions(opts: {
23
46
  agent?: 'claude' | 'codex';
24
47
  }[];
25
48
  }): Promise<ProjectSessions[]>;
49
+ /** Read visible conversation and tool activity for one known managed session. */
50
+ export declare function readSessionTranscript(opts: {
51
+ sharedRoot: string;
52
+ profiles: {
53
+ name: string;
54
+ dir: string;
55
+ agent?: 'claude' | 'codex';
56
+ }[];
57
+ agent: 'claude' | 'codex';
58
+ scope: string;
59
+ id: string;
60
+ }): Promise<SessionTranscript | null>;