lat.md 0.3.0 → 0.4.1
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/README.md +10 -1
- package/dist/src/cli/check.js +15 -0
- package/dist/src/cli/gen.d.ts +1 -0
- package/dist/src/cli/gen.js +3 -0
- package/dist/src/cli/index.js +17 -1
- package/dist/src/cli/init.js +339 -52
- package/dist/src/cli/search.js +15 -2
- package/dist/src/config.d.ts +17 -0
- package/dist/src/config.js +65 -0
- package/dist/src/mcp/server.d.ts +1 -0
- package/dist/src/mcp/server.js +310 -0
- package/dist/src/search/provider.js +2 -2
- package/package.json +5 -2
- package/templates/AGENTS.md +1 -1
- package/templates/cursor-rules.md +71 -0
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ npm install -g lat.md
|
|
|
29
29
|
|
|
30
30
|
Or use directly with `npx lat.md@latest <command>`.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
After installing, run `lat init` in the repo you want to use lat in.
|
|
33
33
|
|
|
34
34
|
## How it works
|
|
35
35
|
|
|
@@ -58,6 +58,15 @@ npx lat.md search "how do we auth?" # semantic search via embeddings
|
|
|
58
58
|
npx lat.md prompt "fix [[OAuth Flow]]" # expand [[refs]] in a prompt for agents
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
Semantic search (`lat search`) requires an OpenAI (`sk-...`) or Vercel AI Gateway (`vck_...`) API key. The key is resolved in order:
|
|
64
|
+
|
|
65
|
+
1. `LAT_LLM_KEY` env var — direct value
|
|
66
|
+
2. `LAT_LLM_KEY_FILE` env var — path to a file containing the key
|
|
67
|
+
3. `LAT_LLM_KEY_HELPER` env var — shell command that prints the key (10s timeout)
|
|
68
|
+
4. Config file — saved by `lat init`. Run `lat config` to see its location.
|
|
69
|
+
|
|
61
70
|
## Development
|
|
62
71
|
|
|
63
72
|
Requires Node.js 22+ and pnpm.
|
package/dist/src/cli/check.js
CHANGED
|
@@ -288,4 +288,19 @@ export async function checkAllCmd(ctx) {
|
|
|
288
288
|
if (totalErrors > 0)
|
|
289
289
|
process.exit(1);
|
|
290
290
|
console.log(ctx.chalk.green('All checks passed'));
|
|
291
|
+
const { getLlmKey } = await import('../config.js');
|
|
292
|
+
let hasKey = false;
|
|
293
|
+
try {
|
|
294
|
+
hasKey = !!getLlmKey();
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// key resolution failed (e.g. empty file) — treat as missing
|
|
298
|
+
}
|
|
299
|
+
if (!hasKey) {
|
|
300
|
+
console.log(ctx.chalk.yellow('Warning:') +
|
|
301
|
+
' No LLM key found — semantic search (lat search) will not work.' +
|
|
302
|
+
' Provide a key via LAT_LLM_KEY, LAT_LLM_KEY_FILE, LAT_LLM_KEY_HELPER, or run ' +
|
|
303
|
+
ctx.chalk.cyan('lat init') +
|
|
304
|
+
' to configure.');
|
|
305
|
+
}
|
|
291
306
|
}
|
package/dist/src/cli/gen.d.ts
CHANGED
package/dist/src/cli/gen.js
CHANGED
|
@@ -4,6 +4,9 @@ import { findTemplatesDir } from './templates.js';
|
|
|
4
4
|
export function readAgentsTemplate() {
|
|
5
5
|
return readFileSync(join(findTemplatesDir(), 'AGENTS.md'), 'utf-8');
|
|
6
6
|
}
|
|
7
|
+
export function readCursorRulesTemplate() {
|
|
8
|
+
return readFileSync(join(findTemplatesDir(), 'cursor-rules.md'), 'utf-8');
|
|
9
|
+
}
|
|
7
10
|
export async function genCmd(target) {
|
|
8
11
|
const normalized = target.toLowerCase();
|
|
9
12
|
if (normalized !== 'agents.md' && normalized !== 'claude.md') {
|
package/dist/src/cli/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
if (!process.argv.includes('--verbose')) {
|
|
4
4
|
process.noDeprecation = true;
|
|
5
5
|
}
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { Command } from 'commander';
|
|
@@ -138,4 +138,20 @@ program
|
|
|
138
138
|
const { initCmd } = await import('./init.js');
|
|
139
139
|
await initCmd(dir);
|
|
140
140
|
});
|
|
141
|
+
program
|
|
142
|
+
.command('mcp')
|
|
143
|
+
.description('Start the MCP server (stdio transport)')
|
|
144
|
+
.action(async () => {
|
|
145
|
+
const { startMcpServer } = await import('../mcp/server.js');
|
|
146
|
+
await startMcpServer();
|
|
147
|
+
});
|
|
148
|
+
program
|
|
149
|
+
.command('config')
|
|
150
|
+
.description('Show configuration file path')
|
|
151
|
+
.action(async () => {
|
|
152
|
+
const { getConfigPath } = await import('../config.js');
|
|
153
|
+
const configPath = getConfigPath();
|
|
154
|
+
const exists = existsSync(configPath);
|
|
155
|
+
console.log(`Config file: ${configPath}${exists ? '' : ' (not found)'}`);
|
|
156
|
+
});
|
|
141
157
|
await program.parseAsync();
|
package/dist/src/cli/init.js
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
|
-
import { existsSync, cpSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, chmodSync,
|
|
1
|
+
import { existsSync, cpSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, chmodSync, } 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
|
-
import { readAgentsTemplate } from './gen.js';
|
|
6
|
+
import { readAgentsTemplate, readCursorRulesTemplate } from './gen.js';
|
|
7
|
+
import { getConfigPath, readConfig, writeConfig, } from '../config.js';
|
|
7
8
|
async function confirm(rl, message) {
|
|
8
9
|
try {
|
|
9
10
|
const answer = await rl.question(`${message} ${chalk.dim('[Y/n]')} `);
|
|
10
11
|
return answer.trim().toLowerCase() !== 'n';
|
|
11
12
|
}
|
|
12
13
|
catch {
|
|
13
|
-
|
|
14
|
+
// Ctrl+C or closed stdin — abort
|
|
15
|
+
console.log('');
|
|
16
|
+
process.exit(130);
|
|
14
17
|
}
|
|
15
18
|
}
|
|
19
|
+
async function prompt(rl, message) {
|
|
20
|
+
try {
|
|
21
|
+
const answer = await rl.question(message);
|
|
22
|
+
return answer.trim();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
console.log('');
|
|
26
|
+
process.exit(130);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// ── Claude Code helpers ──────────────────────────────────────────────
|
|
16
30
|
const HOOK_COMMAND = '.claude/hooks/lat-prompt-hook.sh';
|
|
17
|
-
/**
|
|
18
|
-
* Check if .claude/settings.json already has the lat-prompt hook configured.
|
|
19
|
-
*/
|
|
20
31
|
function hasLatHook(settingsPath) {
|
|
21
32
|
if (!existsSync(settingsPath))
|
|
22
33
|
return false;
|
|
@@ -31,17 +42,15 @@ function hasLatHook(settingsPath) {
|
|
|
31
42
|
return false;
|
|
32
43
|
}
|
|
33
44
|
}
|
|
34
|
-
/**
|
|
35
|
-
* Add the lat-prompt hook to .claude/settings.json, preserving existing config.
|
|
36
|
-
*/
|
|
37
45
|
function addLatHook(settingsPath) {
|
|
38
46
|
let settings = {};
|
|
39
47
|
if (existsSync(settingsPath)) {
|
|
48
|
+
const raw = readFileSync(settingsPath, 'utf-8');
|
|
40
49
|
try {
|
|
41
|
-
settings = JSON.parse(
|
|
50
|
+
settings = JSON.parse(raw);
|
|
42
51
|
}
|
|
43
|
-
catch {
|
|
44
|
-
|
|
52
|
+
catch (e) {
|
|
53
|
+
throw new Error(`Cannot parse ${settingsPath}: ${e.message}`);
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
@@ -56,6 +65,278 @@ function addLatHook(settingsPath) {
|
|
|
56
65
|
});
|
|
57
66
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
58
67
|
}
|
|
68
|
+
// ── Gitignore helper ─────────────────────────────────────────────────
|
|
69
|
+
function ensureGitignored(root, entry) {
|
|
70
|
+
const gitignorePath = join(root, '.gitignore');
|
|
71
|
+
const gitDir = join(root, '.git');
|
|
72
|
+
// Check if already ignored
|
|
73
|
+
if (existsSync(gitignorePath)) {
|
|
74
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
75
|
+
const lines = content.split('\n').map((l) => l.trim());
|
|
76
|
+
if (lines.includes(entry)) {
|
|
77
|
+
console.log(chalk.green(` ${entry}`) + ' already in .gitignore');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (existsSync(gitignorePath)) {
|
|
82
|
+
// Append to existing .gitignore
|
|
83
|
+
let content = readFileSync(gitignorePath, 'utf-8');
|
|
84
|
+
if (!content.endsWith('\n'))
|
|
85
|
+
content += '\n';
|
|
86
|
+
writeFileSync(gitignorePath, content + entry + '\n');
|
|
87
|
+
console.log(chalk.green(` Added ${entry}`) + ' to .gitignore');
|
|
88
|
+
}
|
|
89
|
+
else if (existsSync(gitDir)) {
|
|
90
|
+
// Create .gitignore with the entry
|
|
91
|
+
writeFileSync(gitignorePath, entry + '\n');
|
|
92
|
+
console.log(chalk.green(` Created .gitignore`) + ` with ${entry}`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.log(chalk.yellow(` Warning:`) +
|
|
96
|
+
` could not add ${entry} to .gitignore (not a git repository)`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// ── MCP command detection ────────────────────────────────────────────
|
|
100
|
+
/**
|
|
101
|
+
* Derive the MCP server command from the currently running binary.
|
|
102
|
+
* If `lat init` was invoked as `/path/to/lat`, we emit
|
|
103
|
+
* `{ command: "/path/to/lat", args: ["mcp"] }` so the MCP client
|
|
104
|
+
* starts the same binary.
|
|
105
|
+
*/
|
|
106
|
+
function mcpCommand() {
|
|
107
|
+
return { command: resolve(process.argv[1]), args: ['mcp'] };
|
|
108
|
+
}
|
|
109
|
+
function hasMcpServer(configPath) {
|
|
110
|
+
if (!existsSync(configPath))
|
|
111
|
+
return false;
|
|
112
|
+
try {
|
|
113
|
+
const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
114
|
+
return !!cfg?.mcpServers?.lat;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function addMcpServer(configPath) {
|
|
121
|
+
let cfg = { mcpServers: {} };
|
|
122
|
+
if (existsSync(configPath)) {
|
|
123
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
124
|
+
try {
|
|
125
|
+
cfg = JSON.parse(raw);
|
|
126
|
+
if (!cfg.mcpServers)
|
|
127
|
+
cfg.mcpServers = {};
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
throw new Error(`Cannot parse ${configPath}: ${e.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
cfg.mcpServers.lat = mcpCommand();
|
|
134
|
+
mkdirSync(join(configPath, '..'), { recursive: true });
|
|
135
|
+
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
136
|
+
}
|
|
137
|
+
// ── 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
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function setupClaudeCode(root, template) {
|
|
149
|
+
const created = [];
|
|
150
|
+
// 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
|
|
161
|
+
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.'));
|
|
164
|
+
const claudeDir = join(root, '.claude');
|
|
165
|
+
const hooksDir = join(claudeDir, 'hooks');
|
|
166
|
+
const hookPath = join(hooksDir, 'lat-prompt-hook.sh');
|
|
167
|
+
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
|
+
}
|
|
180
|
+
// MCP server → .mcp.json at project root
|
|
181
|
+
console.log('');
|
|
182
|
+
console.log(chalk.dim(' Agents can call `lat` from the command line, but an MCP server gives lat'));
|
|
183
|
+
console.log(chalk.dim(' more visibility and makes agents more likely to use it proactively.'));
|
|
184
|
+
const mcpPath = join(root, '.mcp.json');
|
|
185
|
+
if (hasMcpServer(mcpPath)) {
|
|
186
|
+
console.log(chalk.green(' MCP server') + ' already configured');
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
addMcpServer(mcpPath);
|
|
190
|
+
console.log(chalk.green(' MCP server') + ' registered in .mcp.json');
|
|
191
|
+
created.push('.mcp.json');
|
|
192
|
+
}
|
|
193
|
+
// Ensure .mcp.json is gitignored (it contains local absolute paths)
|
|
194
|
+
ensureGitignored(root, '.mcp.json');
|
|
195
|
+
return created;
|
|
196
|
+
}
|
|
197
|
+
async function setupCursor(root) {
|
|
198
|
+
const created = [];
|
|
199
|
+
// .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
|
+
}
|
|
211
|
+
// .cursor/mcp.json
|
|
212
|
+
console.log('');
|
|
213
|
+
console.log(chalk.dim(' Agents can call `lat` from the command line, but an MCP server gives lat'));
|
|
214
|
+
console.log(chalk.dim(' more visibility and makes agents more likely to use it proactively.'));
|
|
215
|
+
const mcpPath = join(root, '.cursor', 'mcp.json');
|
|
216
|
+
if (hasMcpServer(mcpPath)) {
|
|
217
|
+
console.log(chalk.green(' MCP server') + ' already configured');
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
addMcpServer(mcpPath);
|
|
221
|
+
console.log(chalk.green(' MCP server') + ' registered in .cursor/mcp.json');
|
|
222
|
+
created.push('.cursor/mcp.json');
|
|
223
|
+
}
|
|
224
|
+
// Ensure .cursor/mcp.json is gitignored (it contains local absolute paths)
|
|
225
|
+
ensureGitignored(root, '.cursor/mcp.json');
|
|
226
|
+
console.log('');
|
|
227
|
+
console.log(chalk.yellow(' Note:') +
|
|
228
|
+
' Enable MCP in Cursor: Settings → Features → MCP → check "Enable MCP"');
|
|
229
|
+
return created;
|
|
230
|
+
}
|
|
231
|
+
async function setupCopilot(root) {
|
|
232
|
+
const created = [];
|
|
233
|
+
// .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
|
+
}
|
|
246
|
+
// .vscode/mcp.json
|
|
247
|
+
console.log('');
|
|
248
|
+
console.log(chalk.dim(' Agents can call `lat` from the command line, but an MCP server gives lat'));
|
|
249
|
+
console.log(chalk.dim(' more visibility and makes agents more likely to use it proactively.'));
|
|
250
|
+
const mcpPath = join(root, '.vscode', 'mcp.json');
|
|
251
|
+
if (hasMcpServer(mcpPath)) {
|
|
252
|
+
console.log(chalk.green(' MCP server') + ' already configured');
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
addMcpServer(mcpPath);
|
|
256
|
+
console.log(chalk.green(' MCP server') + ' registered in .vscode/mcp.json');
|
|
257
|
+
created.push('.vscode/mcp.json');
|
|
258
|
+
}
|
|
259
|
+
return created;
|
|
260
|
+
}
|
|
261
|
+
// ── LLM key setup ───────────────────────────────────────────────────
|
|
262
|
+
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
|
+
// Check env var first
|
|
276
|
+
const envKey = process.env.LAT_LLM_KEY;
|
|
277
|
+
if (envKey) {
|
|
278
|
+
console.log(chalk.green(' LAT_LLM_KEY') +
|
|
279
|
+
' is set in your environment. Semantic search is ready.');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Check existing config
|
|
283
|
+
const config = readConfig();
|
|
284
|
+
const configPath = getConfigPath();
|
|
285
|
+
if (config.llm_key) {
|
|
286
|
+
console.log(chalk.green(' LLM key') +
|
|
287
|
+
' already configured in ' +
|
|
288
|
+
chalk.dim(configPath));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Interactive prompt
|
|
292
|
+
if (!rl) {
|
|
293
|
+
console.log(chalk.yellow(' No LLM key found.') +
|
|
294
|
+
' Set LAT_LLM_KEY env var or run ' +
|
|
295
|
+
chalk.cyan('lat init') +
|
|
296
|
+
' interactively.');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
console.log(' You can provide a key now, or skip and set ' +
|
|
300
|
+
chalk.cyan('LAT_LLM_KEY') +
|
|
301
|
+
' env var later.');
|
|
302
|
+
console.log(' Supported: OpenAI (' +
|
|
303
|
+
chalk.dim('sk-...') +
|
|
304
|
+
') or Vercel AI Gateway (' +
|
|
305
|
+
chalk.dim('vck_...') +
|
|
306
|
+
')');
|
|
307
|
+
console.log('');
|
|
308
|
+
const key = await prompt(rl, ` Paste your key (or press Enter to skip): `);
|
|
309
|
+
if (!key) {
|
|
310
|
+
console.log(chalk.dim(' Skipped.') +
|
|
311
|
+
' You can set ' +
|
|
312
|
+
chalk.cyan('LAT_LLM_KEY') +
|
|
313
|
+
' later or re-run ' +
|
|
314
|
+
chalk.cyan('lat init') +
|
|
315
|
+
'.');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// Validate prefix
|
|
319
|
+
if (key.startsWith('sk-ant-')) {
|
|
320
|
+
console.log(chalk.red(' That looks like an Anthropic key.') +
|
|
321
|
+
" Anthropic doesn't offer embeddings.");
|
|
322
|
+
console.log(' lat.md needs an OpenAI (' +
|
|
323
|
+
chalk.dim('sk-...') +
|
|
324
|
+
') or Vercel AI Gateway (' +
|
|
325
|
+
chalk.dim('vck_...') +
|
|
326
|
+
') key.');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!key.startsWith('sk-') && !key.startsWith('vck_')) {
|
|
330
|
+
console.log(chalk.yellow(' Unrecognized key prefix.') +
|
|
331
|
+
' Expected sk-... (OpenAI) or vck_... (Vercel AI Gateway).');
|
|
332
|
+
console.log(' Saving anyway — you can update it later.');
|
|
333
|
+
}
|
|
334
|
+
// Save to config
|
|
335
|
+
const updatedConfig = { ...config, llm_key: key };
|
|
336
|
+
writeConfig(updatedConfig);
|
|
337
|
+
console.log(chalk.green(' Key saved') + ' to ' + chalk.dim(configPath));
|
|
338
|
+
}
|
|
339
|
+
// ── Main init flow ───────────────────────────────────────────────────
|
|
59
340
|
export async function initCmd(targetDir) {
|
|
60
341
|
const root = resolve(targetDir ?? process.cwd());
|
|
61
342
|
const latDir = join(root, 'lat.md');
|
|
@@ -83,51 +364,57 @@ export async function initCmd(targetDir) {
|
|
|
83
364
|
cpSync(templateDir, latDir, { recursive: true });
|
|
84
365
|
console.log(chalk.green('Created lat.md/'));
|
|
85
366
|
}
|
|
86
|
-
// Step 2:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
367
|
+
// Step 2: Which coding agents do you use?
|
|
368
|
+
console.log('');
|
|
369
|
+
console.log(chalk.bold('Which coding agents do you use?'));
|
|
370
|
+
console.log('');
|
|
371
|
+
const useClaudeCode = await ask(' Claude Code?');
|
|
372
|
+
const useCursor = await ask(' Cursor?');
|
|
373
|
+
const useCopilot = await ask(' VS Code Copilot?');
|
|
374
|
+
const useCodex = await ask(' Codex / OpenCode?');
|
|
375
|
+
const anySelected = useClaudeCode || useCursor || useCopilot || useCodex;
|
|
376
|
+
if (!anySelected) {
|
|
377
|
+
console.log('');
|
|
378
|
+
console.log(chalk.dim('No agents selected. You can re-run') +
|
|
379
|
+
' lat init ' +
|
|
380
|
+
chalk.dim('later.'));
|
|
381
|
+
return;
|
|
98
382
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
383
|
+
console.log('');
|
|
384
|
+
const template = readAgentsTemplate();
|
|
385
|
+
// Step 3: AGENTS.md (shared by non-Claude agents)
|
|
386
|
+
const needsAgentsMd = useCursor || useCopilot || useCodex;
|
|
387
|
+
if (needsAgentsMd) {
|
|
388
|
+
setupAgentsMd(root, template);
|
|
105
389
|
}
|
|
106
|
-
// Step
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (hasLatHook(settingsPath)) {
|
|
112
|
-
console.log(chalk.green('Claude Code hook') + ' already configured');
|
|
390
|
+
// Step 4: Per-agent setup
|
|
391
|
+
if (useClaudeCode) {
|
|
392
|
+
console.log('');
|
|
393
|
+
console.log(chalk.bold('Setting up Claude Code...'));
|
|
394
|
+
await setupClaudeCode(root, template);
|
|
113
395
|
}
|
|
114
|
-
|
|
396
|
+
if (useCursor) {
|
|
115
397
|
console.log('');
|
|
116
|
-
console.log(chalk.bold('
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
' with UserPromptSubmit hook');
|
|
129
|
-
}
|
|
398
|
+
console.log(chalk.bold('Setting up Cursor...'));
|
|
399
|
+
await setupCursor(root);
|
|
400
|
+
}
|
|
401
|
+
if (useCopilot) {
|
|
402
|
+
console.log('');
|
|
403
|
+
console.log(chalk.bold('Setting up VS Code Copilot...'));
|
|
404
|
+
await setupCopilot(root);
|
|
405
|
+
}
|
|
406
|
+
if (useCodex) {
|
|
407
|
+
console.log('');
|
|
408
|
+
console.log(chalk.bold('Codex / OpenCode') +
|
|
409
|
+
' — uses AGENTS.md (already created). No additional setup needed.');
|
|
130
410
|
}
|
|
411
|
+
// Step 5: LLM key setup
|
|
412
|
+
await setupLlmKey(rl);
|
|
413
|
+
console.log('');
|
|
414
|
+
console.log(chalk.green('Done!') +
|
|
415
|
+
' Run ' +
|
|
416
|
+
chalk.cyan('lat check') +
|
|
417
|
+
' to validate your setup.');
|
|
131
418
|
}
|
|
132
419
|
finally {
|
|
133
420
|
rl?.close();
|
package/dist/src/cli/search.js
CHANGED
|
@@ -5,10 +5,23 @@ import { indexSections } from '../search/index.js';
|
|
|
5
5
|
import { searchSections } from '../search/search.js';
|
|
6
6
|
import { loadAllSections, flattenSections } from '../lattice.js';
|
|
7
7
|
import { formatResultList } from '../format.js';
|
|
8
|
+
import { getLlmKey, getConfigPath } from '../config.js';
|
|
8
9
|
export async function searchCmd(ctx, query, opts) {
|
|
9
|
-
|
|
10
|
+
let key;
|
|
11
|
+
try {
|
|
12
|
+
key = getLlmKey();
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
console.error(chalk.red(err.message));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
10
18
|
if (!key) {
|
|
11
|
-
console.error(chalk.red('
|
|
19
|
+
console.error(chalk.red('No API key configured.') +
|
|
20
|
+
' Provide a key via LAT_LLM_KEY, LAT_LLM_KEY_FILE, LAT_LLM_KEY_HELPER, or run ' +
|
|
21
|
+
chalk.cyan('lat init') +
|
|
22
|
+
' to save one in ' +
|
|
23
|
+
chalk.dim(getConfigPath()) +
|
|
24
|
+
'.');
|
|
12
25
|
process.exit(1);
|
|
13
26
|
}
|
|
14
27
|
const provider = detectProvider(key);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare function getConfigDir(): string;
|
|
2
|
+
export declare function getConfigPath(): string;
|
|
3
|
+
export type LatConfig = {
|
|
4
|
+
llm_key?: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function readConfig(): LatConfig;
|
|
7
|
+
export declare function writeConfig(config: LatConfig): void;
|
|
8
|
+
/**
|
|
9
|
+
* Returns the LLM key from (in priority order):
|
|
10
|
+
* 1. LAT_LLM_KEY environment variable
|
|
11
|
+
* 2. LAT_LLM_KEY_FILE — path to a file containing the key
|
|
12
|
+
* 3. LAT_LLM_KEY_HELPER — shell command that prints the key
|
|
13
|
+
* 4. llm_key field in ~/.config/lat/config.json
|
|
14
|
+
*
|
|
15
|
+
* Returns undefined if none is set.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getLlmKey(): string | undefined;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import xdg from '@folder/xdg';
|
|
5
|
+
// ── XDG config directory ────────────────────────────────────────────
|
|
6
|
+
export function getConfigDir() {
|
|
7
|
+
return join(xdg().config, 'lat');
|
|
8
|
+
}
|
|
9
|
+
export function getConfigPath() {
|
|
10
|
+
return join(getConfigDir(), 'config.json');
|
|
11
|
+
}
|
|
12
|
+
export function readConfig() {
|
|
13
|
+
const configPath = getConfigPath();
|
|
14
|
+
if (!existsSync(configPath))
|
|
15
|
+
return {};
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function writeConfig(config) {
|
|
24
|
+
const dir = getConfigDir();
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n');
|
|
27
|
+
}
|
|
28
|
+
// ── Centralized LLM key resolution ─────────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* Returns the LLM key from (in priority order):
|
|
31
|
+
* 1. LAT_LLM_KEY environment variable
|
|
32
|
+
* 2. LAT_LLM_KEY_FILE — path to a file containing the key
|
|
33
|
+
* 3. LAT_LLM_KEY_HELPER — shell command that prints the key
|
|
34
|
+
* 4. llm_key field in ~/.config/lat/config.json
|
|
35
|
+
*
|
|
36
|
+
* Returns undefined if none is set.
|
|
37
|
+
*/
|
|
38
|
+
export function getLlmKey() {
|
|
39
|
+
const envKey = process.env.LAT_LLM_KEY;
|
|
40
|
+
if (envKey)
|
|
41
|
+
return envKey;
|
|
42
|
+
const file = process.env.LAT_LLM_KEY_FILE;
|
|
43
|
+
if (file) {
|
|
44
|
+
const content = readFileSync(file, 'utf-8').trim();
|
|
45
|
+
if (!content) {
|
|
46
|
+
throw new Error(`LAT_LLM_KEY_FILE (${file}) is empty.`);
|
|
47
|
+
}
|
|
48
|
+
return content;
|
|
49
|
+
}
|
|
50
|
+
const helper = process.env.LAT_LLM_KEY_HELPER;
|
|
51
|
+
if (helper) {
|
|
52
|
+
const result = execSync(helper, {
|
|
53
|
+
encoding: 'utf-8',
|
|
54
|
+
timeout: 10_000,
|
|
55
|
+
}).trim();
|
|
56
|
+
if (!result) {
|
|
57
|
+
throw new Error('LAT_LLM_KEY_HELPER command returned an empty string.');
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
const config = readConfig();
|
|
62
|
+
if (config.llm_key)
|
|
63
|
+
return config.llm_key;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startMcpServer(): Promise<void>;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { relative } from 'node:path';
|
|
5
|
+
import { findLatticeDir, loadAllSections, findSections, flattenSections, buildFileIndex, resolveRef, extractRefs, listLatticeFiles, } from '../lattice.js';
|
|
6
|
+
import { scanCodeRefs } from '../code-refs.js';
|
|
7
|
+
import { checkMd, checkCodeRefs, checkIndex } from '../cli/check.js';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
function formatSection(s, latDir) {
|
|
11
|
+
const relPath = relative(process.cwd(), latDir + '/' + s.file + '.md');
|
|
12
|
+
const kind = s.id.includes('#') ? 'Section' : 'File';
|
|
13
|
+
const lines = [
|
|
14
|
+
`* ${kind}: [[${s.id}]]`,
|
|
15
|
+
` Defined in ${relPath}:${s.startLine}-${s.endLine}`,
|
|
16
|
+
];
|
|
17
|
+
if (s.body) {
|
|
18
|
+
const truncated = s.body.length > 200 ? s.body.slice(0, 200) + '...' : s.body;
|
|
19
|
+
lines.push('', ` > ${truncated}`);
|
|
20
|
+
}
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
function formatMatches(header, matches, latDir) {
|
|
24
|
+
const lines = [header, ''];
|
|
25
|
+
for (let i = 0; i < matches.length; i++) {
|
|
26
|
+
if (i > 0)
|
|
27
|
+
lines.push('');
|
|
28
|
+
lines.push(formatSection(matches[i].section, latDir) + ` (${matches[i].reason})`);
|
|
29
|
+
}
|
|
30
|
+
return lines.join('\n');
|
|
31
|
+
}
|
|
32
|
+
export async function startMcpServer() {
|
|
33
|
+
const latDir = findLatticeDir();
|
|
34
|
+
if (!latDir) {
|
|
35
|
+
process.stderr.write('No lat.md directory found\n');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const server = new McpServer({
|
|
39
|
+
name: 'lat',
|
|
40
|
+
version: '1.0.0',
|
|
41
|
+
});
|
|
42
|
+
server.tool('lat_locate', 'Find sections by name (exact, fuzzy, subsequence matching)', { query: z.string().describe('Section name or id to search for') }, async ({ query }) => {
|
|
43
|
+
const sections = await loadAllSections(latDir);
|
|
44
|
+
const matches = findSections(sections, query.replace(/^\[\[|\]\]$/g, ''));
|
|
45
|
+
if (matches.length === 0) {
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: 'text',
|
|
50
|
+
text: `No sections matching "${query}"`,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: 'text',
|
|
59
|
+
text: formatMatches(`Sections matching "${query}":`, matches, latDir),
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
server.tool('lat_search', 'Semantic search across lat.md sections using embeddings', {
|
|
65
|
+
query: z.string().describe('Search query in natural language'),
|
|
66
|
+
limit: z
|
|
67
|
+
.number()
|
|
68
|
+
.optional()
|
|
69
|
+
.default(5)
|
|
70
|
+
.describe('Max results (default 5)'),
|
|
71
|
+
}, async ({ query, limit }) => {
|
|
72
|
+
const { getLlmKey } = await import('../config.js');
|
|
73
|
+
let key;
|
|
74
|
+
try {
|
|
75
|
+
key = getLlmKey();
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: 'text', text: err.message }],
|
|
80
|
+
isError: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!key) {
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: 'No LLM key found. Provide a key via LAT_LLM_KEY, LAT_LLM_KEY_FILE, LAT_LLM_KEY_HELPER, or run `lat init`.',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
isError: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const { detectProvider } = await import('../search/provider.js');
|
|
95
|
+
const { openDb, ensureSchema, closeDb } = await import('../search/db.js');
|
|
96
|
+
const { indexSections } = await import('../search/index.js');
|
|
97
|
+
const { searchSections } = await import('../search/search.js');
|
|
98
|
+
const provider = detectProvider(key);
|
|
99
|
+
const db = openDb(latDir);
|
|
100
|
+
try {
|
|
101
|
+
await ensureSchema(db, provider.dimensions);
|
|
102
|
+
await indexSections(latDir, db, provider, key);
|
|
103
|
+
const results = await searchSections(db, query, provider, key, limit);
|
|
104
|
+
if (results.length === 0) {
|
|
105
|
+
return {
|
|
106
|
+
content: [{ type: 'text', text: 'No results found.' }],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const allSections = await loadAllSections(latDir);
|
|
110
|
+
const flat = flattenSections(allSections);
|
|
111
|
+
const byId = new Map(flat.map((s) => [s.id, s]));
|
|
112
|
+
const matched = results
|
|
113
|
+
.map((r) => byId.get(r.id))
|
|
114
|
+
.filter((s) => !!s)
|
|
115
|
+
.map((s) => ({ section: s, reason: 'semantic match' }));
|
|
116
|
+
return {
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
type: 'text',
|
|
120
|
+
text: formatMatches(`Search results for "${query}":`, matched, latDir),
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await closeDb(db);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
server.tool('lat_prompt', 'Expand [[refs]] in text to resolved lat.md section paths with context', { text: z.string().describe('Text containing [[refs]] to expand') }, async ({ text }) => {
|
|
130
|
+
const WIKI_LINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
131
|
+
const allSections = await loadAllSections(latDir);
|
|
132
|
+
const refs = [...text.matchAll(WIKI_LINK_RE)];
|
|
133
|
+
if (refs.length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
content: [{ type: 'text', text }],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const resolved = new Map();
|
|
139
|
+
const errors = [];
|
|
140
|
+
for (const match of refs) {
|
|
141
|
+
const target = match[1];
|
|
142
|
+
if (resolved.has(target))
|
|
143
|
+
continue;
|
|
144
|
+
const matches = findSections(allSections, target);
|
|
145
|
+
if (matches.length >= 1) {
|
|
146
|
+
resolved.set(target, {
|
|
147
|
+
target,
|
|
148
|
+
best: matches[0],
|
|
149
|
+
alternatives: matches.slice(1),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
errors.push(`No section found for [[${target}]]`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (errors.length > 0) {
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: 'text', text: errors.join('\n') }],
|
|
159
|
+
isError: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
let output = text.replace(WIKI_LINK_RE, (_match, target) => {
|
|
163
|
+
const ref = resolved.get(target);
|
|
164
|
+
return `[[${ref.best.section.id}]]`;
|
|
165
|
+
});
|
|
166
|
+
output += '\n\n<lat-context>\n';
|
|
167
|
+
for (const ref of resolved.values()) {
|
|
168
|
+
const isExact = ref.best.reason === 'exact match';
|
|
169
|
+
const all = isExact ? [ref.best] : [ref.best, ...ref.alternatives];
|
|
170
|
+
if (isExact) {
|
|
171
|
+
output += `* [[${ref.target}]] is referring to:\n`;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
output += `* [[${ref.target}]] might be referring to either of the following:\n`;
|
|
175
|
+
}
|
|
176
|
+
for (const m of all) {
|
|
177
|
+
const reason = isExact ? '' : ` (${m.reason})`;
|
|
178
|
+
const relPath = relative(process.cwd(), latDir + '/' + m.section.file + '.md');
|
|
179
|
+
output += ` * [[${m.section.id}]]${reason}\n`;
|
|
180
|
+
output += ` * ${relPath}:${m.section.startLine}-${m.section.endLine}\n`;
|
|
181
|
+
if (m.section.body) {
|
|
182
|
+
output += ` * ${m.section.body}\n`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
output += '</lat-context>\n';
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: 'text', text: output }],
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
server.tool('lat_check', 'Validate all wiki links, code references, and directory indexes in lat.md', {}, async () => {
|
|
192
|
+
const md = await checkMd(latDir);
|
|
193
|
+
const code = await checkCodeRefs(latDir);
|
|
194
|
+
const indexErrors = await checkIndex(latDir);
|
|
195
|
+
const allErrors = [...md.errors, ...code.errors];
|
|
196
|
+
const lines = [];
|
|
197
|
+
for (const err of allErrors) {
|
|
198
|
+
lines.push(`${err.file}:${err.line}: ${err.message}`);
|
|
199
|
+
}
|
|
200
|
+
for (const err of indexErrors) {
|
|
201
|
+
lines.push(`${err.dir}: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
const totalErrors = allErrors.length + indexErrors.length;
|
|
204
|
+
if (totalErrors === 0) {
|
|
205
|
+
return {
|
|
206
|
+
content: [{ type: 'text', text: 'All checks passed' }],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
lines.push(`\n${totalErrors} error${totalErrors === 1 ? '' : 's'} found`);
|
|
210
|
+
return {
|
|
211
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
212
|
+
isError: true,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
server.tool('lat_refs', 'Find sections that reference a given section via wiki links or @lat code comments', {
|
|
216
|
+
query: z.string().describe('Section id to find references for'),
|
|
217
|
+
scope: z
|
|
218
|
+
.enum(['md', 'code', 'md+code'])
|
|
219
|
+
.optional()
|
|
220
|
+
.default('md')
|
|
221
|
+
.describe('Where to search: md, code, or md+code'),
|
|
222
|
+
}, async ({ query, scope }) => {
|
|
223
|
+
const allSections = await loadAllSections(latDir);
|
|
224
|
+
const flat = flattenSections(allSections);
|
|
225
|
+
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
226
|
+
const fileIndex = buildFileIndex(allSections);
|
|
227
|
+
const { resolved } = resolveRef(query, sectionIds, fileIndex);
|
|
228
|
+
const q = resolved.toLowerCase();
|
|
229
|
+
const exactMatch = flat.find((s) => s.id.toLowerCase() === q);
|
|
230
|
+
if (!exactMatch) {
|
|
231
|
+
const matches = findSections(allSections, query);
|
|
232
|
+
if (matches.length > 0) {
|
|
233
|
+
const suggestions = matches
|
|
234
|
+
.map((m) => ` * ${m.section.id} (${m.reason})`)
|
|
235
|
+
.join('\n');
|
|
236
|
+
return {
|
|
237
|
+
content: [
|
|
238
|
+
{
|
|
239
|
+
type: 'text',
|
|
240
|
+
text: `No exact section "${query}" found. Did you mean:\n${suggestions}`,
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
content: [
|
|
247
|
+
{
|
|
248
|
+
type: 'text',
|
|
249
|
+
text: `No section matching "${query}"`,
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const targetId = exactMatch.id.toLowerCase();
|
|
255
|
+
const mdMatches = [];
|
|
256
|
+
const codeLines = [];
|
|
257
|
+
if (scope === 'md' || scope === 'md+code') {
|
|
258
|
+
const files = await listLatticeFiles(latDir);
|
|
259
|
+
const matchingFromSections = new Set();
|
|
260
|
+
for (const file of files) {
|
|
261
|
+
const content = await readFile(file, 'utf-8');
|
|
262
|
+
const fileRefs = extractRefs(file, content, latDir);
|
|
263
|
+
for (const ref of fileRefs) {
|
|
264
|
+
const { resolved: refResolved } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
265
|
+
if (refResolved.toLowerCase() === targetId) {
|
|
266
|
+
matchingFromSections.add(ref.fromSection.toLowerCase());
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (matchingFromSections.size > 0) {
|
|
271
|
+
const referrers = flat.filter((s) => matchingFromSections.has(s.id.toLowerCase()));
|
|
272
|
+
for (const s of referrers) {
|
|
273
|
+
mdMatches.push({ section: s, reason: 'wiki link' });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (scope === 'code' || scope === 'md+code') {
|
|
278
|
+
const projectRoot = join(latDir, '..');
|
|
279
|
+
const { refs: codeRefs } = await scanCodeRefs(projectRoot);
|
|
280
|
+
for (const ref of codeRefs) {
|
|
281
|
+
const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
282
|
+
if (codeResolved.toLowerCase() === targetId) {
|
|
283
|
+
codeLines.push(`${ref.file}:${ref.line}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (mdMatches.length === 0 && codeLines.length === 0) {
|
|
288
|
+
return {
|
|
289
|
+
content: [
|
|
290
|
+
{
|
|
291
|
+
type: 'text',
|
|
292
|
+
text: `No references to "${exactMatch.id}" found`,
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const parts = [];
|
|
298
|
+
if (mdMatches.length > 0) {
|
|
299
|
+
parts.push(formatMatches(`References to "${exactMatch.id}":`, mdMatches, latDir));
|
|
300
|
+
}
|
|
301
|
+
if (codeLines.length > 0) {
|
|
302
|
+
parts.push('Code references:\n' + codeLines.map((l) => `* ${l}`).join('\n'));
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
content: [{ type: 'text', text: parts.join('\n\n') }],
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
const transport = new StdioServerTransport();
|
|
309
|
+
await server.connect(transport);
|
|
310
|
+
}
|
|
@@ -30,11 +30,11 @@ export function detectProvider(key) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
if (key.startsWith('sk-ant-')) {
|
|
33
|
-
throw new Error("Anthropic doesn't offer an embedding model. Set LAT_LLM_KEY to an OpenAI (sk-...) or Vercel AI (vck_...) key.");
|
|
33
|
+
throw new Error("Anthropic doesn't offer an embedding model. Set LAT_LLM_KEY to an OpenAI (sk-...) or Vercel AI Gateway (vck_...) key.");
|
|
34
34
|
}
|
|
35
35
|
if (key.startsWith('vck_'))
|
|
36
36
|
return vercel;
|
|
37
37
|
if (key.startsWith('sk-'))
|
|
38
38
|
return openai;
|
|
39
|
-
throw new Error(`Unrecognized LAT_LLM_KEY prefix. Supported: OpenAI (sk-...), Vercel AI (vck_...).`);
|
|
39
|
+
throw new Error(`Unrecognized LAT_LLM_KEY prefix. Supported: OpenAI (sk-...), Vercel AI Gateway (vck_...).`);
|
|
40
40
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lat.md",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "A knowledge graph for your codebase, written in markdown",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.30.2",
|
|
@@ -47,7 +47,9 @@
|
|
|
47
47
|
"vitest": "^3.0.0"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
+
"@folder/xdg": "^4.0.1",
|
|
50
51
|
"@libsql/client": "^0.17.0",
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
51
53
|
"chalk": "^5.6.2",
|
|
52
54
|
"commander": "^14.0.3",
|
|
53
55
|
"ignore-walk": "^8.0.0",
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
"remark-parse": "^11.0.0",
|
|
56
58
|
"remark-stringify": "^11.0.0",
|
|
57
59
|
"unified": "^11.0.0",
|
|
58
|
-
"unist-util-visit": "^5.0.0"
|
|
60
|
+
"unist-util-visit": "^5.0.0",
|
|
61
|
+
"zod": "^4.3.6"
|
|
59
62
|
}
|
|
60
63
|
}
|
package/templates/AGENTS.md
CHANGED
|
@@ -29,7 +29,7 @@ lat check # validate all links and code refs
|
|
|
29
29
|
|
|
30
30
|
Run `lat --help` when in doubt about available commands or options.
|
|
31
31
|
|
|
32
|
-
If `lat search` fails because
|
|
32
|
+
If `lat search` fails because no API key is configured, explain to the user that semantic search requires a key provided via `LAT_LLM_KEY` (direct value), `LAT_LLM_KEY_FILE` (path to key file), or `LAT_LLM_KEY_HELPER` (command that prints the key). Supported key prefixes: `sk-...` (OpenAI) or `vck_...` (Vercel). If the user doesn't want to set it up, use `lat locate` for direct lookups instead.
|
|
33
33
|
|
|
34
34
|
# Syntax primer
|
|
35
35
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Before starting work
|
|
2
|
+
|
|
3
|
+
- Use the `lat_search` tool to find sections relevant to your task. Read them to understand the design intent before writing code.
|
|
4
|
+
- Use the `lat_prompt` tool on user prompts to expand any `[[refs]]` — this resolves section names to file locations and provides context.
|
|
5
|
+
|
|
6
|
+
# Post-task checklist (REQUIRED — do not skip)
|
|
7
|
+
|
|
8
|
+
After EVERY task, before responding to the user:
|
|
9
|
+
|
|
10
|
+
- [ ] Update `lat.md/` if you added or changed any functionality, architecture, tests, or behavior
|
|
11
|
+
- [ ] Use the `lat_check` tool — all wiki links and code refs must pass
|
|
12
|
+
- [ ] Do not skip these steps. Do not consider your task done until both are complete.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# What is lat.md?
|
|
17
|
+
|
|
18
|
+
This project uses [lat.md](https://www.npmjs.com/package/lat.md) to maintain a structured knowledge graph of its architecture, design decisions, and test specs in the `lat.md/` directory. It is a set of cross-linked markdown files that describe **what** this project does and **why** — the domain concepts, key design decisions, business logic, and test specifications. Use it to ground your work in the actual architecture rather than guessing.
|
|
19
|
+
|
|
20
|
+
# Tools
|
|
21
|
+
|
|
22
|
+
You have access to the following MCP tools from the `lat` server:
|
|
23
|
+
|
|
24
|
+
- **lat_locate** — find a section by name (exact, fuzzy)
|
|
25
|
+
- **lat_search** — semantic search across all sections
|
|
26
|
+
- **lat_prompt** — expand `[[refs]]` in text to resolved locations
|
|
27
|
+
- **lat_check** — validate all wiki links and code refs
|
|
28
|
+
- **lat_refs** — find what references a section
|
|
29
|
+
|
|
30
|
+
If `lat_search` fails because `LAT_LLM_KEY` is not set, explain to the user that semantic search requires an API key (`export LAT_LLM_KEY=sk-...` for OpenAI or `export LAT_LLM_KEY=vck_...` for Vercel). If the user doesn't want to set it up, use `lat_locate` for direct lookups instead.
|
|
31
|
+
|
|
32
|
+
# Syntax primer
|
|
33
|
+
|
|
34
|
+
- **Section ids**: `path/to/file#Heading#SubHeading` — full form uses vault-relative path (e.g. `tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`).
|
|
35
|
+
- **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections
|
|
36
|
+
- **Code refs**: `// @lat: [[section-id]]` (JS/TS) or `# @lat: [[section-id]]` (Python) — ties source code to concepts
|
|
37
|
+
|
|
38
|
+
# Test specs
|
|
39
|
+
|
|
40
|
+
Key tests can be described as sections in `lat.md/` files (e.g. `tests.md`). Add frontmatter to require that every leaf section is referenced by a `// @lat:` or `# @lat:` comment in test code:
|
|
41
|
+
|
|
42
|
+
```markdown
|
|
43
|
+
---
|
|
44
|
+
lat:
|
|
45
|
+
require-code-mention: true
|
|
46
|
+
---
|
|
47
|
+
# Tests
|
|
48
|
+
|
|
49
|
+
## User login
|
|
50
|
+
### Rejects expired tokens
|
|
51
|
+
Tokens past their expiry timestamp are rejected with 401, even if otherwise valid.
|
|
52
|
+
|
|
53
|
+
### Handles missing password
|
|
54
|
+
Login request without a password field returns 400 with a descriptive error.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Every section MUST have a description — at least one sentence explaining what the test verifies and why. Empty sections with just a heading are not acceptable.
|
|
58
|
+
|
|
59
|
+
Each test in code should reference its spec with exactly one comment placed next to the relevant test — not at the top of the file:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
# @lat: [[tests#User login#Rejects expired tokens]]
|
|
63
|
+
def test_rejects_expired_tokens():
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
# @lat: [[tests#User login#Handles missing password]]
|
|
67
|
+
def test_handles_missing_password():
|
|
68
|
+
...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Do not duplicate refs. One `@lat:` comment per spec section, placed at the test that covers it. `lat check` will flag any spec section not covered by a code reference, and any code reference pointing to a nonexistent section.
|