claude-memory-admin 1.10.0 → 1.10.2

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/src/parse.mjs CHANGED
@@ -24,6 +24,36 @@ const WIKILINK = /\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g;
24
24
  /** Leading hook separator: em dash, en dash, hyphen or colon. */
25
25
  const HOOK_SEP = /^[ \t]*(?:—|–|-|:)[ \t]*/;
26
26
 
27
+ /**
28
+ * A line with its carriage return taken off, for matching only.
29
+ *
30
+ * Every classifying regex here ends in `$`, and JavaScript's `.` does not match
31
+ * a carriage return, so on a CRLF file each of them fails on the last character
32
+ * and the file parses as if it held nothing: no index entries, no frontmatter,
33
+ * every memory an orphan. Splitting on /\r?\n/ instead would fix the matching
34
+ * and break the promise that matters more - that a file is rewritten byte for
35
+ * byte - because it would rewrite a CRLF file as LF. So the return stays on the
36
+ * stored line and comes off only for the match.
37
+ */
38
+ export function forMatch(line) {
39
+ return typeof line === 'string' && line.endsWith('\r') ? line.slice(0, -1) : line;
40
+ }
41
+
42
+ /**
43
+ * The carriage return a line added to this file should end with, so a new entry
44
+ * matches the file it joins rather than making it half one thing and half the
45
+ * other. Decided by what most of the file already does.
46
+ */
47
+ export function fileEol(lines) {
48
+ let crlf = 0;
49
+ let lf = 0;
50
+ for (let i = 0; i < lines.length - 1; i++) {
51
+ if (lines[i].endsWith('\r')) crlf += 1;
52
+ else lf += 1;
53
+ }
54
+ return crlf > lf ? '\r' : '';
55
+ }
56
+
27
57
  function unquote(value) {
28
58
  const trimmed = value.trim();
29
59
  if (trimmed.length >= 2) {
@@ -50,7 +80,7 @@ function readYaml(lines) {
50
80
  const withoutComment = raw.trimStart().startsWith('#') ? '' : raw;
51
81
  if (!withoutComment.trim()) continue;
52
82
 
53
- const match = withoutComment.match(/^([ \t]*)([A-Za-z0-9_.\-]+):[ \t]*(.*)$/);
83
+ const match = forMatch(withoutComment).match(/^([ \t]*)([A-Za-z0-9_.\-]+):[ \t]*(.*)$/);
54
84
  if (!match) continue;
55
85
 
56
86
  const [, indent, key, rest] = match;
@@ -103,7 +133,7 @@ export function parseFrontmatter(text) {
103
133
  return {
104
134
  data: readYaml(block),
105
135
  raw: block.join('\n'),
106
- body: lines.slice(end + 1).join('\n').replace(/^\n+/, ''),
136
+ body: lines.slice(end + 1).join('\n').replace(/^(?:\r?\n)+/, ''),
107
137
  hasFrontmatter: true,
108
138
  };
109
139
  }
@@ -140,12 +170,12 @@ export function parseIndex(text) {
140
170
  let section = null;
141
171
 
142
172
  lines.forEach((line, i) => {
143
- const heading = line.match(/^(#{1,6})[ \t]+(.*)$/);
173
+ const heading = forMatch(line).match(/^(#{1,6})[ \t]+(.*)$/);
144
174
  if (heading) {
145
175
  section = heading[2].trim();
146
176
  parsedLines.push({ index: i, kind: 'heading', text: line, level: heading[1].length, section });
147
177
  } else {
148
- const indexMatch = line.match(INDEX_LINE);
178
+ const indexMatch = forMatch(line).match(INDEX_LINE);
149
179
  // Only an unindented bullet is an index entry. An indented one is a
150
180
  // sub-bullet of surrounding prose and must not be treated as a pointer.
151
181
  if (indexMatch && indexMatch[1] === '') {
@@ -255,19 +285,23 @@ export function unwrapWikilink(text, target) {
255
285
  }
256
286
 
257
287
  function splitEntryLine(line) {
258
- const match = line.match(INDEX_LINE);
288
+ const body = forMatch(line);
289
+ const match = body.match(INDEX_LINE);
259
290
  if (!match || match[1] !== '') return null;
260
291
  const file = match[3];
261
292
  const marker = `](${file})`;
262
- const linkEnd = line.indexOf(marker) + marker.length;
263
- const tail = line.slice(linkEnd);
293
+ const linkEnd = body.indexOf(marker) + marker.length;
294
+ const tail = body.slice(linkEnd);
264
295
  const separator = tail.match(HOOK_SEP);
265
296
  return {
266
- head: line.slice(0, linkEnd),
297
+ head: body.slice(0, linkEnd),
267
298
  title: match[2].trim(),
268
299
  file,
269
300
  separator: separator ? separator[0] : null,
270
301
  hook: tail.replace(HOOK_SEP, ''),
302
+ // Carried so a rewritten line ends the way the line it replaces did. A file
303
+ // that comes back half CRLF and half LF is a file this tool corrupted.
304
+ eol: line.endsWith('\r') ? '\r' : '',
271
305
  };
272
306
  }
273
307
 
@@ -317,7 +351,7 @@ export function setIndexHook(text, index, expectedText, hook) {
317
351
 
318
352
  const separator = parts.separator || dominantSeparator(parseIndex(text));
319
353
  const before = lines[index];
320
- lines[index] = trimmed ? `${parts.head}${separator}${trimmed}` : parts.head;
354
+ lines[index] = (trimmed ? `${parts.head}${separator}${trimmed}` : parts.head) + parts.eol;
321
355
  return { text: lines.join('\n'), before, after: lines[index] };
322
356
  }
323
357
 
@@ -396,7 +430,7 @@ export function sectionInsertIndex(parsed, section) {
396
430
  export function insertIndexEntry(text, at, line) {
397
431
  const lines = text.split('\n');
398
432
  const target = Math.max(0, Math.min(Number(at), lines.length));
399
- lines.splice(target, 0, line);
433
+ lines.splice(target, 0, line + fileEol(lines));
400
434
  return { text: lines.join('\n'), index: target };
401
435
  }
402
436
 
package/src/pathcheck.mjs CHANGED
@@ -22,7 +22,12 @@ function candidate(raw) {
22
22
  if (token.includes('://') || token.startsWith('@')) return null;
23
23
  if (GLOB_OR_SHELL.test(token)) return null;
24
24
 
25
+ // A memory written on Windows quotes its paths with backslashes. The project
26
+ // index below is keyed on forward slashes, so a token that is not normalised
27
+ // matches nothing and every Windows-written path is reported as missing.
28
+ token = token.replace(/\\/g, '/');
25
29
  token = token.replace(LINE_SUFFIX, '').replace(/[.,;:]+$/, '').replace(/\/+$/, '');
30
+ if (/^[A-Za-z]:\//.test(token)) return null;
26
31
  if (token.startsWith('./')) token = token.slice(2);
27
32
  if (!token || token.startsWith('/') || token.startsWith('~')) return null;
28
33
  if (token.split('/').includes('..')) return null;
package/src/projects.mjs CHANGED
@@ -14,12 +14,12 @@
14
14
  // and the repository root preferred as the store's identity.
15
15
 
16
16
  import fs from 'node:fs';
17
- import os from 'node:os';
18
17
  import path from 'node:path';
18
+ import { configPath, configSource, fixedProjectDirName } from './config.mjs';
19
19
  import { rememberedPath } from './pathcache.mjs';
20
20
  import { autoMemoryState, expandHome, resolveMemoryDirectory } from './settings.mjs';
21
21
 
22
- export const DEFAULT_ROOT = path.join(os.homedir(), '.claude', 'projects');
22
+ export const DEFAULT_ROOT = configPath('projects');
23
23
 
24
24
  /**
25
25
  * Which store to read, and what decided it. The source travels with the path so
@@ -44,7 +44,24 @@ export function resolveRoot() {
44
44
  invalid: `autoMemoryDirectory "${configured.raw}" is ${configured.invalid}`,
45
45
  };
46
46
  }
47
- return { path: DEFAULT_ROOT, source: 'default', file: null, invalid: null };
47
+ // CLAUDE_CONFIG_DIR moved the whole config directory, so DEFAULT_ROOT is
48
+ // already inside it. Naming that as the source keeps the UI from claiming a
49
+ // default location the user never used.
50
+ const config = configSource();
51
+ if (config.source === 'env') {
52
+ return { path: DEFAULT_ROOT, source: 'config-dir', file: null, invalid: config.invalid };
53
+ }
54
+ return { path: DEFAULT_ROOT, source: 'default', file: null, invalid: config.invalid };
55
+ }
56
+
57
+ /**
58
+ * The one project directory every repository shares when
59
+ * CLAUDE_CODE_PROJECT_DIR_NAME is set, or null when each repository gets a slug
60
+ * of its own. Callers use it to explain why one row stands for several
61
+ * repositories rather than to change how the row is read.
62
+ */
63
+ export function fixedProjectDir() {
64
+ return fixedProjectDirName();
48
65
  }
49
66
 
50
67
  export function projectsRoot() {
@@ -180,12 +197,29 @@ export function storeIdentity(cwds) {
180
197
  * Candidates are verified against the filesystem so a wrong guess is reported
181
198
  * as unresolved rather than presented as fact.
182
199
  */
183
- function decodeSlug(slug) {
200
+ export function slugCandidates(slug, platform = process.platform) {
201
+ // A Windows cwd slugifies with its drive in front ("C--Users-me-repo"), so the
202
+ // POSIX candidates below decode it to a path with no drive and no meaning.
203
+ if (platform === 'win32') {
204
+ const drive = /^([A-Za-z])--(.*)$/.exec(slug);
205
+ if (drive) {
206
+ const [, letter, rest] = drive;
207
+ return [
208
+ `${letter}:\\` + rest.replace(/--/g, '\\.').replace(/-/g, '\\'),
209
+ `${letter}:\\` + rest.replace(/-/g, '\\'),
210
+ ];
211
+ }
212
+ }
213
+
184
214
  const body = slug.replace(/^-/, '');
185
- const candidates = [
215
+ return [
186
216
  '/' + body.replace(/--/g, '/.').replace(/-/g, '/'),
187
217
  '/' + body.replace(/-/g, '/'),
188
218
  ];
219
+ }
220
+
221
+ function decodeSlug(slug) {
222
+ const candidates = slugCandidates(slug);
189
223
  for (const candidate of candidates) {
190
224
  try {
191
225
  if (fs.existsSync(candidate)) return { path: candidate, verified: true };
@@ -223,7 +257,10 @@ export function resolveProjectPath(dir, slug) {
223
257
 
224
258
  /** A short label for the sidebar: the last two path segments. */
225
259
  export function shortLabel(fullPath) {
226
- const parts = fullPath.split('/').filter(Boolean);
260
+ // Split on both separators: a store recorded on Windows is still browsed on
261
+ // whatever machine is reading it, and a backslash path would otherwise come
262
+ // out as one unsplittable segment.
263
+ const parts = String(fullPath).split(/[\\/]+/).filter(Boolean);
227
264
  return parts.slice(-2).join('/') || fullPath;
228
265
  }
229
266
 
package/src/settings.mjs CHANGED
@@ -11,10 +11,15 @@
11
11
  // -> .claude/settings.json -> ~/.claude/settings.json
12
12
 
13
13
  import fs from 'node:fs';
14
- import os from 'node:os';
15
14
  import path from 'node:path';
16
15
 
17
- export const USER_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
16
+ import { configPath, configSource, expandHome, fixedProjectDirName, isAbsolutePath } from './config.mjs';
17
+
18
+ export const USER_SETTINGS = configPath('settings.json');
19
+
20
+ // Re-exported because this is where every caller has always imported it from,
21
+ // and because settings values are the main thing that arrives `~/`-prefixed.
22
+ export { expandHome };
18
23
 
19
24
  /** Default when nothing sets cleanupPeriodDays; transcripts older than this are swept. */
20
25
  export const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
@@ -24,6 +29,7 @@ export const SETTINGS_SEVERITY = {
24
29
  unreadable: 'bad',
25
30
  'not-object': 'bad',
26
31
  'invalid-auto-memory-directory': 'bad',
32
+ 'invalid-config-dir': 'bad',
27
33
  };
28
34
 
29
35
  export function summariseSettings(problems) {
@@ -149,11 +155,6 @@ export function lookupPath(layers, keyPath) {
149
155
  return null;
150
156
  }
151
157
 
152
- export function expandHome(value) {
153
- if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
154
- return value;
155
- }
156
-
157
158
  /**
158
159
  * Where the auto memory store lives, per settings.
159
160
  *
@@ -171,12 +172,55 @@ export function resolveMemoryDirectory(options = {}) {
171
172
 
172
173
  const raw = typeof found.value === 'string' ? found.value.trim() : '';
173
174
  if (!raw) return null;
174
- if (!raw.startsWith('/') && !raw.startsWith('~/') && !/^[A-Za-z]:[\\/]/.test(raw)) {
175
+ // Accepted on every platform, not only the one this is running on: a settings
176
+ // file is routinely shared between machines, and a Windows path read on macOS
177
+ // is a path this tool cannot open but must still report as intentional.
178
+ const rooted = raw.startsWith('~/') || raw.startsWith('~\\')
179
+ || isAbsolutePath(raw, 'win32') || isAbsolutePath(raw, 'linux');
180
+ if (!rooted) {
175
181
  return { ...found, raw, path: null, invalid: 'not an absolute or ~/ path' };
176
182
  }
177
183
  return { ...found, raw, path: expandHome(raw), invalid: null };
178
184
  }
179
185
 
186
+ export const DISABLE_AUTO_MEMORY = 'CLAUDE_CODE_DISABLE_AUTO_MEMORY';
187
+
188
+ /** The env-var convention Claude Code uses: set, and not "0" or "false". */
189
+ function truthyEnv(value) {
190
+ if (value === null || value === undefined) return false;
191
+ const text = String(value).trim();
192
+ return Boolean(text) && text !== '0' && text !== 'false';
193
+ }
194
+
195
+ /**
196
+ * CLAUDE_CODE_DISABLE_AUTO_MEMORY, from either place it can be set.
197
+ *
198
+ * The process environment is the obvious one, but settings files carry an `env`
199
+ * block whose entries a session exports before it starts, so the same switch is
200
+ * equally settable in any of the five layers. Reading only process.env reported
201
+ * auto memory as on for anyone who had turned it off in a file - the reverse of
202
+ * what this tool is for.
203
+ */
204
+ export function disableAutoMemoryEnv({ layers = null, projectDir = null, env = process.env } = {}) {
205
+ const raw = env[DISABLE_AUTO_MEMORY];
206
+ if (truthyEnv(raw)) {
207
+ return { disabling: true, value: raw, scope: 'env', file: null };
208
+ }
209
+
210
+ const found = lookupPath(layers || settingsLayers({ projectDir }), ['env', DISABLE_AUTO_MEMORY]);
211
+ if (found && truthyEnv(found.value)) {
212
+ return { disabling: true, value: found.value, scope: found.scope, file: found.file };
213
+ }
214
+
215
+ const value = raw ?? (found ? found.value : null);
216
+ return {
217
+ disabling: false,
218
+ value: value === undefined ? null : value,
219
+ scope: raw !== undefined && raw !== null ? 'env' : found?.scope ?? null,
220
+ file: raw !== undefined && raw !== null ? null : found?.file ?? null,
221
+ };
222
+ }
223
+
180
224
  /**
181
225
  * Whether auto memory is on for a project, and what decided it.
182
226
  *
@@ -186,12 +230,14 @@ export function resolveMemoryDirectory(options = {}) {
186
230
  * local layer to read, so the answer is unknown rather than assumed.
187
231
  */
188
232
  export function autoMemoryState({ projectDir = null } = {}) {
189
- const env = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY;
190
- if (env && env !== '0' && env !== 'false') {
191
- return { enabled: false, setBy: 'CLAUDE_CODE_DISABLE_AUTO_MEMORY', scope: 'env', known: true };
233
+ const layers = settingsLayers({ projectDir });
234
+
235
+ const disabled = disableAutoMemoryEnv({ layers });
236
+ if (disabled.disabling) {
237
+ return { enabled: false, setBy: disabled.file || DISABLE_AUTO_MEMORY, scope: disabled.scope, known: true };
192
238
  }
193
239
 
194
- const found = lookup(settingsLayers({ projectDir }), 'autoMemoryEnabled');
240
+ const found = lookup(layers, 'autoMemoryEnabled');
195
241
  if (found && typeof found.value === 'boolean') {
196
242
  return { enabled: found.value, setBy: found.file, scope: found.scope, known: true };
197
243
  }
@@ -263,8 +309,8 @@ export function settingsReport(options = {}) {
263
309
  const cleanup = keys.find((entry) => entry.key === 'cleanupPeriodDays');
264
310
  cleanup.normalized = cleanupPeriodDays(options);
265
311
 
266
- const raw = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY;
267
- const disabling = Boolean(raw && raw !== '0' && raw !== 'false');
312
+ const disable = disableAutoMemoryEnv({ layers: reads.filter((read) => read.status === 'ok') });
313
+ const config = configSource();
268
314
 
269
315
  const problems = layers
270
316
  .filter((layer) => layer.status !== 'ok' && layer.status !== 'absent')
@@ -276,6 +322,16 @@ export function settingsReport(options = {}) {
276
322
  detail: layer.error,
277
323
  }));
278
324
 
325
+ if (config.invalid) {
326
+ problems.push({
327
+ kind: 'invalid-config-dir',
328
+ severity: SETTINGS_SEVERITY['invalid-config-dir'],
329
+ scope: 'env',
330
+ file: null,
331
+ detail: config.invalid,
332
+ });
333
+ }
334
+
279
335
  const directory = resolveMemoryDirectory(options);
280
336
  if (directory && directory.invalid) {
281
337
  problems.push({
@@ -292,9 +348,19 @@ export function settingsReport(options = {}) {
292
348
  layers,
293
349
  keys,
294
350
  env: {
295
- name: 'CLAUDE_CODE_DISABLE_AUTO_MEMORY',
296
- value: raw ?? null,
297
- overrides: disabling ? 'autoMemoryEnabled' : null,
351
+ name: DISABLE_AUTO_MEMORY,
352
+ value: disable.value,
353
+ scope: disable.scope,
354
+ file: disable.file,
355
+ overrides: disable.disabling ? 'autoMemoryEnabled' : null,
356
+ },
357
+ configDir: {
358
+ name: 'CLAUDE_CONFIG_DIR',
359
+ path: config.path,
360
+ source: config.source,
361
+ value: config.raw,
362
+ invalid: config.invalid,
363
+ fixedProjectDirName: fixedProjectDirName(),
298
364
  },
299
365
  problems,
300
366
  };
package/src/stores.mjs CHANGED
@@ -15,11 +15,13 @@
15
15
  // somewhere else first.
16
16
 
17
17
  import fs from 'node:fs';
18
- import os from 'node:os';
19
18
  import path from 'node:path';
19
+ import { listAllAgents, MEMORY_SCOPES } from './agents.mjs';
20
+ import { canonicalPath, configDir, configPath, DEFAULT_CONFIG_DIR_NAME } from './config.mjs';
20
21
  import { listProjects, memoryDir, projectsRoot, shortLabel } from './projects.mjs';
22
+ import { autoMemoryState } from './settings.mjs';
21
23
 
22
- export const AGENT_USER_DIR = path.join(os.homedir(), '.claude', 'agent-memory');
24
+ export const AGENT_USER_DIR = configPath('agent-memory');
23
25
  export const AGENT_PROJECT_DIR = path.join('.claude', 'agent-memory');
24
26
  export const AGENT_LOCAL_DIR = path.join('.claude', 'agent-memory-local');
25
27
 
@@ -96,10 +98,15 @@ export function listAgentStores({ userDir = AGENT_USER_DIR, projectPaths = [] }
96
98
 
97
99
  // A repository can be reached through more than one project entry (a worktree
98
100
  // and its root), so the same directory must not be listed twice.
101
+ // Keyed on the canonical form: Windows and a default macOS volume are
102
+ // case-insensitive, so two spellings of one repository are one repository and
103
+ // would otherwise contribute the same agent store twice.
99
104
  const seen = new Set();
100
105
  for (const projectPath of projectPaths) {
101
- if (!projectPath || !path.isAbsolute(projectPath) || seen.has(projectPath)) continue;
102
- seen.add(projectPath);
106
+ if (!projectPath || !path.isAbsolute(projectPath)) continue;
107
+ const key = canonicalPath(projectPath);
108
+ if (seen.has(key)) continue;
109
+ seen.add(key);
103
110
  for (const [kind, relative] of [['agent-project', AGENT_PROJECT_DIR], ['agent-local', AGENT_LOCAL_DIR]]) {
104
111
  const parent = path.join(projectPath, relative);
105
112
  for (const name of agentDirs(parent)) {
@@ -113,6 +120,60 @@ export function listAgentStores({ userDir = AGENT_USER_DIR, projectPaths = [] }
113
120
  || a.sublabel.localeCompare(b.sublabel));
114
121
  }
115
122
 
123
+ /**
124
+ * Join each subagent store to the definition that asks for it.
125
+ *
126
+ * A store directory on its own says nothing about whether a session still loads
127
+ * it: the `memory:` field in an agent file is what creates one, and that field
128
+ * can be changed to another scope or removed entirely without the directory it
129
+ * created ever going away. So the store is annotated with what the definitions
130
+ * actually say, and the cleanup checks in src/checks.mjs read those annotations
131
+ * rather than re-deriving them.
132
+ *
133
+ * The one rule that outranks all of it: subagent memory is part of auto memory,
134
+ * so when auto memory is off the `memory:` field has no effect at all - the
135
+ * agent launches with no memory instructions and no file tools, and every store
136
+ * on the machine is frozen where it stands.
137
+ */
138
+ export function linkAgentStores(stores, agents, { autoMemory = null } = {}) {
139
+ const byName = new Map();
140
+ for (const agent of agents || []) {
141
+ const key = agent.name.toLowerCase();
142
+ if (!byName.has(key)) byName.set(key, []);
143
+ byName.get(key).push(agent);
144
+ }
145
+
146
+ return stores.map((store) => {
147
+ if (!store.kind.startsWith('agent-')) return store;
148
+
149
+ const candidates = (byName.get(store.agentName.toLowerCase()) || []).filter((agent) => {
150
+ // A project-scope definition only speaks for stores in its own repository.
151
+ if (agent.scope !== 'project' || !store.projectPath) return true;
152
+ return canonicalPath(agent.projectPath) === canonicalPath(store.projectPath);
153
+ });
154
+
155
+ const declaring = candidates.find((agent) => MEMORY_SCOPES[agent.memory] === store.kind) || null;
156
+ const other = declaring ? null : candidates.find((agent) => agent.memory) || null;
157
+ const inert = autoMemory ? autoMemory.enabled === false : false;
158
+
159
+ return {
160
+ ...store,
161
+ // Marks that the join actually ran. A store that was never linked knows
162
+ // nothing about its definitions, and "nothing is declared" and "nobody
163
+ // asked" have to stay distinguishable or the checks below report an
164
+ // orphan every time a caller builds a store on its own.
165
+ linkage: true,
166
+ declaredBy: declaring ? declaring.file : other ? other.file : null,
167
+ declaredScope: declaring ? declaring.memory : other ? other.memory : null,
168
+ declaringScope: declaring ? declaring.scope : other ? other.scope : null,
169
+ defined: candidates.length > 0,
170
+ linked: Boolean(declaring),
171
+ inert,
172
+ inertBy: inert ? autoMemory.setBy : null,
173
+ };
174
+ });
175
+ }
176
+
116
177
  /** An auto-memory project, in the shape the rest of the app expects of a store. */
117
178
  function autoStore(project, root) {
118
179
  return {
@@ -133,8 +194,7 @@ function autoStore(project, root) {
133
194
  * `hasMemoryDir` false is what keeps it out of full-text search, which skips
134
195
  * stores without one, and out of everything else that reads memory files.
135
196
  */
136
- function globalStore(home) {
137
- const dir = path.join(home, '.claude');
197
+ function globalStore(dir) {
138
198
  return {
139
199
  id: storeId('global', dir),
140
200
  kind: 'global',
@@ -154,19 +214,26 @@ function globalStore(home) {
154
214
  * found under the project paths auto memory already resolved, so a repository this
155
215
  * tool has never seen a session for contributes nothing.
156
216
  *
157
- * `home` is a parameter because the global entry is about the real home directory
158
- * rather than the memory root, which --root can point somewhere else entirely.
217
+ * The config directory is a parameter because the global entry is about where
218
+ * Claude Code keeps its own files rather than about the memory root, which
219
+ * --root can point somewhere else entirely. `home` is the older spelling of the
220
+ * same idea and still accepted, since a home directory names exactly one config
221
+ * directory when CLAUDE_CONFIG_DIR is not set.
159
222
  */
160
- export function listStores(root = projectsRoot(), { home = os.homedir() } = {}) {
223
+ export function listStores(root = projectsRoot(), { home = null, dir = null, agents = null } = {}) {
224
+ const global = dir || (home ? path.join(home, DEFAULT_CONFIG_DIR_NAME) : configDir());
161
225
  const projects = listProjects(root);
162
226
  const projectPaths = projects
163
227
  .filter((project) => project.pathExists)
164
228
  .flatMap((project) => [project.path, ...(project.workingDirs || [])]);
165
229
 
230
+ const agentStores = listAgentStores({ projectPaths });
231
+ const definitions = agents || listAllAgents({ projectPaths });
232
+
166
233
  return [
167
- globalStore(home),
234
+ globalStore(global),
168
235
  ...projects.map((project) => autoStore(project, root)),
169
- ...listAgentStores({ projectPaths }),
236
+ ...linkAgentStores(agentStores, definitions, { autoMemory: autoMemoryState() }),
170
237
  ];
171
238
  }
172
239
 
package/src/toolrun.mjs CHANGED
@@ -9,20 +9,25 @@ export const num = (value) => (typeof value === 'number' && Number.isFinite(valu
9
9
  export const text = (value) => (typeof value === 'string' ? value : '');
10
10
  export const pct = (part, whole) => (whole > 0 ? (part / whole) * 100 : 0);
11
11
 
12
- function candidateNames(binary, env) {
13
- if (process.platform !== 'win32') return [binary];
14
- return text(env.PATHEXT || '.EXE').split(';').filter(Boolean).map((ext) => binary + ext.toLowerCase());
12
+ function candidateNames(binary, env, platform) {
13
+ if (platform !== 'win32') return [binary];
14
+ return text(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map((ext) => binary + ext.toLowerCase());
15
15
  }
16
16
 
17
- export function findBinary(binary, env = process.env) {
17
+ export function findBinary(binary, env = process.env, platform = process.platform) {
18
+ // PATH is split on this process's delimiter, not the target platform's, since
19
+ // the PATH being searched is the one this process actually has.
18
20
  const dirs = text(env.PATH).split(path.delimiter).filter(Boolean);
19
- const names = candidateNames(binary, env);
21
+ const names = candidateNames(binary, env, platform);
20
22
  for (const dir of dirs) {
21
23
  for (const name of names) {
22
24
  const candidate = path.join(dir, name);
23
25
  try {
24
26
  if (!fs.statSync(candidate).isFile()) continue;
25
- fs.accessSync(candidate, fs.constants.X_OK);
27
+ // Windows has no execute bit, and Node answers X_OK there from the
28
+ // file's existence alone. Asking for it is meaningless rather than
29
+ // harmless: it is the check that would reject a perfectly good .cmd.
30
+ if (platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK);
26
31
  return { found: true, path: candidate };
27
32
  } catch {
28
33
  continue;
@@ -32,10 +37,45 @@ export function findBinary(binary, env = process.env) {
32
37
  return { found: false, path: null };
33
38
  }
34
39
 
35
- export function runBinary(binary, args, { cwd, timeout = TIMEOUT_MS } = {}) {
40
+ /**
41
+ * How to actually start a program on Windows.
42
+ *
43
+ * npm installs `rtk` and `ccusage` as `rtk.cmd`, and a batch file is not a
44
+ * program: execFile refuses to run one outright, and would not find it by bare
45
+ * name in the first place, since only the shell applies PATHEXT. So the resolved
46
+ * file is handed to cmd.exe the way Node's own shell support does - `/d /s /c`
47
+ * with one pre-quoted command string and verbatim arguments - and the arguments
48
+ * are quoted here rather than trusted to survive a second round of parsing.
49
+ */
50
+ export function windowsInvocation(binary, args, env = process.env) {
51
+ const found = findBinary(binary, env, 'win32');
52
+ const target = found.found ? found.path : binary;
53
+ if (!/\.(cmd|bat)$/i.test(target)) return { file: target, args, options: {} };
54
+
55
+ const quote = (value) => `"${String(value).replace(/"/g, '""')}"`;
56
+ const command = [target, ...args].map(quote).join(' ');
57
+ return {
58
+ file: env.COMSPEC || 'cmd.exe',
59
+ args: ['/d', '/s', '/c', command],
60
+ options: { windowsVerbatimArguments: true },
61
+ };
62
+ }
63
+
64
+ export function runBinary(binary, args, { cwd, timeout = TIMEOUT_MS, env = process.env } = {}) {
36
65
  return new Promise((resolve, reject) => {
37
- const options = { cwd, timeout, maxBuffer: MAX_BUFFER, windowsHide: true, encoding: 'utf8' };
38
- execFile(binary, args, options, (err, stdout, stderr) => {
66
+ const invocation = process.platform === 'win32'
67
+ ? windowsInvocation(binary, args, env)
68
+ : { file: binary, args, options: {} };
69
+
70
+ const options = {
71
+ cwd,
72
+ timeout,
73
+ maxBuffer: MAX_BUFFER,
74
+ windowsHide: true,
75
+ encoding: 'utf8',
76
+ ...invocation.options,
77
+ };
78
+ execFile(invocation.file, invocation.args, options, (err, stdout, stderr) => {
39
79
  if (!err) return resolve(stdout);
40
80
  if (err.code === 'ENOENT') return reject(new Error(`${binary} is not on this process PATH`));
41
81
  if (err.killed) return reject(new Error(`${binary} ${args[0]} was still running after ${timeout}ms and was stopped`));