lat.md 0.5.0 → 0.7.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.
Files changed (39) hide show
  1. package/dist/src/cli/check.d.ts +7 -5
  2. package/dist/src/cli/check.js +243 -74
  3. package/dist/src/cli/context.d.ts +3 -7
  4. package/dist/src/cli/context.js +15 -1
  5. package/dist/src/cli/expand.d.ts +7 -0
  6. package/dist/src/cli/expand.js +92 -0
  7. package/dist/src/cli/gen.js +11 -4
  8. package/dist/src/cli/hook.d.ts +1 -0
  9. package/dist/src/cli/hook.js +147 -0
  10. package/dist/src/cli/index.js +77 -28
  11. package/dist/src/cli/init.js +148 -120
  12. package/dist/src/cli/locate.d.ts +2 -2
  13. package/dist/src/cli/locate.js +9 -4
  14. package/dist/src/cli/refs.d.ts +20 -4
  15. package/dist/src/cli/refs.js +64 -42
  16. package/dist/src/cli/search.d.ts +25 -3
  17. package/dist/src/cli/search.js +87 -47
  18. package/dist/src/cli/section.d.ts +26 -0
  19. package/dist/src/cli/section.js +133 -0
  20. package/dist/src/code-refs.js +2 -1
  21. package/dist/src/config.js +3 -2
  22. package/dist/src/context.d.ts +21 -0
  23. package/dist/src/context.js +11 -0
  24. package/dist/src/format.d.ts +4 -3
  25. package/dist/src/format.js +16 -20
  26. package/dist/src/init-version.d.ts +10 -0
  27. package/dist/src/init-version.js +49 -0
  28. package/dist/src/lattice.d.ts +11 -5
  29. package/dist/src/lattice.js +87 -38
  30. package/dist/src/mcp/server.js +27 -279
  31. package/dist/src/search/index.js +5 -4
  32. package/dist/src/source-parser.d.ts +23 -0
  33. package/dist/src/source-parser.js +720 -0
  34. package/package.json +3 -1
  35. package/templates/AGENTS.md +38 -6
  36. package/templates/cursor-rules.md +11 -5
  37. package/templates/lat-prompt-hook.sh +2 -2
  38. package/dist/src/cli/prompt.d.ts +0 -2
  39. package/dist/src/cli/prompt.js +0 -60
@@ -1,19 +1,28 @@
1
- import { existsSync, cpSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, chmodSync, } from 'node:fs';
1
+ import { existsSync, cpSync, mkdirSync, writeFileSync, readFileSync, } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { createInterface } from 'node:readline/promises';
4
4
  import chalk from 'chalk';
5
5
  import { findTemplatesDir } from './templates.js';
6
6
  import { readAgentsTemplate, readCursorRulesTemplate } from './gen.js';
7
7
  import { getConfigPath, readConfig, writeConfig, } from '../config.js';
8
+ import { writeInitMeta, readFileHash, contentHash } from '../init-version.js';
8
9
  async function confirm(rl, message) {
9
- try {
10
- const answer = await rl.question(`${message} ${chalk.dim('[Y/n]')} `);
11
- return answer.trim().toLowerCase() !== 'n';
12
- }
13
- catch {
14
- // Ctrl+C or closed stdin — abort
15
- console.log('');
16
- process.exit(130);
10
+ while (true) {
11
+ let answer;
12
+ try {
13
+ answer = await rl.question(`${message} ${chalk.dim('[Y/n]')} `);
14
+ }
15
+ catch {
16
+ // Ctrl+C or closed stdin — abort
17
+ console.log('');
18
+ process.exit(130);
19
+ }
20
+ const val = answer.trim().toLowerCase();
21
+ if (val === '' || val === 'y' || val === 'yes')
22
+ return true;
23
+ if (val === 'n' || val === 'no')
24
+ return false;
25
+ console.log(chalk.yellow(' Please answer Y or n.'));
17
26
  }
18
27
  }
19
28
  async function prompt(rl, message) {
@@ -27,22 +36,21 @@ async function prompt(rl, message) {
27
36
  }
28
37
  }
29
38
  // ── Claude Code helpers ──────────────────────────────────────────────
30
- const HOOK_COMMAND = '.claude/hooks/lat-prompt-hook.sh';
31
- function hasLatHook(settingsPath) {
32
- if (!existsSync(settingsPath))
33
- return false;
34
- try {
35
- const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
36
- const entries = settings?.hooks?.UserPromptSubmit;
37
- if (!Array.isArray(entries))
38
- return false;
39
- return entries.some((entry) => entry.hooks?.some((h) => h.command === HOOK_COMMAND));
40
- }
41
- catch {
42
- return false;
43
- }
39
+ /** Derive the hook command prefix from the currently running binary. */
40
+ function latHookCommand(event) {
41
+ return `${resolve(process.argv[1])} hook claude ${event}`;
44
42
  }
45
- function addLatHook(settingsPath) {
43
+ /** True if any command in this entry looks like it was installed by lat. */
44
+ function isLatHookEntry(entry) {
45
+ const bin = resolve(process.argv[1]);
46
+ return (entry.hooks?.some((h) => typeof h.command === 'string' &&
47
+ (/\blat\b/.test(h.command) || h.command.startsWith(bin + ' '))) ?? false);
48
+ }
49
+ /**
50
+ * Remove all lat-owned hook entries from settings, then add fresh ones.
51
+ * Preserves any non-lat hooks the user may have configured.
52
+ */
53
+ function syncLatHooks(settingsPath) {
46
54
  let settings = {};
47
55
  if (existsSync(settingsPath)) {
48
56
  const raw = readFileSync(settingsPath, 'utf-8');
@@ -57,12 +65,27 @@ function addLatHook(settingsPath) {
57
65
  settings.hooks = {};
58
66
  }
59
67
  const hooks = settings.hooks;
60
- if (!Array.isArray(hooks.UserPromptSubmit)) {
61
- hooks.UserPromptSubmit = [];
68
+ // Strip lat-owned entries from ALL event types (cleans up stale events too)
69
+ for (const [event, entries] of Object.entries(hooks)) {
70
+ if (!Array.isArray(entries))
71
+ continue;
72
+ const filtered = entries.filter((entry) => !isLatHookEntry(entry));
73
+ if (filtered.length > 0) {
74
+ hooks[event] = filtered;
75
+ }
76
+ else {
77
+ delete hooks[event];
78
+ }
79
+ }
80
+ // Add fresh hooks for current events
81
+ for (const event of ['UserPromptSubmit', 'Stop']) {
82
+ if (!Array.isArray(hooks[event])) {
83
+ hooks[event] = [];
84
+ }
85
+ hooks[event].push({
86
+ hooks: [{ type: 'command', command: latHookCommand(event) }],
87
+ });
62
88
  }
63
- hooks.UserPromptSubmit.push({
64
- hooks: [{ type: 'command', command: HOOK_COMMAND }],
65
- });
66
89
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
67
90
  }
68
91
  // ── Gitignore helper ─────────────────────────────────────────────────
@@ -113,7 +136,8 @@ function hasMcpServer(configPath, key) {
113
136
  const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
114
137
  return !!cfg?.[key]?.lat;
115
138
  }
116
- catch {
139
+ catch (err) {
140
+ process.stderr.write(`Warning: failed to parse ${configPath}: ${err.message}\n`);
117
141
  return false;
118
142
  }
119
143
  }
@@ -134,49 +158,73 @@ function addMcpServer(configPath, key) {
134
158
  mkdirSync(join(configPath, '..'), { recursive: true });
135
159
  writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
136
160
  }
161
+ // ── Template file helpers ─────────────────────────────────────────────
162
+ /**
163
+ * Write a template-generated file, using stored hashes to decide whether
164
+ * to overwrite or prompt the user about local modifications.
165
+ *
166
+ * Returns the hash of the written content, or null if the file was skipped.
167
+ */
168
+ async function writeTemplateFile(root, latDir, relPath, template, genTarget, label, indent, ask) {
169
+ const absPath = join(root, relPath);
170
+ const templateHash = contentHash(template);
171
+ if (!existsSync(absPath)) {
172
+ mkdirSync(join(absPath, '..'), { recursive: true });
173
+ writeFileSync(absPath, template);
174
+ console.log(chalk.green(`${indent}Created ${label}`));
175
+ return templateHash;
176
+ }
177
+ // File exists — check if user has modified it
178
+ const currentContent = readFileSync(absPath, 'utf-8');
179
+ const currentHash = contentHash(currentContent);
180
+ const storedHash = readFileHash(latDir, relPath);
181
+ if (currentHash === templateHash) {
182
+ // Already matches the latest template
183
+ console.log(chalk.green(`${indent}${label}`) + ' already up to date');
184
+ return templateHash;
185
+ }
186
+ if (storedHash && currentHash === storedHash) {
187
+ // Unmodified by user — safe to overwrite with new template
188
+ writeFileSync(absPath, template);
189
+ console.log(chalk.green(`${indent}Updated ${label}`));
190
+ return templateHash;
191
+ }
192
+ // User has modified the file — ask whether to overwrite
193
+ console.log(chalk.yellow(`${indent}${label}`) +
194
+ ' exists and may contain your own content.');
195
+ if (await ask(`${indent}Overwrite with latest lat template?`)) {
196
+ writeFileSync(absPath, template);
197
+ console.log(chalk.green(`${indent}Updated ${label}`));
198
+ return templateHash;
199
+ }
200
+ console.log(chalk.dim(`${indent}Kept existing file.`) +
201
+ ' Run ' +
202
+ chalk.cyan(`lat gen ${genTarget}`) +
203
+ ' to see the latest template.');
204
+ return null;
205
+ }
137
206
  // ── Per-agent setup ──────────────────────────────────────────────────
138
- function setupAgentsMd(root, template) {
139
- const agentsPath = join(root, 'AGENTS.md');
140
- if (!existsSync(agentsPath)) {
141
- writeFileSync(agentsPath, template);
142
- console.log(chalk.green('Created AGENTS.md'));
143
- }
144
- else {
145
- console.log(chalk.green('AGENTS.md') + ' already exists');
146
- }
207
+ async function setupAgentsMd(root, latDir, template, hashes, ask) {
208
+ const hash = await writeTemplateFile(root, latDir, 'AGENTS.md', template, 'agents.md', 'AGENTS.md', '', ask);
209
+ if (hash)
210
+ hashes['AGENTS.md'] = hash;
147
211
  }
148
- async function setupClaudeCode(root, template) {
149
- const created = [];
212
+ async function setupClaudeCode(root, latDir, template, hashes, ask) {
150
213
  // CLAUDE.md — written directly (not a symlink)
151
- const claudePath = join(root, 'CLAUDE.md');
152
- if (!existsSync(claudePath)) {
153
- writeFileSync(claudePath, template);
154
- console.log(chalk.green(' Created CLAUDE.md'));
155
- created.push('CLAUDE.md');
156
- }
157
- else {
158
- console.log(chalk.green(' CLAUDE.md') + ' already exists');
159
- }
160
- // Prompt hook
214
+ const hash = await writeTemplateFile(root, latDir, 'CLAUDE.md', template, 'claude.md', 'CLAUDE.md', ' ', ask);
215
+ if (hash)
216
+ hashes['CLAUDE.md'] = hash;
217
+ // Hooks — UserPromptSubmit (lat.md reminders + [[ref]] expansion) and Stop (update reminder)
161
218
  console.log('');
162
- console.log(chalk.dim(" Claude Code doesn't reliably follow CLAUDE.md for per-prompt actions,"));
163
- console.log(chalk.dim(' so we install a hook that injects lat.md workflow reminders into every prompt.'));
219
+ console.log(chalk.dim(' Hooks inject lat.md workflow reminders into every prompt and remind'));
220
+ console.log(chalk.dim(' the agent to update lat.md/ before finishing.'));
164
221
  const claudeDir = join(root, '.claude');
165
- const hooksDir = join(claudeDir, 'hooks');
166
- const hookPath = join(hooksDir, 'lat-prompt-hook.sh');
167
222
  const settingsPath = join(claudeDir, 'settings.json');
168
- if (hasLatHook(settingsPath)) {
169
- console.log(chalk.green(' Prompt hook') + ' already configured');
170
- }
171
- else {
172
- mkdirSync(hooksDir, { recursive: true });
173
- const templateHook = join(findTemplatesDir(), 'lat-prompt-hook.sh');
174
- copyFileSync(templateHook, hookPath);
175
- chmodSync(hookPath, 0o755);
176
- addLatHook(settingsPath);
177
- console.log(chalk.green(' Prompt hook') + ' installed');
178
- created.push('.claude/hooks/lat-prompt-hook.sh');
179
- }
223
+ mkdirSync(claudeDir, { recursive: true });
224
+ syncLatHooks(settingsPath);
225
+ console.log(chalk.green(' Hooks') + ' synced (UserPromptSubmit + Stop)');
226
+ // Ensure .claude is gitignored (settings contain local absolute paths)
227
+ ensureGitignored(root, '.claude');
180
228
  // MCP server → .mcp.json at project root
181
229
  console.log('');
182
230
  console.log(chalk.dim(' Agents can call `lat` from the command line, but an MCP server gives lat'));
@@ -188,26 +236,15 @@ async function setupClaudeCode(root, template) {
188
236
  else {
189
237
  addMcpServer(mcpPath, 'mcpServers');
190
238
  console.log(chalk.green(' MCP server') + ' registered in .mcp.json');
191
- created.push('.mcp.json');
192
239
  }
193
240
  // Ensure .mcp.json is gitignored (it contains local absolute paths)
194
241
  ensureGitignored(root, '.mcp.json');
195
- return created;
196
242
  }
197
- async function setupCursor(root) {
198
- const created = [];
243
+ async function setupCursor(root, latDir, hashes, ask) {
199
244
  // .cursor/rules/lat.md
200
- const rulesDir = join(root, '.cursor', 'rules');
201
- const rulesPath = join(rulesDir, 'lat.md');
202
- if (!existsSync(rulesPath)) {
203
- mkdirSync(rulesDir, { recursive: true });
204
- writeFileSync(rulesPath, readCursorRulesTemplate());
205
- console.log(chalk.green(' Rules') + ' created at .cursor/rules/lat.md');
206
- created.push('.cursor/rules/lat.md');
207
- }
208
- else {
209
- console.log(chalk.green(' Rules') + ' already exist');
210
- }
245
+ const hash = await writeTemplateFile(root, latDir, '.cursor/rules/lat.md', readCursorRulesTemplate(), 'cursor-rules.md', 'Rules (.cursor/rules/lat.md)', ' ', ask);
246
+ if (hash)
247
+ hashes['.cursor/rules/lat.md'] = hash;
211
248
  // .cursor/mcp.json
212
249
  console.log('');
213
250
  console.log(chalk.dim(' Agents can call `lat` from the command line, but an MCP server gives lat'));
@@ -219,30 +256,18 @@ async function setupCursor(root) {
219
256
  else {
220
257
  addMcpServer(mcpPath, 'mcpServers');
221
258
  console.log(chalk.green(' MCP server') + ' registered in .cursor/mcp.json');
222
- created.push('.cursor/mcp.json');
223
259
  }
224
260
  // Ensure .cursor/mcp.json is gitignored (it contains local absolute paths)
225
261
  ensureGitignored(root, '.cursor/mcp.json');
226
262
  console.log('');
227
263
  console.log(chalk.yellow(' Note:') +
228
264
  ' Enable MCP in Cursor: Settings → Features → MCP → check "Enable MCP"');
229
- return created;
230
265
  }
231
- async function setupCopilot(root) {
232
- const created = [];
266
+ async function setupCopilot(root, latDir, hashes, ask) {
233
267
  // .github/copilot-instructions.md
234
- const githubDir = join(root, '.github');
235
- const instructionsPath = join(githubDir, 'copilot-instructions.md');
236
- if (!existsSync(instructionsPath)) {
237
- mkdirSync(githubDir, { recursive: true });
238
- writeFileSync(instructionsPath, readAgentsTemplate());
239
- console.log(chalk.green(' Instructions') +
240
- ' created at .github/copilot-instructions.md');
241
- created.push('.github/copilot-instructions.md');
242
- }
243
- else {
244
- console.log(chalk.green(' Instructions') + ' already exist');
245
- }
268
+ const hash = await writeTemplateFile(root, latDir, '.github/copilot-instructions.md', readAgentsTemplate(), 'agents.md', 'Instructions (.github/copilot-instructions.md)', ' ', ask);
269
+ if (hash)
270
+ hashes['.github/copilot-instructions.md'] = hash;
246
271
  // .vscode/mcp.json
247
272
  console.log('');
248
273
  console.log(chalk.dim(' Agents can call `lat` from the command line, but an MCP server gives lat'));
@@ -254,40 +279,40 @@ async function setupCopilot(root) {
254
279
  else {
255
280
  addMcpServer(mcpPath, 'servers');
256
281
  console.log(chalk.green(' MCP server') + ' registered in .vscode/mcp.json');
257
- created.push('.vscode/mcp.json');
258
282
  }
259
- return created;
260
283
  }
261
284
  // ── LLM key setup ───────────────────────────────────────────────────
262
285
  async function setupLlmKey(rl) {
263
- console.log('');
264
- console.log(chalk.bold('Semantic search'));
265
- console.log('');
266
- console.log(' lat.md includes semantic search (' +
267
- chalk.cyan('lat search') +
268
- ') that lets agents find');
269
- console.log(' relevant documentation by meaning, not just keywords. This requires an');
270
- console.log(' embedding API key (OpenAI or Vercel AI Gateway). Without it, agents can still');
271
- console.log(' use ' +
272
- chalk.cyan('lat locate') +
273
- ' for exact lookups, but will miss semantic matches.');
274
- console.log('');
275
286
  // Check env var first
276
287
  const envKey = process.env.LAT_LLM_KEY;
277
288
  if (envKey) {
278
- console.log(chalk.green(' LAT_LLM_KEY') +
279
- ' is set in your environment. Semantic search is ready.');
289
+ console.log('');
290
+ console.log(chalk.green('Semantic search') + ' LAT_LLM_KEY is set. Ready.');
280
291
  return;
281
292
  }
282
293
  // Check existing config
283
294
  const config = readConfig();
284
295
  const configPath = getConfigPath();
285
296
  if (config.llm_key) {
286
- console.log(chalk.green(' LLM key') +
287
- ' already configured in ' +
297
+ console.log('');
298
+ console.log(chalk.green('Semantic search') +
299
+ ' — LLM key configured in ' +
288
300
  chalk.dim(configPath));
289
301
  return;
290
302
  }
303
+ // No key found — explain what semantic search is and prompt
304
+ console.log('');
305
+ console.log(chalk.bold('Semantic search'));
306
+ console.log('');
307
+ console.log(' lat.md includes semantic search (' +
308
+ chalk.cyan('lat search') +
309
+ ') that lets agents find');
310
+ console.log(' relevant documentation by meaning, not just keywords. This requires an');
311
+ console.log(' embedding API key (OpenAI or Vercel AI Gateway). Without it, agents can still');
312
+ console.log(' use ' +
313
+ chalk.cyan('lat locate') +
314
+ ' for exact lookups, but will miss semantic matches.');
315
+ console.log('');
291
316
  // Interactive prompt
292
317
  if (!rl) {
293
318
  console.log(chalk.yellow(' No LLM key found.') +
@@ -382,26 +407,27 @@ export async function initCmd(targetDir) {
382
407
  }
383
408
  console.log('');
384
409
  const template = readAgentsTemplate();
410
+ const fileHashes = {};
385
411
  // Step 3: AGENTS.md (shared by non-Claude agents)
386
412
  const needsAgentsMd = useCursor || useCopilot || useCodex;
387
413
  if (needsAgentsMd) {
388
- setupAgentsMd(root, template);
414
+ await setupAgentsMd(root, latDir, template, fileHashes, ask);
389
415
  }
390
416
  // Step 4: Per-agent setup
391
417
  if (useClaudeCode) {
392
418
  console.log('');
393
419
  console.log(chalk.bold('Setting up Claude Code...'));
394
- await setupClaudeCode(root, template);
420
+ await setupClaudeCode(root, latDir, template, fileHashes, ask);
395
421
  }
396
422
  if (useCursor) {
397
423
  console.log('');
398
424
  console.log(chalk.bold('Setting up Cursor...'));
399
- await setupCursor(root);
425
+ await setupCursor(root, latDir, fileHashes, ask);
400
426
  }
401
427
  if (useCopilot) {
402
428
  console.log('');
403
429
  console.log(chalk.bold('Setting up VS Code Copilot...'));
404
- await setupCopilot(root);
430
+ await setupCopilot(root, latDir, fileHashes, ask);
405
431
  }
406
432
  if (useCodex) {
407
433
  console.log('');
@@ -410,6 +436,8 @@ export async function initCmd(targetDir) {
410
436
  }
411
437
  // Step 5: LLM key setup
412
438
  await setupLlmKey(rl);
439
+ // Record init version and file hashes so `lat check` can detect stale setups
440
+ writeInitMeta(latDir, fileHashes);
413
441
  console.log('');
414
442
  console.log(chalk.green('Done!') +
415
443
  ' Run ' +
@@ -1,2 +1,2 @@
1
- import type { CliContext } from './context.js';
2
- export declare function locateCmd(ctx: CliContext, query: string): Promise<void>;
1
+ import type { CmdContext, CmdResult } from '../context.js';
2
+ export declare function locateCommand(ctx: CmdContext, query: string): Promise<CmdResult>;
@@ -1,12 +1,17 @@
1
1
  import { loadAllSections, findSections } from '../lattice.js';
2
2
  import { formatResultList } from '../format.js';
3
- export async function locateCmd(ctx, query) {
3
+ export async function locateCommand(ctx, query) {
4
4
  const stripped = query.replace(/^\[\[|\]\]$/g, '');
5
5
  const sections = await loadAllSections(ctx.latDir);
6
6
  const matches = findSections(sections, stripped);
7
7
  if (matches.length === 0) {
8
- console.error(ctx.chalk.red(`No sections matching "${stripped}" (no exact, substring, or fuzzy matches)`));
9
- process.exit(1);
8
+ const s = ctx.styler;
9
+ return {
10
+ output: s.red(`No sections matching "${stripped}" (no exact, substring, or fuzzy matches)`),
11
+ isError: true,
12
+ };
10
13
  }
11
- console.log(formatResultList(`Sections matching "${stripped}":`, matches, ctx.latDir));
14
+ return {
15
+ output: formatResultList(ctx, `Sections matching "${stripped}":`, matches),
16
+ };
12
17
  }
@@ -1,4 +1,20 @@
1
- import type { CliContext } from './context.js';
2
- type Scope = 'md' | 'code' | 'md+code';
3
- export declare function refsCmd(ctx: CliContext, query: string, scope: Scope): Promise<void>;
4
- export {};
1
+ import { type Section, type SectionMatch } from '../lattice.js';
2
+ import type { CmdContext, CmdResult } from '../context.js';
3
+ export type Scope = 'md' | 'code' | 'md+code';
4
+ export type RefsFound = {
5
+ kind: 'found';
6
+ target: Section;
7
+ mdRefs: SectionMatch[];
8
+ codeRefs: string[];
9
+ };
10
+ export type RefsError = {
11
+ kind: 'no-match';
12
+ suggestions: SectionMatch[];
13
+ };
14
+ export type RefsResult = RefsFound | RefsError;
15
+ /**
16
+ * Find all sections and code locations that reference a given section.
17
+ * Accepts any valid section id (full-path, short-form, with or without brackets).
18
+ */
19
+ export declare function findRefs(ctx: CmdContext, query: string, scope: Scope): Promise<RefsResult>;
20
+ export declare function refsCommand(ctx: CmdContext, query: string, scope: Scope): Promise<CmdResult>;
@@ -1,45 +1,44 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
2
  import { listLatticeFiles, loadAllSections, findSections, extractRefs, flattenSections, buildFileIndex, resolveRef, } from '../lattice.js';
4
3
  import { formatResultList } from '../format.js';
5
4
  import { scanCodeRefs } from '../code-refs.js';
6
- export async function refsCmd(ctx, query, scope) {
5
+ /**
6
+ * Find all sections and code locations that reference a given section.
7
+ * Accepts any valid section id (full-path, short-form, with or without brackets).
8
+ */
9
+ export async function findRefs(ctx, query, scope) {
10
+ query = query.replace(/^\[\[|\]\]$/g, '');
7
11
  const allSections = await loadAllSections(ctx.latDir);
8
- const matches = findSections(allSections, query);
9
- if (matches.length === 0) {
10
- console.error(ctx.chalk.red(`No section matching "${query}" (no exact, substring, or fuzzy matches)`));
11
- process.exit(1);
12
- }
13
- // Resolve short refs and require exact match
14
12
  const flat = flattenSections(allSections);
15
13
  const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
16
14
  const fileIndex = buildFileIndex(allSections);
17
15
  const { resolved } = resolveRef(query, sectionIds, fileIndex);
18
16
  const q = resolved.toLowerCase();
19
- const exactMatch = flat.find((s) => s.id.toLowerCase() === q);
20
- if (!exactMatch) {
21
- console.error(ctx.chalk.red(`No section "${query}" found.`));
22
- if (matches.length > 0) {
23
- console.error(ctx.chalk.dim('\nDid you mean:\n'));
24
- for (const m of matches) {
25
- console.error(ctx.chalk.dim('*') +
26
- ' ' +
27
- ctx.chalk.white(m.section.id) +
28
- ' ' +
29
- ctx.chalk.dim(`(${m.reason})`));
30
- }
17
+ let exactMatch = flat.find((s) => s.id.toLowerCase() === q);
18
+ // If resolveRef didn't land on an exact id, use findSections as fallback
19
+ const matches = !exactMatch ? findSections(allSections, query) : [];
20
+ if (!exactMatch && matches.length >= 1) {
21
+ const top = matches[0];
22
+ const isConfident = top.reason === 'exact match' ||
23
+ top.reason.startsWith('file stem expanded') ||
24
+ top.reason === 'section name match';
25
+ if (isConfident) {
26
+ exactMatch = top.section;
31
27
  }
32
- process.exit(1);
28
+ }
29
+ if (!exactMatch) {
30
+ const suggestions = matches.length > 0 ? matches : findSections(allSections, query);
31
+ return { kind: 'no-match', suggestions };
33
32
  }
34
33
  const targetId = exactMatch.id.toLowerCase();
35
- const mdMatches = [];
36
- const codeLines = [];
34
+ const mdRefs = [];
35
+ const codeRefs = [];
37
36
  if (scope === 'md' || scope === 'md+code') {
38
37
  const files = await listLatticeFiles(ctx.latDir);
39
38
  const matchingFromSections = new Set();
40
39
  for (const file of files) {
41
40
  const content = await readFile(file, 'utf-8');
42
- const fileRefs = extractRefs(file, content, ctx.latDir);
41
+ const fileRefs = extractRefs(file, content, ctx.projectRoot);
43
42
  for (const ref of fileRefs) {
44
43
  const { resolved: refResolved } = resolveRef(ref.target, sectionIds, fileIndex);
45
44
  if (refResolved.toLowerCase() === targetId) {
@@ -50,34 +49,57 @@ export async function refsCmd(ctx, query, scope) {
50
49
  if (matchingFromSections.size > 0) {
51
50
  const referrers = flat.filter((s) => matchingFromSections.has(s.id.toLowerCase()));
52
51
  for (const s of referrers) {
53
- mdMatches.push({ section: s, reason: 'wiki link' });
52
+ mdRefs.push({ section: s, reason: 'wiki link' });
54
53
  }
55
54
  }
56
55
  }
57
56
  if (scope === 'code' || scope === 'md+code') {
58
- const projectRoot = join(ctx.latDir, '..');
59
- const { refs: codeRefs } = await scanCodeRefs(projectRoot);
60
- for (const ref of codeRefs) {
57
+ const { refs: scannedRefs } = await scanCodeRefs(ctx.projectRoot);
58
+ for (const ref of scannedRefs) {
61
59
  const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
62
60
  if (codeResolved.toLowerCase() === targetId) {
63
- codeLines.push(`${ref.file}:${ref.line}`);
61
+ codeRefs.push(`${ref.file}:${ref.line}`);
64
62
  }
65
63
  }
66
64
  }
67
- if (mdMatches.length === 0 && codeLines.length === 0) {
68
- console.error(ctx.chalk.red(`No references to "${exactMatch.id}" found`));
69
- process.exit(1);
65
+ return { kind: 'found', target: exactMatch, mdRefs, codeRefs };
66
+ }
67
+ export async function refsCommand(ctx, query, scope) {
68
+ const result = await findRefs(ctx, query, scope);
69
+ if (result.kind === 'no-match') {
70
+ const s = ctx.styler;
71
+ if (result.suggestions.length > 0) {
72
+ const suggestions = result.suggestions
73
+ .map((m) => ` ${s.dim('*')} ${s.white(m.section.id)} ${s.dim(`(${m.reason})`)}`)
74
+ .join('\n');
75
+ return {
76
+ output: s.red(`No section "${query}" found.`) +
77
+ ' Did you mean:\n' +
78
+ suggestions,
79
+ isError: true,
80
+ };
81
+ }
82
+ return {
83
+ output: s.red(`No section matching "${query}"`),
84
+ isError: true,
85
+ };
70
86
  }
71
- if (mdMatches.length > 0) {
72
- console.log(formatResultList(`References to "${exactMatch.id}":`, mdMatches, ctx.latDir));
87
+ const { target, mdRefs, codeRefs } = result;
88
+ if (mdRefs.length === 0 && codeRefs.length === 0) {
89
+ return {
90
+ output: ctx.styler.yellow(`No references to "${target.id}" found`),
91
+ isError: true,
92
+ };
73
93
  }
74
- if (codeLines.length > 0) {
75
- if (mdMatches.length > 0)
76
- console.log('');
77
- console.log(ctx.chalk.bold('Code references:'));
78
- console.log('');
79
- for (const line of codeLines) {
80
- console.log(`${ctx.chalk.dim('*')} ${line}`);
81
- }
94
+ const s = ctx.styler;
95
+ const parts = [];
96
+ if (mdRefs.length > 0) {
97
+ parts.push(formatResultList(ctx, `References to "${target.id}":`, mdRefs));
98
+ }
99
+ if (codeRefs.length > 0) {
100
+ parts.push(s.bold('Code references:') +
101
+ '\n\n' +
102
+ codeRefs.map((l) => `${s.dim('*')} ${l}`).join('\n'));
82
103
  }
104
+ return { output: parts.join('\n') };
83
105
  }
@@ -1,5 +1,27 @@
1
- import type { CliContext } from './context.js';
2
- export declare function searchCmd(ctx: CliContext, query: string | undefined, opts: {
1
+ import type { CmdContext, CmdResult, Styler } from '../context.js';
2
+ import { type IndexStats } from '../search/index.js';
3
+ import { type SectionMatch } from '../lattice.js';
4
+ export type SearchResult = {
5
+ query: string;
6
+ matches: SectionMatch[];
7
+ };
8
+ export type IndexProgress = {
9
+ /** Called before indexing starts. `isEmpty` is true on first run. */
10
+ beforeIndex?: (isEmpty: boolean) => void;
11
+ /** Called after indexing completes with stats. */
12
+ afterIndex?: (stats: IndexStats, isEmpty: boolean) => void;
13
+ };
14
+ /**
15
+ * Run a semantic search across lat.md sections.
16
+ * Handles indexing (with optional progress callback). Returns matched sections.
17
+ */
18
+ export declare function runSearch(latDir: string, query: string, key: string, limit: number, progress?: IndexProgress): Promise<SearchResult>;
19
+ /**
20
+ * Index-only mode (no query). Used by `lat search --reindex`.
21
+ */
22
+ export declare function runIndex(latDir: string, key: string, progress?: IndexProgress): Promise<void>;
23
+ export declare function cliProgress(reindex: boolean, s: Styler): IndexProgress;
24
+ export declare function searchCommand(ctx: CmdContext, query: string | undefined, opts: {
3
25
  limit: number;
4
26
  reindex?: boolean;
5
- }): Promise<void>;
27
+ }, progress?: IndexProgress): Promise<CmdResult>;