@vodmal/vdx-cli 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/memory.ts ADDED
@@ -0,0 +1,367 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { remoteParts } from './author.ts';
5
+ import { claudeConfigDir, projectDirName } from './conversations.ts';
6
+ import { dict, str } from './narrow.ts';
7
+
8
+ /**
9
+ * Where Claude Code keeps a project's memory (docs/tasks/claude-memory-in-project).
10
+ * Unset, it is `<config>/projects/<path, non-letters → "-">/memory/` — tied to the
11
+ * folder's path, so a moved folder loses it, and its conversations with it. The
12
+ * owner keeps it inside the project, `.claude/memory/`, named by
13
+ * `autoMemoryDirectory`: an absolute path or `~/`, nothing relative, no variables
14
+ * (Claude Code 2.1.308, DL #2). A path written there outlives a move as the trace of
15
+ * the old place.
16
+ */
17
+ export type MemoryState =
18
+ /** In `.claude/memory/`, nothing left in the old place. */
19
+ | { kind: 'inside' }
20
+ /** In `.claude/memory/`, and notes still lie in the old place — a move made by hand. */
21
+ | { kind: 'leftovers'; old: string; notes: number; differ: number; onlyOld: number }
22
+ /** Not set: memory goes to the old place. */
23
+ | { kind: 'outside'; old: string; notes: number }
24
+ /**
25
+ * Set to `<X>/.claude/memory/` of a folder that is gone: the project moved from X. `stray`: X
26
+ * holds nothing but that memory — a session that read the old path wrote there after the move.
27
+ */
28
+ | { kind: 'moved'; setting: MemorySetting; from: string; conversations: string | null; stray: string | null }
29
+ /** Set to `<X>/.claude/memory/` of another folder that exists. */
30
+ | { kind: 'elsewhere'; setting: MemorySetting; from: string }
31
+ /** Set to a path of the owner's own choosing. */
32
+ | { kind: 'custom'; setting: MemorySetting };
33
+
34
+ interface MemorySetting {
35
+ /** The settings file that names it. */
36
+ file: string;
37
+ /** The path it names, `~/` expanded, without the trailing slash. */
38
+ dir: string;
39
+ }
40
+
41
+ export interface MemoryCtx {
42
+ env: NodeJS.ProcessEnv;
43
+ home: string;
44
+ }
45
+
46
+ const MEMORY = path.join('.claude', 'memory');
47
+
48
+ function realRoot(root: string): string {
49
+ try {
50
+ return fs.realpathSync(root);
51
+ } catch {
52
+ return path.resolve(root);
53
+ }
54
+ }
55
+
56
+ /** Where the setting is read, in Claude Code's order: local settings, the project's, the user's. */
57
+ function settingFiles(root: string, ctx: MemoryCtx): string[] {
58
+ return [
59
+ path.join(root, '.claude', 'settings.local.json'),
60
+ path.join(root, '.claude', 'settings.json'),
61
+ path.join(claudeConfigDir(ctx.env, ctx.home), 'settings.json'),
62
+ ];
63
+ }
64
+
65
+ function readJson(file: string): Record<string, unknown> | null {
66
+ try {
67
+ return dict(JSON.parse(fs.readFileSync(file, 'utf8'))) ?? null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function readSetting(root: string, ctx: MemoryCtx): MemorySetting | null {
74
+ for (const file of settingFiles(root, ctx)) {
75
+ const value = str(readJson(file)?.['autoMemoryDirectory']);
76
+ if (!value) continue;
77
+ const expanded = value.startsWith('~/') ? path.join(ctx.home, value.slice(2)) : value;
78
+ // Claude Code drops a relative path without a word (DL #2): it is no setting.
79
+ if (!path.isAbsolute(expanded)) continue;
80
+ return { file, dir: path.resolve(expanded) };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** The old place: what Claude Code uses when nothing is set. */
86
+ export function defaultMemoryDir(root: string, ctx: MemoryCtx): string {
87
+ return path.join(claudeConfigDir(ctx.env, ctx.home), 'projects', projectDirName(realRoot(root)), 'memory');
88
+ }
89
+
90
+ function entries(dir: string): string[] {
91
+ try {
92
+ return fs.readdirSync(dir).filter((n) => n !== '.DS_Store');
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ function sameFile(a: string, b: string): boolean {
99
+ try {
100
+ return fs.readFileSync(a).equals(fs.readFileSync(b));
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ export function memoryState(projectRoot: string, ctx: MemoryCtx): MemoryState {
107
+ const root = realRoot(projectRoot);
108
+ const target = path.join(root, MEMORY);
109
+ const old = defaultMemoryDir(root, ctx);
110
+ const setting = readSetting(root, ctx);
111
+ const notes = entries(old);
112
+ if (!setting) return { kind: 'outside', old, notes: notes.length };
113
+ if (setting.dir === target) {
114
+ if (notes.length === 0) return { kind: 'inside' };
115
+ const differ = notes.filter(
116
+ (n) => fs.existsSync(path.join(target, n)) && !sameFile(path.join(old, n), path.join(target, n)),
117
+ );
118
+ const onlyOld = notes.filter((n) => !fs.existsSync(path.join(target, n)));
119
+ return { kind: 'leftovers', old, notes: notes.length, differ: differ.length, onlyOld: onlyOld.length };
120
+ }
121
+ if (path.basename(setting.dir) === 'memory' && path.basename(path.dirname(setting.dir)) === '.claude') {
122
+ const from = path.dirname(path.dirname(setting.dir));
123
+ const stub = entries(from).join() === '.claude' && entries(path.join(from, '.claude')).join() === 'memory';
124
+ if (fs.existsSync(from) && !stub) return { kind: 'elsewhere', setting, from };
125
+ const conversations = path.join(claudeConfigDir(ctx.env, ctx.home), 'projects', projectDirName(from));
126
+ return {
127
+ kind: 'moved',
128
+ setting,
129
+ from,
130
+ conversations: fs.existsSync(conversations) ? conversations : null,
131
+ stray: stub ? setting.dir : null,
132
+ };
133
+ }
134
+ return { kind: 'custom', setting };
135
+ }
136
+
137
+ function git(root: string, args: string[]): string | null {
138
+ try {
139
+ return execFileSync('git', ['--no-optional-locks', '-C', root, ...args], {
140
+ encoding: 'utf8',
141
+ stdio: ['ignore', 'pipe', 'ignore'],
142
+ }).trim();
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * A personal project's memory goes under git with it; anyone else's would reach
150
+ * whoever clones it (DL #3, #4). Personal: no origin, or origin on a host the
151
+ * profile names in `git.personal_hosts`.
152
+ */
153
+ export function isPersonalProject(projectRoot: string, personalHosts: string[]): boolean {
154
+ const url = git(projectRoot, ['remote', 'get-url', 'origin']);
155
+ if (!url) return true;
156
+ const host = remoteParts(url).host.toLowerCase();
157
+ return personalHosts.some((h) => h.toLowerCase() === host);
158
+ }
159
+
160
+ /** Needs `vdx doctor --fix`: anything but inside, or a path of the owner's own. */
161
+ export function memoryNeedsFix(state: MemoryState): boolean {
162
+ return state.kind !== 'inside' && state.kind !== 'custom';
163
+ }
164
+
165
+ function tilde(p: string, home: string): string {
166
+ return p === home || p.startsWith(home + path.sep) ? '~' + p.slice(home.length) : p;
167
+ }
168
+
169
+ /** One line on where the memory is, for doctor and `vdx ai`. */
170
+ export function describeMemory(state: MemoryState, ctx: MemoryCtx): string {
171
+ switch (state.kind) {
172
+ case 'inside':
173
+ return 'in .claude/memory/';
174
+ case 'leftovers': {
175
+ const parts = [state.differ ? `${state.differ} differ` : '', state.onlyOld ? `${state.onlyOld} only there` : '']
176
+ .filter(Boolean)
177
+ .join(', ');
178
+ return `in .claude/memory/, and ${state.notes} note(s) still in the old place, ${tilde(state.old, ctx.home)}${parts ? ` (${parts})` : ''} — the project's agent takes what it needs, vdx copies nothing back`;
179
+ }
180
+ case 'outside':
181
+ return state.notes
182
+ ? `outside the project: ${state.notes} note(s) in ${tilde(state.old, ctx.home)} — a moved folder loses them, and its conversations`
183
+ : `not set: notes would go to ${tilde(state.old, ctx.home)}, by the folder's path — a moved folder loses them, and its conversations`;
184
+ case 'moved':
185
+ return `autoMemoryDirectory points to ${tilde(state.setting.dir, ctx.home)}/, ${state.stray ? 'in a folder that holds nothing else' : 'a folder that is gone'} — the project moved from ${tilde(state.from, ctx.home)}${state.conversations ? ', and its conversations stay under the old path' : ''}`;
186
+ case 'elsewhere':
187
+ return `autoMemoryDirectory points to the memory of another folder, ${tilde(state.from, ctx.home)} (set in ${tilde(state.setting.file, ctx.home)})`;
188
+ case 'custom':
189
+ return `in ${tilde(state.setting.dir, ctx.home)}/ (autoMemoryDirectory, set in ${tilde(state.setting.file, ctx.home)})`;
190
+ }
191
+ }
192
+
193
+ export interface MemoryFixOptions {
194
+ /** Personal project (`isPersonalProject`): the path goes into `.claude/settings.json`, the memory under git. */
195
+ personal: boolean;
196
+ /** Not personal: keep `.claude/memory/` under git anyway — the person said yes. */
197
+ underGit: boolean;
198
+ /** YYYY-MM-DD, for the name the old place is set aside under. */
199
+ today: string;
200
+ }
201
+
202
+ /** A free name beside `dir`: `<dir>.moved-<date>`, then `-2`, `-3`… */
203
+ function asideName(dir: string, today: string): string {
204
+ const base = `${dir}.moved-${today}`;
205
+ let name = base;
206
+ for (let i = 2; fs.existsSync(name); i++) name = `${base}-${i}`;
207
+ return name;
208
+ }
209
+
210
+ /** Copies what `to` lacks; a name `to` already has stays as it is there. Returns the names kept. */
211
+ function copyMissing(from: string, to: string): { copied: number; kept: string[] } {
212
+ fs.mkdirSync(to, { recursive: true });
213
+ let copied = 0;
214
+ const kept: string[] = [];
215
+ for (const name of entries(from)) {
216
+ const src = path.join(from, name);
217
+ const dst = path.join(to, name);
218
+ if (fs.existsSync(dst)) {
219
+ if (!sameFile(src, dst)) kept.push(name);
220
+ continue;
221
+ }
222
+ fs.cpSync(src, dst, { recursive: true, errorOnExist: true, force: false });
223
+ copied++;
224
+ }
225
+ return { copied, kept };
226
+ }
227
+
228
+ /** Sets `autoMemoryDirectory` in a settings file, keeping its other keys. */
229
+ function writeSetting(file: string, dir: string): void {
230
+ let doc: Record<string, unknown> = {};
231
+ if (fs.existsSync(file)) {
232
+ const parsed = readJson(file);
233
+ if (!parsed)
234
+ throw new Error(`${file} is not a JSON object — nothing written; set autoMemoryDirectory there by hand`);
235
+ doc = parsed;
236
+ }
237
+ doc['autoMemoryDirectory'] = dir + '/';
238
+ fs.mkdirSync(path.dirname(file), { recursive: true });
239
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
240
+ }
241
+
242
+ /** `/.claude/memory/` in the clone's own exclude file — the repository itself does not change. */
243
+ function excludeMemory(root: string): string | null {
244
+ const file = git(root, ['rev-parse', '--git-path', 'info/exclude']);
245
+ if (!file) return null;
246
+ const abs = path.resolve(root, file);
247
+ const line = '/.claude/memory/';
248
+ const text = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
249
+ if (!text.split('\n').includes(line)) {
250
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
251
+ fs.writeFileSync(abs, text + (text && !text.endsWith('\n') ? '\n' : '') + line + '\n');
252
+ }
253
+ return abs;
254
+ }
255
+
256
+ /**
257
+ * Moves the project's conversations to the folder's new path: Claude Code finds them
258
+ * by the path, and a moved folder starts with none. What the new place already has
259
+ * stays; the rest of the old place is set aside, never deleted.
260
+ */
261
+ function moveConversations(from: string, to: string, today: string): string {
262
+ if (!fs.existsSync(to)) {
263
+ fs.renameSync(from, to);
264
+ return `conversations ${path.basename(from)} → ${path.basename(to)}`;
265
+ }
266
+ let moved = 0;
267
+ for (const name of entries(from)) {
268
+ const dst = path.join(to, name);
269
+ if (fs.existsSync(dst)) continue;
270
+ fs.renameSync(path.join(from, name), dst);
271
+ moved++;
272
+ }
273
+ const left = entries(from).length;
274
+ const aside = left ? asideName(from, today) : null;
275
+ if (aside) fs.renameSync(from, aside);
276
+ else fs.rmdirSync(from);
277
+ return `${moved} conversation file(s) ${path.basename(from)} → ${path.basename(to)}${aside ? `; ${left} already there, the rest set aside as ${path.basename(aside)}` : ''}`;
278
+ }
279
+
280
+ /**
281
+ * Puts the project's memory inside it — `vdx doctor --fix` and the question in
282
+ * `vdx ai`. Nothing is overwritten and nothing deleted: a name `.claude/memory/`
283
+ * already has stays, the old place is renamed. Returns what was done, one line each.
284
+ */
285
+ export function fixMemory(projectRoot: string, state: MemoryState, ctx: MemoryCtx, opts: MemoryFixOptions): string[] {
286
+ const root = realRoot(projectRoot);
287
+ const target = path.join(root, MEMORY);
288
+ const done: string[] = [];
289
+ const setAside = (old: string) => {
290
+ if (!fs.existsSync(old)) return;
291
+ const aside = asideName(old, opts.today);
292
+ fs.renameSync(old, aside);
293
+ done.push(`the old place set aside: ${tilde(aside, ctx.home)}`);
294
+ };
295
+ switch (state.kind) {
296
+ case 'inside':
297
+ case 'custom':
298
+ return done;
299
+ case 'leftovers':
300
+ setAside(state.old);
301
+ return done;
302
+ case 'outside': {
303
+ const { copied, kept } = fs.existsSync(state.old) ? copyMissing(state.old, target) : { copied: 0, kept: [] };
304
+ if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true });
305
+ done.push(`${copied} note(s) → .claude/memory/`);
306
+ if (kept.length) done.push(`kept .claude/memory/ as it is for ${kept.join(', ')} — the old copy differs`);
307
+ const file = path.join(root, '.claude', opts.personal ? 'settings.json' : 'settings.local.json');
308
+ writeSetting(file, target);
309
+ done.push(`autoMemoryDirectory → .claude/${path.basename(file)}`);
310
+ if (!opts.personal && !opts.underGit && excludeMemory(root)) done.push('.claude/memory/ → .git/info/exclude');
311
+ setAside(state.old);
312
+ if (opts.personal && git(root, ['rev-parse', '--git-dir']) !== null) {
313
+ done.push('commit .claude/memory/ and .claude/settings.json with the project');
314
+ }
315
+ return done;
316
+ }
317
+ case 'moved':
318
+ case 'elsewhere': {
319
+ writeSetting(state.setting.file, target);
320
+ done.push(`autoMemoryDirectory → ${target}/ (${path.relative(root, state.setting.file)})`);
321
+ if (state.kind === 'moved' && state.stray) {
322
+ const { copied, kept } = copyMissing(state.stray, target);
323
+ done.push(
324
+ `${copied} note(s) written at the old path after the move → .claude/memory/${kept.length ? `; kept .claude/memory/ as it is for ${kept.join(', ')}` : ''} — ${tilde(state.from, ctx.home)} holds only them, remove it`,
325
+ );
326
+ }
327
+ if (state.kind === 'moved' && state.conversations) {
328
+ const here = path.join(claudeConfigDir(ctx.env, ctx.home), 'projects', projectDirName(root));
329
+ done.push(moveConversations(state.conversations, here, opts.today));
330
+ }
331
+ return done;
332
+ }
333
+ }
334
+ }
335
+
336
+ export interface ProjectMemoryDeps {
337
+ /** One line from the person at the terminal; null without one. */
338
+ ask: (prompt: string) => string | null;
339
+ log: (line: string) => void;
340
+ today: string;
341
+ /** The profile's `git.personal_hosts`. */
342
+ personalHosts: string[];
343
+ }
344
+
345
+ /**
346
+ * `fixMemory` with the question it needs: outside a personal project the memory stays
347
+ * out of git unless the person says yes — a publication cannot be taken back (DL #3,
348
+ * #4); without a terminal, out.
349
+ */
350
+ export function fixProjectMemory(
351
+ projectRoot: string,
352
+ state: MemoryState,
353
+ ctx: MemoryCtx,
354
+ deps: ProjectMemoryDeps,
355
+ ): string[] {
356
+ const personal = isPersonalProject(projectRoot, deps.personalHosts);
357
+ let underGit = false;
358
+ if (state.kind === 'outside' && !personal && git(projectRoot, ['rev-parse', '--git-dir']) !== null) {
359
+ deps.log(` ${path.basename(projectRoot)} is not a personal repo: memory under git reaches whoever clones it`);
360
+ underGit = /^y(es)?$/i.test(deps.ask(' Keep .claude/memory/ under git? [y/N] ')?.trim() ?? '');
361
+ }
362
+ const done = fixMemory(projectRoot, state, ctx, { personal, underGit, today: deps.today });
363
+ if (done.length && (state.kind === 'outside' || state.kind === 'moved')) {
364
+ done.push('sessions of this project running now keep the old place until they restart');
365
+ }
366
+ return done;
367
+ }
package/src/publish.ts CHANGED
@@ -199,6 +199,17 @@ export function checkNpmAuth(registry: string | null, npm: NpmRunner): Preflight
199
199
  fix,
200
200
  };
201
201
  }
202
+ // A 5xx is the registry's trouble, not the login: 0.35.0 met E503 during an npm
203
+ // incident on 2026-10-22, and the line read as a failed login.
204
+ const outage = /\bE(5\d\d)\b/.exec(stderr);
205
+ if (outage) {
206
+ const npmjs = !registry || registry.includes('registry.npmjs.org');
207
+ return {
208
+ name: 'npm-auth',
209
+ ok: false,
210
+ message: `${where} is not answering (E${outage[1]}) — trouble on its side, not the login${npmjs ? '; status: https://status.npmjs.org' : ''}. Run vdx publish again once it answers`,
211
+ };
212
+ }
202
213
  return {
203
214
  name: 'npm-auth',
204
215
  ok: false,