claude-memory-admin 1.10.1 → 1.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.
@@ -23,6 +23,7 @@ const NAVIGATION = [
23
23
  { id: 'instructions', label: 'Instructions' },
24
24
  { id: 'settings', label: 'Settings' },
25
25
  { id: 'cost', label: 'Cost' },
26
+ { id: 'attribution', label: 'Attribution' },
26
27
  { id: 'sessions', label: 'Sessions' },
27
28
  { id: 'tools', label: 'Tools' },
28
29
  ],
@@ -41,6 +42,7 @@ function segmentVisible(tab, segment, store) {
41
42
  }
42
43
  if (segment === 'instructions') return global || hasProjectDir(store);
43
44
  if (segment === 'cost') return global;
45
+ if (segment === 'attribution') return global;
44
46
  if (segment === 'tools') return state.tools.length > 0;
45
47
  if (global) return false;
46
48
  if (segment === 'sessions') return Boolean(store.sessions?.count);
@@ -56,11 +58,17 @@ export const costProblems = () => {
56
58
  ];
57
59
  };
58
60
 
61
+ export const attributionProblems = () => state.aux.attribution?.settings?.problems || [];
62
+
59
63
  function segmentBadge(id) {
60
64
  if (id === 'cost') {
61
65
  const problems = costProblems();
62
66
  return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
63
67
  }
68
+ if (id === 'attribution') {
69
+ const problems = attributionProblems();
70
+ return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
71
+ }
64
72
  if (id === 'instructions') {
65
73
  const problems = state.aux.instructions?.problems || [];
66
74
  return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
@@ -107,6 +115,7 @@ function tabBadge(tab, store) {
107
115
  ...(state.aux.instructions?.problems || []),
108
116
  ...(state.aux.settings?.problems || []),
109
117
  ...costProblems(),
118
+ ...attributionProblems(),
110
119
  ];
111
120
  return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
112
121
  }
@@ -258,5 +258,29 @@ export function renderIssue(item, memories) {
258
258
  );
259
259
  }
260
260
 
261
+ if (item.kind === 'agent-memory-inert') {
262
+ return issue(
263
+ 'Auto memory is off, so this store is frozen',
264
+ `subagent memory is part of auto memory${item.setBy ? `, and ${item.setBy} turns it off` : ' and it is turned off'}. The memory: field on ${item.agentName} has no effect while that is the case: the agent starts with no memory instructions and no file tools, so nothing here is read and nothing new is written.`,
265
+ { bad },
266
+ );
267
+ }
268
+
269
+ if (item.kind === 'agent-store-orphan') {
270
+ return issue(
271
+ 'Nothing declares this store any more',
272
+ `${item.defined ? `${item.agentName} exists but no longer carries a memory: field` : `no agent named ${item.agentName} was found in any scope`}. A store is created by that field and outlives it, so this directory is still holding what it learned and no session will read it again.`,
273
+ { bad },
274
+ );
275
+ }
276
+
277
+ if (item.kind === 'agent-store-scope-mismatch') {
278
+ return issue(
279
+ `${item.agentName} keeps its memory in the ${item.declaredScope} scope now`,
280
+ `this store is the ${item.scope} one${item.declaredBy ? `, but ${item.declaredBy} declares memory: ${item.declaredScope}` : ''}. The live store is the ${item.declaredScope} one; this is what the agent wrote before the field changed.`,
281
+ { bad },
282
+ );
283
+ }
284
+
261
285
  return issue(item.kind, JSON.stringify(item));
262
286
  }
@@ -11,6 +11,26 @@ function storeSubtitle(store) {
11
11
  return `${scope} · ${store.sublabel}`;
12
12
  }
13
13
 
14
+ function agentMarker(store) {
15
+ if (!String(store.kind).startsWith('agent-') || !store.linkage) return null;
16
+ if (store.inert) {
17
+ return { text: 'inert', title: `Auto memory is off${store.inertBy ? ` (${store.inertBy})` : ''}, so the memory: field has no effect and this store is frozen.` };
18
+ }
19
+ if (store.linked) return null;
20
+ if (store.declaredScope) {
21
+ return {
22
+ text: 'moved',
23
+ title: `${store.agentName} now declares memory: ${store.declaredScope}, so the live store is elsewhere and this one is stale.`,
24
+ };
25
+ }
26
+ return {
27
+ text: 'orphan',
28
+ title: store.defined
29
+ ? `${store.agentName} exists but declares no memory: field any more, so nothing loads this store.`
30
+ : `No agent named ${store.agentName} was found in any scope, so nothing loads this store.`,
31
+ };
32
+ }
33
+
14
34
  function issueTitle(store) {
15
35
  const parts = [];
16
36
  if (store.issueCount) parts.push(`${store.issueCount} to fix in Cleanup`);
@@ -32,6 +52,7 @@ function storeButton(store) {
32
52
  const global = store.kind === 'global';
33
53
  const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
34
54
  const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
55
+ const marker = agentMarker(store);
35
56
  const active = state.activeSessions.filter((s) => s.storeId === store.id);
36
57
  return node('button', {
37
58
  class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
@@ -43,6 +64,7 @@ function storeButton(store) {
43
64
  node('span', { class: ui.storeName, text: store.label }),
44
65
  active.length ? node('span', { class: ui.dot('ok'), title: activeTitle(active) }) : null,
45
66
  off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
67
+ marker ? node('span', { class: ui.offMarker, text: marker.text, title: marker.title }) : null,
46
68
  global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
47
69
  ]),
48
70
  node('span', { class: ui.storePath, text: storeSubtitle(store) }),
package/server.mjs CHANGED
@@ -13,7 +13,8 @@ import { buildStore } from './src/model.mjs';
13
13
  import { resolveGlobalInstructions, resolveInstructions, summarise } from './src/instructions.mjs';
14
14
  import { settingsReport, summariseSettings } from './src/settings.mjs';
15
15
  import { costReport, writeUserSetting } from './src/cost.mjs';
16
- import { AGENTS_DIR, AGENT_FIELDS, agentsDirExists, listAgents, setAgentField } from './src/agents.mjs';
16
+ import { attributionReport, writeAttributionSetting } from './src/attribution.mjs';
17
+ import { AGENTS_DIR, AGENT_FIELDS, agentsDirExists, listAllAgents, setAgentField } from './src/agents.mjs';
17
18
  import { listStores } from './src/stores.mjs';
18
19
  import { forgetPath, rememberPath } from './src/pathcache.mjs';
19
20
  import { searchAll } from './src/search.mjs';
@@ -140,6 +141,67 @@ const VERSION = (() => {
140
141
  }
141
142
  })();
142
143
 
144
+ /**
145
+ * Every agent definition on the machine: the user directory plus the agents
146
+ * directory of each repository auto memory has already resolved a path for.
147
+ * Nothing here guesses at a repository that was not confirmed somewhere else.
148
+ */
149
+ function allAgents() {
150
+ const projectPaths = listStores(ROOT)
151
+ .filter((store) => store.kind === 'auto' && store.pathExists)
152
+ .flatMap((store) => [store.path, ...(store.workingDirs || [])]);
153
+ return listAllAgents({ projectPaths });
154
+ }
155
+
156
+ /** Each subagent memory store next to the definition that asks for it, for the agents panel. */
157
+ function agentStoreLinks() {
158
+ return listStores(ROOT)
159
+ .filter((store) => String(store.kind).startsWith('agent-'))
160
+ .map((store) => ({
161
+ id: store.id,
162
+ kind: store.kind,
163
+ agentName: store.agentName,
164
+ projectPath: store.projectPath,
165
+ memoryCount: store.memoryCount,
166
+ declaredBy: store.declaredBy ?? null,
167
+ declaredScope: store.declaredScope ?? null,
168
+ linked: Boolean(store.linked),
169
+ defined: Boolean(store.defined),
170
+ inert: Boolean(store.inert),
171
+ inertBy: store.inertBy ?? null,
172
+ }));
173
+ }
174
+
175
+ /**
176
+ * Hand the address to whatever opens a URL here, and never let that decide
177
+ * whether the server runs.
178
+ *
179
+ * `start` is a cmd.exe builtin rather than a program, so spawning it by name
180
+ * fails on Windows; the documented form is `cmd /c start "" <url>`, where the
181
+ * empty string is the window title `start` would otherwise read the URL as. On
182
+ * Linux `xdg-open` is simply missing on a minimal install and inside some
183
+ * containers. Either way the failure arrives as an async 'error' event, which
184
+ * with no listener is an uncaught exception that would take down a server that
185
+ * had already printed its address and was working perfectly well.
186
+ */
187
+ function openBrowser(address) {
188
+ const [command, args] = process.platform === 'darwin'
189
+ ? ['open', [address]]
190
+ : process.platform === 'win32'
191
+ ? [process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', 'start', '""', address.replace(/&/g, '^&')]]
192
+ : ['xdg-open', [address]];
193
+
194
+ try {
195
+ const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true });
196
+ child.on('error', () => {
197
+ console.log('Could not open a browser automatically - open the address above yourself.');
198
+ });
199
+ child.unref();
200
+ } catch {
201
+ console.log('Could not open a browser automatically - open the address above yourself.');
202
+ }
203
+ }
204
+
143
205
  function storeProjectDir(store) {
144
206
  // The global store is the user scope itself, which no project owns.
145
207
  if (store.kind === 'global') return null;
@@ -160,7 +222,7 @@ function storeProjectDir(store) {
160
222
  * frontmatter fields in a named file inside ~/.claude/agents. Neither can reach
161
223
  * an instruction file, which is what the rest of this guard exists to protect.
162
224
  */
163
- const GLOBAL_WRITE_ACTIONS = new Set(['cost/setting', 'cost/agent']);
225
+ const GLOBAL_WRITE_ACTIONS = new Set(['cost/setting', 'cost/agent', 'attribution/setting']);
164
226
 
165
227
  export function refuseWritesToGlobal(store, method, action = null) {
166
228
  if (store.kind !== 'global' || method === 'GET') return;
@@ -328,13 +390,24 @@ async function handleApi(req, res, url) {
328
390
  return sendJson(res, 400, { error: 'The cost settings are user-scope, and are edited from the Global entry.' });
329
391
  }
330
392
 
393
+ // The Attribution segment. User scope only, for the same reason as Cost:
394
+ // it edits ~/.claude/settings.json, which applies to every session.
395
+ if ((action === 'attribution' || action.startsWith('attribution/')) && store.kind !== 'global') {
396
+ return sendJson(res, 400, { error: 'The attribution settings are user-scope, and are edited from the Global entry.' });
397
+ }
398
+
331
399
  if (action === 'cost' && req.method === 'GET') {
332
400
  return sendJson(res, 200, {
333
401
  settings: costReport(),
334
- agents: listAgents(),
402
+ // Project-scope definitions come along read-only: an agent with
403
+ // `memory: project` lives in a repository rather than in the user
404
+ // directory, and leaving it out would show its memory store as belonging
405
+ // to no agent at all.
406
+ agents: allAgents(),
335
407
  agentsDir: AGENTS_DIR,
336
408
  agentsDirExists: agentsDirExists(),
337
409
  agentFields: AGENT_FIELDS,
410
+ agentStores: agentStoreLinks(),
338
411
  });
339
412
  }
340
413
 
@@ -346,8 +419,23 @@ async function handleApi(req, res, url) {
346
419
 
347
420
  if (action === 'cost/agent' && req.method === 'POST') {
348
421
  const body = await readBody(req);
349
- const agents = setAgentField(body.file, body.field, body.value ?? null);
350
- return sendJson(res, 200, { agents, agentsDir: AGENTS_DIR, agentsDirExists: agentsDirExists() });
422
+ setAgentField(body.file, body.field, body.value ?? null);
423
+ return sendJson(res, 200, {
424
+ agents: allAgents(),
425
+ agentsDir: AGENTS_DIR,
426
+ agentsDirExists: agentsDirExists(),
427
+ agentStores: agentStoreLinks(),
428
+ });
429
+ }
430
+
431
+ if (action === 'attribution' && req.method === 'GET') {
432
+ return sendJson(res, 200, { settings: attributionReport() });
433
+ }
434
+
435
+ if (action === 'attribution/setting' && req.method === 'POST') {
436
+ const body = await readBody(req);
437
+ writeAttributionSetting(body.key, body.value ?? null);
438
+ return sendJson(res, 200, { settings: attributionReport() });
351
439
  }
352
440
 
353
441
  if (action === 'delete-preview' && req.method === 'POST') {
@@ -484,10 +572,7 @@ export function startServer({ port, root, open = true } = {}) {
484
572
  const address = `http://localhost:${listenPort}`;
485
573
  console.log(`Memory Admin -> ${address}`);
486
574
  console.log(`Reading -> ${ROOT}`);
487
- if (open && !process.env.NO_OPEN) {
488
- const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
489
- spawn(opener, [address], { stdio: 'ignore', detached: true }).unref();
490
- }
575
+ if (open && !process.env.NO_OPEN) openBrowser(address);
491
576
  });
492
577
  return server;
493
578
  }
package/src/agents.mjs CHANGED
@@ -1,9 +1,12 @@
1
- // User-scope subagent definitions: ~/.claude/agents/*.md.
1
+ // Subagent definitions: agents/*.md, in the user scope and in each repository.
2
2
  //
3
- // Not to be confused with ~/.claude/agent-memory (src/stores.mjs), which is what
4
- // those agents *remember*. This module is about what they *are*: a markdown file
5
- // whose frontmatter names the agent and, optionally, pins the model and effort
6
- // it runs at. Pinning a summariser to Haiku while a reviewer stays on Opus is the
3
+ // Not to be confused with agent-memory (src/stores.mjs), which is what those
4
+ // agents *remember*. This module is about what they *are*: a markdown file whose
5
+ // frontmatter names the agent and, optionally, pins the model and effort it runs
6
+ // at, and declares with `memory:` whether it keeps a memory directory at all.
7
+ // That last field is the link between the two: a store exists because some file
8
+ // here asked for it, and a store no file asks for any more is a store nothing
9
+ // loads. Pinning a summariser to Haiku while a reviewer stays on Opus is the
7
10
  // finer-grained version of the CLAUDE_CODE_SUBAGENT_MODEL switch in src/cost.mjs,
8
11
  // which - worth remembering when both are set - outranks everything here.
9
12
  //
@@ -12,13 +15,21 @@
12
15
  // somebody wrote on purpose, and this tool has no view on it.
13
16
 
14
17
  import fs from 'node:fs';
15
- import os from 'node:os';
16
18
  import path from 'node:path';
17
19
 
20
+ import { canonicalPath, configPath } from './config.mjs';
18
21
  import { parseFrontmatter } from './parse.mjs';
19
22
  import { writeFileAtomic } from './mutate.mjs';
20
23
 
21
- export const AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
24
+ export const AGENTS_DIR = configPath('agents');
25
+ export const PROJECT_AGENTS_DIR = path.join('.claude', 'agents');
26
+
27
+ /** The scopes `memory:` accepts, and the store kind each one produces. */
28
+ export const MEMORY_SCOPES = {
29
+ user: 'agent-user',
30
+ project: 'agent-project',
31
+ local: 'agent-local',
32
+ };
22
33
 
23
34
  const MODEL_ID = /^claude-[A-Za-z0-9._[\]-]+$/;
24
35
 
@@ -55,6 +66,7 @@ export const AGENT_PROBLEM_SEVERITY = {
55
66
  'name-mismatch': 'warn',
56
67
  'unknown-model': 'warn',
57
68
  'unknown-effort': 'warn',
69
+ 'unknown-memory-scope': 'warn',
58
70
  };
59
71
 
60
72
  function known(field, value) {
@@ -92,9 +104,9 @@ export function safeAgentPath(dir, file) {
92
104
  return full;
93
105
  }
94
106
 
95
- function describeAgent(dir, file) {
107
+ function describeAgent(dir, file, { scope = 'user', writable = true, projectPath = null } = {}) {
96
108
  const full = path.join(dir, file);
97
- const stem = file.replace(/\.md$/, '');
109
+ const stem = path.basename(file).replace(/\.md$/, '');
98
110
 
99
111
  let text;
100
112
  try {
@@ -102,11 +114,15 @@ function describeAgent(dir, file) {
102
114
  } catch (err) {
103
115
  return {
104
116
  file,
117
+ scope,
118
+ writable: false,
119
+ projectPath,
105
120
  name: stem,
106
121
  description: '',
107
122
  tools: '',
108
123
  model: null,
109
124
  effort: null,
125
+ memory: null,
110
126
  bytes: 0,
111
127
  problems: [{ kind: 'no-frontmatter', severity: 'bad', detail: err.message }],
112
128
  };
@@ -117,6 +133,7 @@ function describeAgent(dir, file) {
117
133
  const name = scalar('name') || stem;
118
134
  const model = scalar('model') || null;
119
135
  const effort = scalar('effort') || null;
136
+ const memory = scalar('memory') || null;
120
137
 
121
138
  const problems = [];
122
139
  const flag = (kind, detail) => problems.push({ kind, severity: AGENT_PROBLEM_SEVERITY[kind], detail });
@@ -129,26 +146,39 @@ function describeAgent(dir, file) {
129
146
  if (scalar('name') && scalar('name') !== stem) flag('name-mismatch', `Named "${scalar('name')}" in a file called "${file}".`);
130
147
  if (model && !known('model', model)) flag('unknown-model', `model: ${model} is neither an alias nor a claude- model name.`);
131
148
  if (effort && !known('effort', effort)) flag('unknown-effort', `effort: ${effort} is not one of low, medium, high, xhigh or max.`);
149
+ if (memory && !Object.prototype.hasOwnProperty.call(MEMORY_SCOPES, memory)) {
150
+ flag('unknown-memory-scope', `memory: ${memory} is not one of user, project or local, so this agent keeps no memory directory.`);
151
+ }
132
152
  }
133
153
 
134
154
  return {
135
155
  file,
156
+ scope,
157
+ // Only a plain .md at the top of the user directory is rewritable: writes go
158
+ // through safeAgentPath, which takes a bare basename, and nothing here has
159
+ // any business editing a file inside somebody's repository.
160
+ writable: writable && file === path.basename(file),
161
+ projectPath,
136
162
  name,
137
163
  description: scalar('description'),
138
164
  tools: scalar('tools'),
139
165
  model,
140
166
  effort,
167
+ memory: memory && Object.prototype.hasOwnProperty.call(MEMORY_SCOPES, memory) ? memory : null,
168
+ memoryRaw: memory,
141
169
  bytes: Buffer.byteLength(text, 'utf8'),
142
170
  problems,
143
171
  };
144
172
  }
145
173
 
146
174
  /**
147
- * Every user-scope agent definition. A directory that is not there is the
148
- * ordinary state of a machine whose owner has never written one, not a failure,
149
- * so it answers with an empty list.
175
+ * Every .md under an agents directory, including the subfolders people use to
176
+ * group them. Claude Code scans both agent roots recursively and identifies an
177
+ * agent by its `name` rather than by where the file sits, so a scan that stopped
178
+ * at the top level would miss agents that are running.
150
179
  */
151
- export function listAgents({ dir = AGENTS_DIR } = {}) {
180
+ function agentFiles(dir, prefix = '', depth = 0) {
181
+ if (depth > 4) return [];
152
182
  let entries;
153
183
  try {
154
184
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -156,12 +186,54 @@ export function listAgents({ dir = AGENTS_DIR } = {}) {
156
186
  return [];
157
187
  }
158
188
 
159
- return entries
160
- .filter((entry) => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('.'))
161
- .map((entry) => describeAgent(dir, entry.name))
189
+ const files = [];
190
+ for (const entry of entries) {
191
+ if (entry.name.startsWith('.')) continue;
192
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
193
+ if (entry.isDirectory()) files.push(...agentFiles(path.join(dir, entry.name), rel, depth + 1));
194
+ else if (entry.isFile() && entry.name.endsWith('.md')) files.push(rel);
195
+ }
196
+ return files;
197
+ }
198
+
199
+ /**
200
+ * Every user-scope agent definition. A directory that is not there is the
201
+ * ordinary state of a machine whose owner has never written one, not a failure,
202
+ * so it answers with an empty list.
203
+ */
204
+ export function listAgents({ dir = AGENTS_DIR } = {}) {
205
+ return agentFiles(dir)
206
+ .map((file) => describeAgent(dir, file, { scope: 'user', writable: true }))
207
+ .sort((a, b) => a.name.localeCompare(b.name));
208
+ }
209
+
210
+ /**
211
+ * Project-scope definitions, from <repo>/.claude/agents. These are read and
212
+ * never written: they belong to a repository somebody else may share, and an
213
+ * agent with `memory: project` is normally defined here rather than in the user
214
+ * scope, so leaving them out would orphan the very stores this exists to explain.
215
+ */
216
+ export function listProjectAgents(projectPath, { relative = PROJECT_AGENTS_DIR } = {}) {
217
+ if (!projectPath || !path.isAbsolute(projectPath)) return [];
218
+ const dir = path.join(projectPath, relative);
219
+ return agentFiles(dir)
220
+ .map((file) => describeAgent(dir, file, { scope: 'project', writable: false, projectPath }))
162
221
  .sort((a, b) => a.name.localeCompare(b.name));
163
222
  }
164
223
 
224
+ /** Every definition on the machine this tool can see: user scope plus each repository. */
225
+ export function listAllAgents({ dir = AGENTS_DIR, projectPaths = [] } = {}) {
226
+ const agents = listAgents({ dir });
227
+ const seen = new Set();
228
+ for (const projectPath of projectPaths) {
229
+ const key = canonicalPath(projectPath || '');
230
+ if (!key || seen.has(key)) continue;
231
+ seen.add(key);
232
+ agents.push(...listProjectAgents(projectPath));
233
+ }
234
+ return agents;
235
+ }
236
+
165
237
  /** Whether the directory itself exists, which is what tells an empty list from a missing one. */
166
238
  export function agentsDirExists({ dir = AGENTS_DIR } = {}) {
167
239
  try {
@@ -205,7 +277,11 @@ function insertionIndex(block) {
205
277
  * that changed, never re-emit the document from a parse of it.
206
278
  */
207
279
  export function rewriteAgentField(text, field, value) {
208
- const lines = text.split('\n');
280
+ // Rejoin with whatever the file already used. A checkout on Windows is CRLF
281
+ // throughout, and splitting on \n then rejoining with it would leave every
282
+ // line this function did not touch ending \r\n and the one it did ending \n.
283
+ const eol = /\r\n/.test(text) ? '\r\n' : '\n';
284
+ const lines = text.split(/\r?\n/);
209
285
  if (lines[0]?.trim() !== '---') {
210
286
  throw new Error('This file has no frontmatter block, so there is nothing to set.');
211
287
  }
@@ -230,7 +306,7 @@ export function rewriteAgentField(text, field, value) {
230
306
  block[at] = `${field}: ${value}`;
231
307
  }
232
308
 
233
- return [lines[0], ...block, ...lines.slice(end)].join('\n');
309
+ return [lines[0], ...block, ...lines.slice(end)].join(eol);
234
310
  }
235
311
 
236
312
  function normaliseAgentValue(field, value) {
@@ -0,0 +1,152 @@
1
+ // The `attribution` block in ~/.claude/settings.json: the trailer Claude Code
2
+ // adds to commits, the line it adds to PR descriptions, and the session URL it
3
+ // appends to cloud/Remote Control commits. The second panel this tool writes to
4
+ // a settings file, alongside the Cost panel in cost.mjs.
5
+ //
6
+ // Unlike Cost's keys, `commit` and `pr` take arbitrary text rather than one of
7
+ // a handful of options, so there is no fixed option list here: a value is either
8
+ // unset (default text), `false` (hidden), or any string (a replacement).
9
+ // `sessionUrl` only ever takes `false` - there is no custom text for it.
10
+
11
+ import { setPath } from './cost.mjs';
12
+ import { writeFileAtomic } from './mutate.mjs';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ import {
17
+ SETTINGS_SEVERITY,
18
+ USER_SETTINGS,
19
+ readJsonDetailed,
20
+ readPath,
21
+ settingsCandidates,
22
+ } from './settings.mjs';
23
+
24
+ export const ATTRIBUTION_KEYS = [
25
+ {
26
+ key: 'commit',
27
+ path: ['attribution', 'commit'],
28
+ label: 'attribution.commit',
29
+ title: 'Commit trailer',
30
+ detail: 'The co-authored-by trailer Claude Code adds to commits it makes. Hide it, or replace it with your own text.',
31
+ kind: 'text-or-hide',
32
+ },
33
+ {
34
+ key: 'pr',
35
+ path: ['attribution', 'pr'],
36
+ label: 'attribution.pr',
37
+ title: 'Pull request line',
38
+ detail: 'The attribution line Claude Code adds to pull request descriptions it writes. Hide it, or replace it with your own text.',
39
+ kind: 'text-or-hide',
40
+ },
41
+ {
42
+ key: 'sessionUrl',
43
+ path: ['attribution', 'sessionUrl'],
44
+ label: 'attribution.sessionUrl',
45
+ title: 'Session link',
46
+ detail: 'The claude.ai session link Claude Code appends to commits made from the cloud or Remote Control. It cannot be replaced, only omitted.',
47
+ kind: 'hide-only',
48
+ },
49
+ ];
50
+
51
+ /**
52
+ * What a value means once it is checked: a string or `false` to write, or null
53
+ * to remove the key. Anything the key does not accept throws rather than being
54
+ * coerced, because a coerced value would be written to the user's settings file.
55
+ */
56
+ export function normaliseAttributionValue(descriptor, value) {
57
+ if (value === null || value === undefined) return null;
58
+
59
+ if (descriptor.kind === 'hide-only') {
60
+ if (value === false) return false;
61
+ throw new Error(`${descriptor.label} only accepts false (to hide it) or unset.`);
62
+ }
63
+
64
+ if (value === false) return false;
65
+ if (typeof value !== 'string') {
66
+ throw new Error(`${descriptor.label} takes a string or false, not ${Array.isArray(value) ? 'a list' : typeof value}.`);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ /**
72
+ * Every layer's value for all three keys, strongest first, in the same shape
73
+ * the Cost report uses so the two render identically.
74
+ */
75
+ export function attributionReport(options = {}) {
76
+ const { userFile = USER_SETTINGS, ...target } = options;
77
+
78
+ const reads = settingsCandidates({ ...target, userFile }).map((candidate) => ({
79
+ ...candidate,
80
+ ...readJsonDetailed(candidate.file),
81
+ }));
82
+ const layers = reads.map(({ scope, file, status, error }) => ({ scope, file, status, error }));
83
+
84
+ const keys = ATTRIBUTION_KEYS.map((descriptor) => {
85
+ const values = reads
86
+ .filter((read) => read.status === 'ok' && readPath(read.data, descriptor.path) !== undefined)
87
+ .map((read) => ({
88
+ scope: read.scope,
89
+ file: read.file,
90
+ value: readPath(read.data, descriptor.path),
91
+ wins: false,
92
+ }));
93
+ if (values.length) values[0].wins = true;
94
+ const winner = values[0] || null;
95
+
96
+ return {
97
+ key: descriptor.key,
98
+ label: descriptor.label,
99
+ title: descriptor.title,
100
+ detail: descriptor.detail,
101
+ kind: descriptor.kind,
102
+ values,
103
+ effective: winner ? { value: winner.value, scope: winner.scope, file: winner.file } : null,
104
+ shadowedByStronger: Boolean(winner && winner.scope !== 'user'),
105
+ };
106
+ });
107
+
108
+ const userRead = reads.find((read) => read.file === userFile);
109
+ const problems = layers
110
+ .filter((layer) => layer.status !== 'ok' && layer.status !== 'absent')
111
+ .map((layer) => ({
112
+ kind: layer.status,
113
+ severity: SETTINGS_SEVERITY[layer.status] || 'warn',
114
+ scope: layer.scope,
115
+ file: layer.file,
116
+ detail: layer.error,
117
+ }));
118
+
119
+ return {
120
+ keys,
121
+ layers,
122
+ problems,
123
+ userFile,
124
+ writable: !userRead || userRead.status === 'ok' || userRead.status === 'absent',
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Write one key to the user settings file, atomically, and answer with the value
130
+ * that landed. A file that exists but does not parse is refused outright.
131
+ */
132
+ export function writeAttributionSetting(key, value, options = {}) {
133
+ const { file = USER_SETTINGS } = options;
134
+
135
+ const descriptor = ATTRIBUTION_KEYS.find((entry) => entry.key === key);
136
+ if (!descriptor) throw new Error(`"${key}" is not a setting this tool writes.`);
137
+
138
+ const next = normaliseAttributionValue(descriptor, value);
139
+
140
+ const read = readJsonDetailed(file);
141
+ if (read.status !== 'ok' && read.status !== 'absent') {
142
+ throw new Error(`Refusing to write ${file}: ${read.error || read.status}. Rewriting a file this tool cannot parse would drop the settings it cannot see, so fix the file by hand first.`);
143
+ }
144
+
145
+ const data = read.status === 'ok' ? read.data : {};
146
+ setPath(data, descriptor.path, next);
147
+
148
+ fs.mkdirSync(path.dirname(file), { recursive: true });
149
+ writeFileAtomic(path.dirname(file), path.basename(file), `${JSON.stringify(data, null, 2)}\n`);
150
+
151
+ return next;
152
+ }