claude-memory-admin 1.10.1 → 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/config.mjs ADDED
@@ -0,0 +1,130 @@
1
+ // Where Claude Code keeps its files, and the path primitives that answer it the
2
+ // same way on all three platforms.
3
+ //
4
+ // Everything under ~/.claude - the projects root, the agent definitions, the
5
+ // agent memory directories, the user settings file, the output styles - moves as
6
+ // one when CLAUDE_CONFIG_DIR is set. Hardcoding the home-relative path in each
7
+ // module meant a machine with a custom config dir opened this tool to an empty
8
+ // list rather than to its memory, so all of them resolve through configDir()
9
+ // instead.
10
+ //
11
+ // The platform is a parameter on every function here rather than a read of
12
+ // process.platform inside it. Only one of the three platforms is ever available
13
+ // to develop on, and behaviour that cannot be exercised from a test is behaviour
14
+ // nobody checks: the defaults keep the call sites unchanged and the parameter is
15
+ // what lets test/config.test.mjs assert the Windows rules from anywhere.
16
+
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ /** The separator rules of the named platform, rather than of the host. */
21
+ const pathFor = (platform) => (platform === 'win32' ? path.win32 : path.posix);
22
+
23
+ /**
24
+ * Expand a leading `~/`. Claude Code documents that form for autoMemoryDirectory
25
+ * and it is what people type on every platform, Windows included, so it is
26
+ * handled before any separator rule is applied.
27
+ *
28
+ * The join goes through pathFor rather than through path directly. A caller that
29
+ * names a platform is naming its separator too, and the native join would answer
30
+ * from the host instead: a POSIX home joined under win32 comes back rooted at no
31
+ * drive, which isAbsolutePath then rejects for a reason the caller never asked
32
+ * for.
33
+ */
34
+ export function expandHome(value, { home = os.homedir(), platform = process.platform } = {}) {
35
+ if (typeof value !== 'string') return value;
36
+ if (value === '~') return home;
37
+ if (value.startsWith('~/') || value.startsWith('~\\')) return pathFor(platform).join(home, value.slice(2));
38
+ return value;
39
+ }
40
+
41
+ /**
42
+ * Whether a path is absolute in the sense Claude Code requires: rooted, and on
43
+ * Windows rooted at a drive or a UNC share.
44
+ *
45
+ * path.isAbsolute is not enough on its own. Under win32 it answers true for
46
+ * "/foo", which has no drive and so names nothing that can be opened. Accepting
47
+ * it would turn a typo into a silent read of the wrong place, which is the one
48
+ * failure every caller here exists to prevent.
49
+ */
50
+ export function isAbsolutePath(value, platform = process.platform) {
51
+ if (typeof value !== 'string' || !value) return false;
52
+ if (platform === 'win32') {
53
+ return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]/.test(value) || /^\/\/[^/]/.test(value);
54
+ }
55
+ return value.startsWith('/');
56
+ }
57
+
58
+ /** A path with forward slashes, which is the only separator glob syntax knows. */
59
+ export function toPosix(value) {
60
+ return typeof value === 'string' ? value.replace(/\\/g, '/') : value;
61
+ }
62
+
63
+ /**
64
+ * A path in the form to compare two paths by, never the form to display or store.
65
+ *
66
+ * Windows and a default macOS volume are both case-insensitive, so two spellings
67
+ * of one directory are one directory, and a dedup set keyed on the raw string
68
+ * misses the duplicate. Lowercasing is only sound for the comparison: the store
69
+ * ids in src/stores.mjs are derived from the path as found on disk and have to
70
+ * stay that way, or every id on the machine changes.
71
+ */
72
+ export function canonicalPath(value, platform = process.platform) {
73
+ if (typeof value !== 'string' || !value) return value;
74
+ const resolved = toPosix(path.resolve(value));
75
+ return platform === 'win32' || platform === 'darwin' ? resolved.toLowerCase() : resolved;
76
+ }
77
+
78
+ export const DEFAULT_CONFIG_DIR_NAME = '.claude';
79
+
80
+ /**
81
+ * The config directory and what decided it, in the shape src/projects.mjs
82
+ * already uses for the memory root: a tool that silently reads somewhere other
83
+ * than the default is worse than one that reads nothing, so the source travels
84
+ * with the path.
85
+ *
86
+ * A CLAUDE_CONFIG_DIR that is not absolute is reported and not used. Claude Code
87
+ * would not accept it either, so falling back to the default is the honest
88
+ * answer - but silently is not, hence `invalid`.
89
+ */
90
+ export function configSource({ env = process.env, home = os.homedir(), platform = process.platform } = {}) {
91
+ const fallback = pathFor(platform).join(home, DEFAULT_CONFIG_DIR_NAME);
92
+ const raw = typeof env.CLAUDE_CONFIG_DIR === 'string' ? env.CLAUDE_CONFIG_DIR.trim() : '';
93
+ if (!raw) return { path: fallback, source: 'default', raw: null, invalid: null };
94
+
95
+ const expanded = expandHome(raw, { home, platform });
96
+ if (!isAbsolutePath(expanded, platform)) {
97
+ return {
98
+ path: fallback,
99
+ source: 'default',
100
+ raw,
101
+ invalid: `CLAUDE_CONFIG_DIR "${raw}" is not an absolute or ~/ path`,
102
+ };
103
+ }
104
+ return { path: expanded, source: 'env', raw, invalid: null };
105
+ }
106
+
107
+ export function configDir(options = {}) {
108
+ return configSource(options).path;
109
+ }
110
+
111
+ /** A path inside the config directory, such as configPath('agents'). */
112
+ export function configPath(...segments) {
113
+ return path.join(configDir(), ...segments);
114
+ }
115
+
116
+ /**
117
+ * CLAUDE_CODE_PROJECT_DIR_NAME, which fixes the <project> directory name so that
118
+ * every repository launched with this config dir shares one store instead of
119
+ * getting a slug of its own.
120
+ *
121
+ * Claude Code documents it as being set beside CLAUDE_CONFIG_DIR, and it is only
122
+ * honoured here on that condition: on its own it would silently redirect a
123
+ * default installation to a directory Claude Code is not using.
124
+ */
125
+ export function fixedProjectDirName({ env = process.env } = {}) {
126
+ if (typeof env.CLAUDE_CONFIG_DIR !== 'string' || !env.CLAUDE_CONFIG_DIR.trim()) return null;
127
+ const name = typeof env.CLAUDE_CODE_PROJECT_DIR_NAME === 'string' ? env.CLAUDE_CODE_PROJECT_DIR_NAME.trim() : '';
128
+ if (!name || name !== path.basename(name) || name.startsWith('.')) return null;
129
+ return name;
130
+ }
package/src/cost.mjs CHANGED
@@ -18,9 +18,9 @@
18
18
  // prompt and are untouched by it.
19
19
 
20
20
  import fs from 'node:fs';
21
- import os from 'node:os';
22
21
  import path from 'node:path';
23
22
 
23
+ import { configPath } from './config.mjs';
24
24
  import { parseFrontmatter } from './parse.mjs';
25
25
  import { writeFileAtomic } from './mutate.mjs';
26
26
  import {
@@ -31,7 +31,7 @@ import {
31
31
  settingsCandidates,
32
32
  } from './settings.mjs';
33
33
 
34
- export const OUTPUT_STYLES_DIR = path.join(os.homedir(), '.claude', 'output-styles');
34
+ export const OUTPUT_STYLES_DIR = configPath('output-styles');
35
35
 
36
36
  // A full model name is documented as acceptable wherever an alias is, and this
37
37
  // tool has no business deciding which ones exist. The shape is checked so a
@@ -13,8 +13,8 @@
13
13
  // would hide exactly the problem this is meant to surface.
14
14
 
15
15
  import fs from 'node:fs';
16
- import os from 'node:os';
17
16
  import path from 'node:path';
17
+ import { canonicalPath, configDir, DEFAULT_CONFIG_DIR_NAME, expandHome, toPosix } from './config.mjs';
18
18
  import { parseFrontmatter } from './parse.mjs';
19
19
  import { estimateTokens } from './stats.mjs';
20
20
  import { lookup, settingsLayers } from './settings.mjs';
@@ -133,7 +133,7 @@ export function findImports(text) {
133
133
  }
134
134
 
135
135
  function resolveImport(spec, fromFile) {
136
- if (spec.startsWith('~/')) return path.join(os.homedir(), spec.slice(2));
136
+ if (spec.startsWith('~/') || spec.startsWith('~\\')) return expandHome(spec);
137
137
  if (path.isAbsolute(spec)) return spec;
138
138
  // Relative to the file holding the import, not to the working directory.
139
139
  return path.resolve(path.dirname(fromFile), spec);
@@ -149,7 +149,10 @@ function resolveImport(spec, fromFile) {
149
149
  export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
150
150
  const files = [];
151
151
  const problems = [];
152
- const seen = new Set([rootFile]);
152
+ // Canonical keys: on a case-insensitive filesystem two spellings of one file
153
+ // are one file, and a cycle spelled differently at each hop would otherwise
154
+ // recurse until it hit the depth limit instead of being named as a cycle.
155
+ const seen = new Set([canonicalPath(rootFile)]);
153
156
  let frontier = [{ file: rootFile, text: rootText, depth: 0 }];
154
157
 
155
158
  while (frontier.length) {
@@ -162,7 +165,7 @@ export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
162
165
  problems.push({ kind: 'too-deep', spec, from: current.file, file: resolved });
163
166
  continue;
164
167
  }
165
- if (seen.has(resolved)) {
168
+ if (seen.has(canonicalPath(resolved))) {
166
169
  problems.push({ kind: 'cycle', spec, from: current.file, file: resolved });
167
170
  continue;
168
171
  }
@@ -173,7 +176,7 @@ export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
173
176
  continue;
174
177
  }
175
178
 
176
- seen.add(resolved);
179
+ seen.add(canonicalPath(resolved));
177
180
  // An import resolving outside the project is the kind Claude Code asks
178
181
  // you to approve once; a declined one then stays silently disabled.
179
182
  const external = projectDir ? path.relative(projectDir, resolved).startsWith('..') : false;
@@ -342,17 +345,34 @@ function entry(file, scope, kind, { conditional = false } = {}) {
342
345
  }
343
346
 
344
347
  /** Directories from the filesystem root down to `dir`, in load order. */
348
+ /**
349
+ * Every directory from the filesystem root down to `dir`, root last.
350
+ *
351
+ * Walked with dirname rather than by splitting on the separator, because the
352
+ * separator alone does not describe a root: a Windows path starts at `C:\` and
353
+ * a UNC path at `\\server\share`, and splitting either of those produces
354
+ * candidates that name nothing.
355
+ */
345
356
  function ancestors(dir) {
346
- const parts = path.resolve(dir).split(path.sep);
347
357
  const out = [];
348
- for (let i = 1; i < parts.length; i++) {
349
- out.push(parts.slice(0, i + 1).join(path.sep) || path.sep);
358
+ let current = path.resolve(dir);
359
+ while (true) {
360
+ out.push(current);
361
+ const parent = path.dirname(current);
362
+ if (parent === current) break;
363
+ current = parent;
350
364
  }
351
- return out;
365
+ return out.reverse();
352
366
  }
353
367
 
354
- function globToRegExp(pattern) {
355
- // Only used for claudeMdExcludes, which matches absolute paths.
368
+ export function globToRegExp(pattern, platform = process.platform) {
369
+ // Only used for claudeMdExcludes, which matches absolute paths. Glob syntax
370
+ // knows one separator, so a Windows path has to be compared in its forward
371
+ // slash form or every pattern silently matches nothing - and on the two
372
+ // case-insensitive platforms the comparison has to ignore case for the same
373
+ // reason a path does.
374
+ const flags = platform === 'win32' || platform === 'darwin' ? 'i' : '';
375
+ pattern = toPosix(pattern);
356
376
  let out = '';
357
377
  for (let i = 0; i < pattern.length; i++) {
358
378
  const char = pattern[i];
@@ -362,16 +382,26 @@ function globToRegExp(pattern) {
362
382
  } else if (char === '?') out += '[^/]';
363
383
  else out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
364
384
  }
365
- return new RegExp(`^${out}$`);
385
+ return new RegExp(`^${out}$`, flags);
366
386
  }
367
387
 
368
388
  /**
369
389
  * Managed policy and user scope: what loads whichever project a session starts in.
370
390
  *
371
- * `home` is a parameter rather than a call to os.homedir() so this is a total
391
+ * `home` is a parameter rather than a read of the config directory so this is a total
372
392
  * function of where it is told to look, which is what makes it testable without a
373
393
  * real home directory.
374
394
  */
395
+ /**
396
+ * The user-scope directory: normally the config directory, which
397
+ * CLAUDE_CONFIG_DIR may have moved off the home directory entirely. A `home`
398
+ * passed in names its own, which is what lets the tests point the user scope at
399
+ * a fixture.
400
+ */
401
+ function userDir(home) {
402
+ return home ? path.join(home, DEFAULT_CONFIG_DIR_NAME) : configDir();
403
+ }
404
+
375
405
  function userCandidates(home, layers) {
376
406
  const candidates = [entry(managedClaudeMd(), 'managed', 'claude-md')];
377
407
 
@@ -382,8 +412,9 @@ function userCandidates(home, layers) {
382
412
  : null;
383
413
  if (inline) candidates.push(inline);
384
414
 
385
- candidates.push(entry(path.join(home, '.claude', 'CLAUDE.md'), 'user', 'claude-md'));
386
- for (const file of listRuleFiles(path.join(home, '.claude', 'rules'))) {
415
+ const dir = userDir(home);
416
+ candidates.push(entry(path.join(dir, 'CLAUDE.md'), 'user', 'claude-md'));
417
+ for (const file of listRuleFiles(path.join(dir, 'rules'))) {
387
418
  candidates.push(ruleEntry(file, 'user'));
388
419
  }
389
420
  return candidates;
@@ -416,14 +447,14 @@ function readExcludes(layers) {
416
447
  * one cannot drift apart in what they count or what they consider broken.
417
448
  */
418
449
  function finalize(candidates, { projectDir, excludes }) {
419
- const matchers = excludes.map(globToRegExp);
450
+ const matchers = excludes.map((pattern) => globToRegExp(pattern));
420
451
  const excluded = [];
421
452
  const loaded = [];
422
453
  const problems = [];
423
454
 
424
455
  for (const candidate of candidates.filter(Boolean)) {
425
456
  // Managed policy cannot be excluded; that is the point of managed policy.
426
- if (candidate.scope !== 'managed' && matchers.some((re) => re.test(candidate.file))) {
457
+ if (candidate.scope !== 'managed' && matchers.some((re) => re.test(toPosix(candidate.file)))) {
427
458
  excluded.push(candidate.file);
428
459
  continue;
429
460
  }
@@ -498,7 +529,7 @@ function finalize(candidates, { projectDir, excludes }) {
498
529
  * and walking into them would bury the one finding that matters.
499
530
  */
500
531
  export function unreferencedUserFiles(home, loadedFiles) {
501
- const dir = path.join(home, '.claude');
532
+ const dir = userDir(home);
502
533
  // Imports arrive already absolutised by resolveImport while candidates are
503
534
  // joined from `home`, so both sides are resolved before they are compared.
504
535
  const loaded = new Set(loadedFiles.map((item) => path.resolve(item.file)));
@@ -526,7 +557,7 @@ export function unreferencedUserFiles(home, loadedFiles) {
526
557
  export function resolveInstructions(projectDir) {
527
558
  const layers = settingsLayers({ projectDir });
528
559
  const candidates = [
529
- ...userCandidates(os.homedir(), layers),
560
+ ...userCandidates(null, layers),
530
561
  ...projectCandidates(projectDir),
531
562
  ];
532
563
  const resolved = finalize(candidates, { projectDir, excludes: readExcludes(layers) });
@@ -552,7 +583,7 @@ export function resolveInstructions(projectDir) {
552
583
  * With no project there is no boundary for an import to resolve outside of, so
553
584
  * nothing is marked external here - that check belongs to the whole-session view.
554
585
  */
555
- export function resolveGlobalInstructions({ home = os.homedir(), settingsFile = null } = {}) {
586
+ export function resolveGlobalInstructions({ home = null, settingsFile = null } = {}) {
556
587
  const layers = settingsLayers(settingsFile ? { settingsFile } : {});
557
588
  const resolved = finalize(userCandidates(home, layers), { projectDir: null, excludes: readExcludes(layers) });
558
589
  resolved.problems.push(...unreferencedUserFiles(home, resolved.files));
@@ -4,9 +4,10 @@
4
4
  // happening right now.
5
5
 
6
6
  import fs from 'node:fs';
7
- import os from 'node:os';
8
7
  import path from 'node:path';
9
8
 
9
+ import { configPath } from './config.mjs';
10
+
10
11
  function isAlive(pid) {
11
12
  try {
12
13
  process.kill(pid, 0);
@@ -21,7 +22,7 @@ function isAlive(pid) {
21
22
  * left behind by a process that has since exited is silently skipped rather
22
23
  * than reported as stale - the registry itself is not this app's to clean up.
23
24
  */
24
- export function readLiveSessions({ dir = path.join(os.homedir(), '.claude', 'sessions') } = {}) {
25
+ export function readLiveSessions({ dir = configPath('sessions') } = {}) {
25
26
  let entries;
26
27
  try {
27
28
  entries = fs.readdirSync(dir, { withFileTypes: true });
package/src/model.mjs CHANGED
@@ -15,7 +15,7 @@ import { parseIndex, parseFrontmatter, extractWikilinks } from './parse.mjs';
15
15
  import { listMemoryFiles, memoryDir, resolveProjectPath, shortLabel } from './projects.mjs';
16
16
  import { autoMemoryState } from './settings.mjs';
17
17
  import { ageInDays, estimateTokens, findDuplicates, indexStats } from './stats.mjs';
18
- import { memoryChecks, sessionChecks } from './checks.mjs';
18
+ import { agentStoreChecks, memoryChecks, sessionChecks } from './checks.mjs';
19
19
  import { listSessions, originSession, retention, transcriptDir } from './sessions.mjs';
20
20
  import { rememberedPath } from './pathcache.mjs';
21
21
 
@@ -198,6 +198,7 @@ export function buildStore(store) {
198
198
  ...(health.longHooks.length
199
199
  ? [{ kind: 'long-hooks', severity: 'warn', count: health.longHooks.length, longest: health.longHooks[0] }]
200
200
  : []),
201
+ ...agentStoreChecks(store),
201
202
  ...memoryChecks(memories, index),
202
203
  ...sessionChecks(store, memories, sessions.retention, {
203
204
  remembered: sessions.remembered,
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