lat.md 0.8.0 → 0.8.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.
@@ -1,11 +1,13 @@
1
1
  import { existsSync, cpSync, mkdirSync, writeFileSync, readFileSync, } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
+ import { execSync } from 'node:child_process';
3
4
  import { createInterface } from 'node:readline/promises';
4
5
  import chalk from 'chalk';
5
6
  import { findTemplatesDir } from './templates.js';
6
7
  import { readAgentsTemplate, readCursorRulesTemplate, readPiExtensionTemplate, } from './gen.js';
7
- import { getConfigPath, readConfig, writeConfig, } from '../config.js';
8
+ import { getLlmKey, getConfigPath, readConfig, writeConfig, } from '../config.js';
8
9
  import { writeInitMeta, readFileHash, contentHash } from '../init-version.js';
10
+ import { getLocalVersion, fetchLatestVersion } from '../version.js';
9
11
  import { selectMenu } from './select-menu.js';
10
12
  async function confirm(rl, message) {
11
13
  while (true) {
@@ -86,10 +88,26 @@ function resolveLatBin() {
86
88
  // .ts file but no special loader flags — best-effort, just return the path
87
89
  return script;
88
90
  }
91
+ /** Return the lat binary string for the given command style. */
92
+ function latBinString(style) {
93
+ if (style === 'global')
94
+ return 'lat';
95
+ if (style === 'npx')
96
+ return 'npx lat.md@latest';
97
+ return resolveLatBin();
98
+ }
99
+ /** Return the MCP server command descriptor for the given command style. */
100
+ function styledMcpCommand(style) {
101
+ if (style === 'global')
102
+ return { command: 'lat', args: ['mcp'] };
103
+ if (style === 'npx')
104
+ return { command: 'npx', args: ['lat.md@latest', 'mcp'] };
105
+ return mcpCommand();
106
+ }
89
107
  // ── Claude Code helpers ──────────────────────────────────────────────
90
- /** Derive the hook command prefix from the currently running binary. */
91
- function latHookCommand(event) {
92
- return `${resolveLatBin()} hook claude ${event}`;
108
+ /** Derive the hook command prefix for the given command style. */
109
+ function latHookCommand(style, event) {
110
+ return `${latBinString(style)} hook claude ${event}`;
93
111
  }
94
112
  /** True if any command in this entry looks like it was installed by lat. */
95
113
  function isLatHookEntry(entry) {
@@ -103,7 +121,7 @@ function isLatHookEntry(entry) {
103
121
  * Remove all lat-owned hook entries from settings, then add fresh ones.
104
122
  * Preserves any non-lat hooks the user may have configured.
105
123
  */
106
- function syncLatHooks(settingsPath) {
124
+ function syncLatHooks(settingsPath, style) {
107
125
  let settings = {};
108
126
  if (existsSync(settingsPath)) {
109
127
  const raw = readFileSync(settingsPath, 'utf-8');
@@ -136,7 +154,7 @@ function syncLatHooks(settingsPath) {
136
154
  hooks[event] = [];
137
155
  }
138
156
  hooks[event].push({
139
- hooks: [{ type: 'command', command: latHookCommand(event) }],
157
+ hooks: [{ type: 'command', command: latHookCommand(style, event) }],
140
158
  });
141
159
  }
142
160
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
@@ -154,6 +172,25 @@ function ensureGitignored(root, entry) {
154
172
  return;
155
173
  }
156
174
  }
175
+ // Skip if the entry is already tracked in git — adding it to .gitignore
176
+ // would have no effect and confuse the user.
177
+ if (existsSync(gitDir)) {
178
+ try {
179
+ const result = execSync(`git ls-files "${entry}"`, {
180
+ cwd: root,
181
+ encoding: 'utf-8',
182
+ stdio: ['pipe', 'pipe', 'pipe'],
183
+ });
184
+ if (result.trim().length > 0) {
185
+ console.log(chalk.yellow(` ${entry}`) +
186
+ ' is already checked in to git — skipping .gitignore');
187
+ return;
188
+ }
189
+ }
190
+ catch {
191
+ console.log(chalk.yellow(` Warning:`) + ' git ls-files failed — skipping check');
192
+ }
193
+ }
157
194
  if (existsSync(gitignorePath)) {
158
195
  // Append to existing .gitignore
159
196
  let content = readFileSync(gitignorePath, 'utf-8');
@@ -215,7 +252,7 @@ function hasMcpServer(configPath, key) {
215
252
  return false;
216
253
  }
217
254
  }
218
- function addMcpServer(configPath, key) {
255
+ function addMcpServer(configPath, key, style) {
219
256
  let cfg = { [key]: {} };
220
257
  if (existsSync(configPath)) {
221
258
  const raw = readFileSync(configPath, 'utf-8');
@@ -228,7 +265,7 @@ function addMcpServer(configPath, key) {
228
265
  throw new Error(`Cannot parse ${configPath}: ${e.message}`);
229
266
  }
230
267
  }
231
- cfg[key].lat = mcpCommand();
268
+ cfg[key].lat = styledMcpCommand(style);
232
269
  mkdirSync(join(configPath, '..'), { recursive: true });
233
270
  writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
234
271
  }
@@ -283,7 +320,7 @@ async function setupAgentsMd(root, latDir, template, hashes, ask) {
283
320
  if (hash)
284
321
  hashes['AGENTS.md'] = hash;
285
322
  }
286
- async function setupClaudeCode(root, latDir, template, hashes, ask) {
323
+ async function setupClaudeCode(root, latDir, template, hashes, ask, style) {
287
324
  // CLAUDE.md — written directly (not a symlink)
288
325
  const hash = await writeTemplateFile(root, latDir, 'CLAUDE.md', template, 'claude.md', 'CLAUDE.md', ' ', ask);
289
326
  if (hash)
@@ -295,7 +332,7 @@ async function setupClaudeCode(root, latDir, template, hashes, ask) {
295
332
  const claudeDir = join(root, '.claude');
296
333
  const settingsPath = join(claudeDir, 'settings.json');
297
334
  mkdirSync(claudeDir, { recursive: true });
298
- syncLatHooks(settingsPath);
335
+ syncLatHooks(settingsPath, style);
299
336
  console.log(chalk.green(' Hooks') + ' synced (UserPromptSubmit + Stop)');
300
337
  // Ensure .claude is gitignored (settings contain local absolute paths)
301
338
  ensureGitignored(root, '.claude');
@@ -308,13 +345,13 @@ async function setupClaudeCode(root, latDir, template, hashes, ask) {
308
345
  console.log(chalk.green(' MCP server') + ' already configured');
309
346
  }
310
347
  else {
311
- addMcpServer(mcpPath, 'mcpServers');
348
+ addMcpServer(mcpPath, 'mcpServers', style);
312
349
  console.log(chalk.green(' MCP server') + ' registered in .mcp.json');
313
350
  }
314
351
  // Ensure .mcp.json is gitignored (it contains local absolute paths)
315
352
  ensureGitignored(root, '.mcp.json');
316
353
  }
317
- async function setupCursor(root, latDir, hashes, ask) {
354
+ async function setupCursor(root, latDir, hashes, ask, style) {
318
355
  // .cursor/rules/lat.md
319
356
  const hash = await writeTemplateFile(root, latDir, '.cursor/rules/lat.md', readCursorRulesTemplate(), 'cursor-rules.md', 'Rules (.cursor/rules/lat.md)', ' ', ask);
320
357
  if (hash)
@@ -328,7 +365,7 @@ async function setupCursor(root, latDir, hashes, ask) {
328
365
  console.log(chalk.green(' MCP server') + ' already configured');
329
366
  }
330
367
  else {
331
- addMcpServer(mcpPath, 'mcpServers');
368
+ addMcpServer(mcpPath, 'mcpServers', style);
332
369
  console.log(chalk.green(' MCP server') + ' registered in .cursor/mcp.json');
333
370
  }
334
371
  // Ensure .cursor/mcp.json is gitignored (it contains local absolute paths)
@@ -337,7 +374,7 @@ async function setupCursor(root, latDir, hashes, ask) {
337
374
  console.log(chalk.yellow(' Note:') +
338
375
  ' Enable MCP in Cursor: Settings → Features → MCP → check "Enable MCP"');
339
376
  }
340
- async function setupCopilot(root, latDir, hashes, ask) {
377
+ async function setupCopilot(root, latDir, hashes, ask, style) {
341
378
  // .github/copilot-instructions.md
342
379
  const hash = await writeTemplateFile(root, latDir, '.github/copilot-instructions.md', readAgentsTemplate(), 'agents.md', 'Instructions (.github/copilot-instructions.md)', ' ', ask);
343
380
  if (hash)
@@ -351,18 +388,18 @@ async function setupCopilot(root, latDir, hashes, ask) {
351
388
  console.log(chalk.green(' MCP server') + ' already configured');
352
389
  }
353
390
  else {
354
- addMcpServer(mcpPath, 'servers');
391
+ addMcpServer(mcpPath, 'servers', style);
355
392
  console.log(chalk.green(' MCP server') + ' registered in .vscode/mcp.json');
356
393
  }
357
394
  }
358
- async function setupPi(root, latDir, hashes, ask) {
395
+ async function setupPi(root, latDir, hashes, ask, style) {
359
396
  // AGENTS.md — Pi reads this natively
360
397
  // (already created in the shared step if any non-Claude agent is selected)
361
398
  // .pi/extensions/lat.ts — extension that registers tools + lifecycle hooks
362
399
  console.log('');
363
400
  console.log(chalk.dim(' The Pi extension registers lat tools and hooks into the agent lifecycle'));
364
401
  console.log(chalk.dim(' to inject search context and validate lat.md/ before finishing.'));
365
- const template = readPiExtensionTemplate().replace('__LAT_BIN__', resolveLatBin());
402
+ const template = readPiExtensionTemplate().replace('__LAT_BIN__', latBinString(style));
366
403
  const hash = await writeTemplateFile(root, latDir, '.pi/extensions/lat.ts', template, 'pi-extension.ts', 'Extension (.pi/extensions/lat.ts)', ' ', ask);
367
404
  if (hash)
368
405
  hashes['.pi/extensions/lat.ts'] = hash;
@@ -371,21 +408,11 @@ async function setupPi(root, latDir, hashes, ask) {
371
408
  }
372
409
  // ── LLM key setup ───────────────────────────────────────────────────
373
410
  async function setupLlmKey(rl) {
374
- // Check env var first
375
- const envKey = process.env.LAT_LLM_KEY;
376
- if (envKey) {
377
- console.log('');
378
- console.log(chalk.green('Semantic search') + ' — LAT_LLM_KEY is set. Ready.');
379
- return;
380
- }
381
- // Check existing config
382
- const config = readConfig();
383
- const configPath = getConfigPath();
384
- if (config.llm_key) {
411
+ // Use the centralized key resolution (env var → file → helper → config)
412
+ const existingKey = getLlmKey();
413
+ if (existingKey) {
385
414
  console.log('');
386
- console.log(chalk.green('Semantic search') +
387
- ' — LLM key configured in ' +
388
- chalk.dim(configPath));
415
+ console.log(chalk.green('Semantic search') + ' — LLM key found. Ready.');
389
416
  return;
390
417
  }
391
418
  // No key found — explain what semantic search is and prompt
@@ -445,18 +472,39 @@ async function setupLlmKey(rl) {
445
472
  console.log(' Saving anyway — you can update it later.');
446
473
  }
447
474
  // Save to config
448
- const updatedConfig = { ...config, llm_key: key };
475
+ const updatedConfig = { ...readConfig(), llm_key: key };
449
476
  writeConfig(updatedConfig);
450
- console.log(chalk.green(' Key saved') + ' to ' + chalk.dim(configPath));
477
+ console.log(chalk.green(' Key saved') + ' to ' + chalk.dim(getConfigPath()));
451
478
  }
452
479
  // ── Main init flow ───────────────────────────────────────────────────
453
480
  export async function initCmd(targetDir) {
481
+ // Upfront version check — let the user upgrade before proceeding
482
+ process.stdout.write(chalk.dim('Checking latest version...'));
483
+ const latest = await fetchLatestVersion();
484
+ const local = getLocalVersion();
485
+ if (latest && latest !== local) {
486
+ console.log(' ' +
487
+ chalk.yellow('update available:') +
488
+ ' ' +
489
+ local +
490
+ ' → ' +
491
+ chalk.green(latest) +
492
+ ' — run ' +
493
+ chalk.cyan('npm install -g lat.md') +
494
+ ' to update.');
495
+ console.log('');
496
+ }
497
+ else {
498
+ console.log(' ' + chalk.green(`latest version is used (${local})`));
499
+ }
454
500
  const root = resolve(targetDir ?? process.cwd());
455
501
  const latDir = join(root, 'lat.md');
456
502
  const interactive = process.stdin.isTTY ?? false;
457
- const rl = interactive
458
- ? createInterface({ input: process.stdin, output: process.stdout })
459
- : null;
503
+ // Readline is created AFTER the selectMenu loop below.
504
+ // selectMenu puts stdin into raw mode with its own 'data' listener;
505
+ // if readline is already attached it receives those raw keypresses,
506
+ // corrupting its internal state and causing rl.question() to hang/exit.
507
+ let rl = null;
460
508
  const ask = async (message) => {
461
509
  if (!rl)
462
510
  return true;
@@ -468,9 +516,21 @@ export async function initCmd(targetDir) {
468
516
  console.log(chalk.green('lat.md/') + ' already exists');
469
517
  }
470
518
  else {
471
- if (!(await ask('Create lat.md/ directory?'))) {
472
- console.log('Aborted.');
473
- return;
519
+ // No rl yet — selectMenu hasn't run, so use a one-off confirm
520
+ if (interactive) {
521
+ const tmpRl = createInterface({
522
+ input: process.stdin,
523
+ output: process.stdout,
524
+ });
525
+ try {
526
+ if (!(await confirm(tmpRl, 'Create lat.md/ directory?'))) {
527
+ console.log('Aborted.');
528
+ return;
529
+ }
530
+ }
531
+ finally {
532
+ tmpRl.close();
533
+ }
474
534
  }
475
535
  const templateDir = join(findTemplatesDir(), 'init');
476
536
  mkdirSync(latDir, { recursive: true });
@@ -514,6 +574,32 @@ export async function initCmd(targetDir) {
514
574
  const useCopilot = selectedAgents.includes('copilot');
515
575
  const useCodex = selectedAgents.includes('codex');
516
576
  const anySelected = selectedAgents.length > 0;
577
+ const needsLatCommand = useClaudeCode || usePi || useCursor || useCopilot;
578
+ // Step 2b: How should agents run lat?
579
+ let commandStyle = 'local';
580
+ if (anySelected && needsLatCommand && interactive) {
581
+ console.log('');
582
+ const localBin = resolveLatBin();
583
+ const styleOptions = [
584
+ { label: 'lat', value: 'global' },
585
+ { label: localBin, value: 'local' },
586
+ { label: 'npx lat.md@latest', value: 'npx' },
587
+ ];
588
+ const styleChoice = await selectMenu(styleOptions, 'How should agents run lat?', 0);
589
+ if (!styleChoice) {
590
+ console.log('Aborted.');
591
+ return;
592
+ }
593
+ commandStyle = styleChoice;
594
+ }
595
+ // Now that selectMenu is done, it's safe to create the readline interface.
596
+ // selectMenu has restored stdin to its original state (paused, non-raw).
597
+ if (interactive) {
598
+ rl = createInterface({
599
+ input: process.stdin,
600
+ output: process.stdout,
601
+ });
602
+ }
517
603
  if (!anySelected) {
518
604
  console.log('');
519
605
  console.log(chalk.dim('No agents selected. You can re-run') +
@@ -533,22 +619,22 @@ export async function initCmd(targetDir) {
533
619
  if (useClaudeCode) {
534
620
  console.log('');
535
621
  console.log(chalk.bold('Setting up Claude Code...'));
536
- await setupClaudeCode(root, latDir, template, fileHashes, ask);
622
+ await setupClaudeCode(root, latDir, template, fileHashes, ask, commandStyle);
537
623
  }
538
624
  if (usePi) {
539
625
  console.log('');
540
626
  console.log(chalk.bold('Setting up Pi...'));
541
- await setupPi(root, latDir, fileHashes, ask);
627
+ await setupPi(root, latDir, fileHashes, ask, commandStyle);
542
628
  }
543
629
  if (useCursor) {
544
630
  console.log('');
545
631
  console.log(chalk.bold('Setting up Cursor...'));
546
- await setupCursor(root, latDir, fileHashes, ask);
632
+ await setupCursor(root, latDir, fileHashes, ask, commandStyle);
547
633
  }
548
634
  if (useCopilot) {
549
635
  console.log('');
550
636
  console.log(chalk.bold('Setting up VS Code Copilot...'));
551
- await setupCopilot(root, latDir, fileHashes, ask);
637
+ await setupCopilot(root, latDir, fileHashes, ask, commandStyle);
552
638
  }
553
639
  if (useCodex) {
554
640
  console.log('');
@@ -0,0 +1,7 @@
1
+ /** Walk up from this file to find the nearest package.json version. */
2
+ export declare function getLocalVersion(): string;
3
+ /**
4
+ * Fetch the latest published version of `lat.md` from the npm registry.
5
+ * Returns null if the fetch fails or times out (3s).
6
+ */
7
+ export declare function fetchLatestVersion(): Promise<string | null>;
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /** Walk up from this file to find the nearest package.json version. */
5
+ export function getLocalVersion() {
6
+ let dir = dirname(fileURLToPath(import.meta.url));
7
+ while (true) {
8
+ const candidate = join(dir, 'package.json');
9
+ try {
10
+ return JSON.parse(readFileSync(candidate, 'utf-8')).version;
11
+ }
12
+ catch { }
13
+ const parent = dirname(dir);
14
+ if (parent === dir)
15
+ return '0.0.0';
16
+ dir = parent;
17
+ }
18
+ }
19
+ /**
20
+ * Fetch the latest published version of `lat.md` from the npm registry.
21
+ * Returns null if the fetch fails or times out (3s).
22
+ */
23
+ export async function fetchLatestVersion() {
24
+ try {
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), 3000);
27
+ const res = await fetch('https://registry.npmjs.org/lat.md/latest', {
28
+ signal: controller.signal,
29
+ });
30
+ clearTimeout(timer);
31
+ if (!res.ok)
32
+ return null;
33
+ const data = (await res.json());
34
+ return data.version ?? null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lat.md",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "A knowledge graph for your codebase, written in markdown",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.2",