ccprofiles-core 0.12.1 → 0.13.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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Which credentials claude-mem's observer runs on.
3
+ *
4
+ * claude-mem's worker is one process shared by every profile. When it spawns its observer
5
+ * `claude`, it STRIPS ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL and
6
+ * CLAUDE_CONFIG_DIR from the env it inherited, then re-adds credentials from exactly two places:
7
+ * 1. ~/.claude-mem/.env (those same three keys) — wins if present
8
+ * 2. the OAuth token of CLAUDE_MEM_CLAUDE_CONFIG_DIR (default ~/.claude), read from the
9
+ * keychain under the same per-dir service name Claude Code uses
10
+ * So a user whose only working account is a token/API-key profile gets no credentials at all,
11
+ * and claude-mem silently stops recording. We point it at an OAuth profile when one exists,
12
+ * and otherwise copy a key profile's credentials into a managed block of its .env.
13
+ * (Verified against claude-mem 13.39.0 worker-service.cjs: kGe/Eh/lre.)
14
+ */
15
+ export declare const CLAUDE_MEM_BEGIN = "# >>> ccprofiles managed >>>";
16
+ export declare const CLAUDE_MEM_END = "# <<< ccprofiles managed <<<";
17
+ export interface ClaudeMemCandidate {
18
+ name: string;
19
+ dir: string;
20
+ /** logged in with OAuth (`oauthAccount` in .claude.json) */
21
+ oauth: boolean;
22
+ /** the profile's provider env (settings.json env + launcher env); values may be secret:// refs */
23
+ env: Record<string, string>;
24
+ }
25
+ export type ClaudeMemAuth = {
26
+ kind: 'oauth';
27
+ profile: string;
28
+ configDir: string;
29
+ } | {
30
+ kind: 'api-key' | 'gateway';
31
+ profile: string;
32
+ env: Record<string, string>;
33
+ model?: string;
34
+ };
35
+ /**
36
+ * OAuth beats a key; a direct Anthropic key beats a gateway. Within a tier, the profile already in
37
+ * use stays (no churn between runs), then the default ~/.claude, then name order.
38
+ */
39
+ export declare function pickClaudeMemAuth(cands: ClaudeMemCandidate[], current?: {
40
+ configDir?: string;
41
+ profile?: string;
42
+ }): ClaudeMemAuth | null;
43
+ export interface ClaudeMemFiles {
44
+ /** parsed ~/.claude-mem/settings.json, or null when absent */
45
+ settings: Record<string, unknown> | null;
46
+ /** raw ~/.claude-mem/.env, '' when absent */
47
+ envText: string;
48
+ }
49
+ /** The profile the managed block was last written from — keeps the choice stable across runs. */
50
+ export declare function claudeMemManagedProfile(envText: string): string | undefined;
51
+ /**
52
+ * Desired settings.json + .env for `auth`, or `skip` when the user configured claude-mem by hand
53
+ * (another provider, or their own keys in .env) — those are never overwritten. `auth` env values
54
+ * must already be resolved (no secret:// refs).
55
+ */
56
+ export declare function renderClaudeMemConfig(files: ClaudeMemFiles, auth: ClaudeMemAuth, defaultConfigDir: string): {
57
+ skip: string;
58
+ } | {
59
+ settings: Record<string, unknown>;
60
+ envText: string;
61
+ changes: string[];
62
+ };
63
+ /** Read claude-mem's config. A corrupt settings.json throws — rewriting it would wipe every other key. */
64
+ export declare function readClaudeMemFiles(dataDir: string): Promise<ClaudeMemFiles>;
65
+ export declare function writeClaudeMemFiles(dataDir: string, before: ClaudeMemFiles, after: {
66
+ settings: Record<string, unknown>;
67
+ envText: string;
68
+ }): Promise<void>;
@@ -0,0 +1,169 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { atomicWrite } from './fsutil.js';
4
+ /**
5
+ * Which credentials claude-mem's observer runs on.
6
+ *
7
+ * claude-mem's worker is one process shared by every profile. When it spawns its observer
8
+ * `claude`, it STRIPS ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL and
9
+ * CLAUDE_CONFIG_DIR from the env it inherited, then re-adds credentials from exactly two places:
10
+ * 1. ~/.claude-mem/.env (those same three keys) — wins if present
11
+ * 2. the OAuth token of CLAUDE_MEM_CLAUDE_CONFIG_DIR (default ~/.claude), read from the
12
+ * keychain under the same per-dir service name Claude Code uses
13
+ * So a user whose only working account is a token/API-key profile gets no credentials at all,
14
+ * and claude-mem silently stops recording. We point it at an OAuth profile when one exists,
15
+ * and otherwise copy a key profile's credentials into a managed block of its .env.
16
+ * (Verified against claude-mem 13.39.0 worker-service.cjs: kGe/Eh/lre.)
17
+ */
18
+ export const CLAUDE_MEM_BEGIN = '# >>> ccprofiles managed >>>';
19
+ export const CLAUDE_MEM_END = '# <<< ccprofiles managed <<<';
20
+ const CRED_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL'];
21
+ // a gateway (z.ai, kimi, ...) rejects claude-mem's default Claude model id; these are where a
22
+ // profile declares which model the gateway actually serves for small/fast work
23
+ const MODEL_KEYS = ['ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL', 'ANTHROPIC_MODEL'];
24
+ const hasKey = (env) => !!(env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN);
25
+ /**
26
+ * OAuth beats a key; a direct Anthropic key beats a gateway. Within a tier, the profile already in
27
+ * use stays (no churn between runs), then the default ~/.claude, then name order.
28
+ */
29
+ export function pickClaudeMemAuth(cands, current = {}) {
30
+ const rank = (xs, isCurrent) => xs.find(isCurrent) ?? xs.find(c => c.dir.endsWith('/.claude') || c.dir.endsWith('\\.claude')) ?? xs[0];
31
+ // a profile that sets a key or base URL runs on THAT, not its OAuth login
32
+ const oauth = cands.filter(c => c.oauth && !hasKey(c.env) && !c.env.ANTHROPIC_BASE_URL);
33
+ if (oauth.length) {
34
+ const c = rank(oauth, c => c.dir === current.configDir);
35
+ return { kind: 'oauth', profile: c.name, configDir: c.dir };
36
+ }
37
+ const keyed = cands.filter(c => hasKey(c.env));
38
+ const direct = keyed.filter(c => !c.env.ANTHROPIC_BASE_URL);
39
+ const pool = direct.length ? direct : keyed;
40
+ if (!pool.length)
41
+ return null;
42
+ const c = rank(pool, c => c.name === current.profile);
43
+ const env = {};
44
+ for (const k of CRED_KEYS)
45
+ if (c.env[k])
46
+ env[k] = c.env[k];
47
+ if (!c.env.ANTHROPIC_BASE_URL)
48
+ return { kind: 'api-key', profile: c.name, env };
49
+ const model = MODEL_KEYS.map(k => c.env[k]).find(Boolean);
50
+ return { kind: 'gateway', profile: c.name, env, ...(model ? { model } : {}) };
51
+ }
52
+ /** Split .env into the managed block's lines and everything else. */
53
+ function splitEnv(text) {
54
+ const outside = [], block = [];
55
+ let inBlock = false;
56
+ for (const line of text.split('\n')) {
57
+ if (line === CLAUDE_MEM_BEGIN) {
58
+ inBlock = true;
59
+ continue;
60
+ }
61
+ if (line === CLAUDE_MEM_END) {
62
+ inBlock = false;
63
+ continue;
64
+ }
65
+ ;
66
+ (inBlock ? block : outside).push(line);
67
+ }
68
+ while (outside.length && outside[outside.length - 1] === '')
69
+ outside.pop();
70
+ return { outside, block };
71
+ }
72
+ const blockMeta = (block, key) => block.find(l => l.startsWith(`# ${key}=`))?.slice(`# ${key}=`.length);
73
+ /** The profile the managed block was last written from — keeps the choice stable across runs. */
74
+ export function claudeMemManagedProfile(envText) {
75
+ return blockMeta(splitEnv(envText).block, 'profile');
76
+ }
77
+ function envLine(k, v) {
78
+ // node's parseEnv (what claude-mem reads with) has its own quoting rules; refusing anything
79
+ // that would need quoting is simpler than matching them, and real tokens/URLs never do
80
+ if (!/^[^\s"'`#\\]+$/.test(v))
81
+ throw new Error(`${k} has characters claude-mem's .env cannot hold safely`);
82
+ return `${k}=${v}`;
83
+ }
84
+ /**
85
+ * Desired settings.json + .env for `auth`, or `skip` when the user configured claude-mem by hand
86
+ * (another provider, or their own keys in .env) — those are never overwritten. `auth` env values
87
+ * must already be resolved (no secret:// refs).
88
+ */
89
+ export function renderClaudeMemConfig(files, auth, defaultConfigDir) {
90
+ const settings = { ...(files.settings ?? {}) };
91
+ const provider = settings.CLAUDE_MEM_PROVIDER;
92
+ if (typeof provider === 'string' && provider && provider !== 'claude')
93
+ return { skip: `claude-mem uses provider "${provider}" — leaving its auth alone` };
94
+ const { outside, block } = splitEnv(files.envText);
95
+ if (outside.some(l => CRED_KEYS.some(k => l.trimStart().replace(/^export\s+/, '').startsWith(`${k}=`))))
96
+ return { skip: 'claude-mem has its own credentials in ~/.claude-mem/.env — leaving them alone' };
97
+ const changes = [];
98
+ const set = (k, v) => {
99
+ if ((settings[k] ?? '') !== v) {
100
+ settings[k] = v;
101
+ changes.push(`${k}=${v || '(default)'}`);
102
+ }
103
+ };
104
+ const prevModel = blockMeta(block, 'model-before');
105
+ let newBlock = [];
106
+ if (auth.kind === 'oauth') {
107
+ set('CLAUDE_MEM_CLAUDE_AUTH_METHOD', 'subscription');
108
+ // '' means ~/.claude — claude-mem treats the default dir as non-explicit either way
109
+ set('CLAUDE_MEM_CLAUDE_CONFIG_DIR', auth.configDir === defaultConfigDir ? '' : auth.configDir);
110
+ if (prevModel !== undefined)
111
+ set('CLAUDE_MEM_MODEL', prevModel);
112
+ }
113
+ else {
114
+ set('CLAUDE_MEM_CLAUDE_AUTH_METHOD', auth.kind);
115
+ set('CLAUDE_MEM_CLAUDE_CONFIG_DIR', '');
116
+ newBlock.push(`# profile=${auth.profile}`);
117
+ if (auth.kind === 'gateway' && auth.model) {
118
+ // remember the model we replaced, once, so switching back to Anthropic restores it
119
+ const before = prevModel ?? (typeof settings.CLAUDE_MEM_MODEL === 'string' ? settings.CLAUDE_MEM_MODEL : '');
120
+ newBlock.push(`# model-before=${before}`);
121
+ set('CLAUDE_MEM_MODEL', auth.model);
122
+ }
123
+ else if (prevModel !== undefined)
124
+ set('CLAUDE_MEM_MODEL', prevModel);
125
+ for (const [k, v] of Object.entries(auth.env))
126
+ newBlock.push(envLine(k, v));
127
+ }
128
+ const blockChanged = newBlock.join('\n') !== block.join('\n');
129
+ if (blockChanged)
130
+ changes.push(newBlock.length ? `.env credentials from profile "${auth.profile}"` : '.env credentials removed');
131
+ const envText = newBlock.length
132
+ ? [...outside, ...(outside.length ? [''] : []), CLAUDE_MEM_BEGIN, ...newBlock, CLAUDE_MEM_END, ''].join('\n')
133
+ : outside.length ? outside.join('\n') + '\n' : '';
134
+ return { settings, envText, changes };
135
+ }
136
+ /** Read claude-mem's config. A corrupt settings.json throws — rewriting it would wipe every other key. */
137
+ export async function readClaudeMemFiles(dataDir) {
138
+ let settings = null;
139
+ try {
140
+ settings = JSON.parse(await readFile(join(dataDir, 'settings.json'), 'utf8'));
141
+ }
142
+ catch (e) {
143
+ if (e?.code !== 'ENOENT')
144
+ throw new Error(`cannot read ${join(dataDir, 'settings.json')}: ${e.message} — fix or remove it`);
145
+ }
146
+ let envText = '';
147
+ try {
148
+ envText = await readFile(join(dataDir, '.env'), 'utf8');
149
+ }
150
+ catch (e) {
151
+ if (e?.code !== 'ENOENT')
152
+ throw e;
153
+ }
154
+ return { settings, envText };
155
+ }
156
+ export async function writeClaudeMemFiles(dataDir, before, after) {
157
+ const settingsPath = join(dataDir, 'settings.json');
158
+ if (JSON.stringify(after.settings) !== JSON.stringify(before.settings ?? {})) {
159
+ let mode = 0o600;
160
+ try {
161
+ mode = (await stat(settingsPath)).mode & 0o777;
162
+ }
163
+ catch { /* new file */ }
164
+ await atomicWrite(settingsPath, JSON.stringify(after.settings, null, 2) + '\n', { mode });
165
+ }
166
+ // .env holds a live API key — always owner-only
167
+ if (after.envText !== before.envText)
168
+ await atomicWrite(join(dataDir, '.env'), after.envText, { mode: 0o600 });
169
+ }
package/dist/index.d.ts CHANGED
@@ -20,3 +20,4 @@ export * from './delegate.js';
20
20
  export * from './scope.js';
21
21
  export * from './plugins.js';
22
22
  export * from './provider.js';
23
+ export * from './claudemem.js';
package/dist/index.js CHANGED
@@ -20,3 +20,4 @@ export * from './delegate.js';
20
20
  export * from './scope.js';
21
21
  export * from './plugins.js';
22
22
  export * from './provider.js';
23
+ export * from './claudemem.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccprofiles-core",
3
- "version": "0.12.1",
3
+ "version": "0.13.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",