@llmkb/claude-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/dist/cli.js +3214 -0
- package/dist/cli.js.map +1 -0
- package/lib/color.ts +61 -0
- package/lib/config-validation.ts +332 -0
- package/lib/config.ts +61 -0
- package/lib/credentials.ts +164 -0
- package/lib/output.ts +130 -0
- package/lib/parser.ts +274 -0
- package/lib/skills.ts +554 -0
- package/lib/sync-spaces-config.ts +180 -0
- package/lib/sync-state.ts +152 -0
- package/lib/sync.ts +437 -0
- package/lib/types.ts +153 -0
- package/lib/watch-lock.ts +78 -0
- package/lib/writer.ts +409 -0
- package/package.json +55 -0
package/lib/sync.ts
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/** File sync engine for ``llmkb sync``.
|
|
2
|
+
|
|
3
|
+
Walks a directory, applies ``.gitignore`` + ``.llmkbignore`` filtering,
|
|
4
|
+
computes SHA256 hashes, uploads new/changed files via TUS, and detects
|
|
5
|
+
renames by content identity.
|
|
6
|
+
|
|
7
|
+
Batch ingestion: Creates one ``IngestionJob`` upfront, uploads all files
|
|
8
|
+
into it via ``/complete?postpone_ingestion=true&ingestion_job_id=``, then
|
|
9
|
+
prints the job URL for the user to track progress in the UI.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdir, stat as fsStat, readFile } from "node:fs/promises";
|
|
13
|
+
import { join, relative, resolve } from "node:path";
|
|
14
|
+
import ignore from "ignore";
|
|
15
|
+
import * as tus from "tus-js-client";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { getConfig } from "./config.js";
|
|
18
|
+
import { getToken } from "./credentials.js";
|
|
19
|
+
import { readSpaceConfig } from "./parser.js";
|
|
20
|
+
import { computeSha256 } from "./sync-state.js";
|
|
21
|
+
import { findProjectRoot } from "./parser.js";
|
|
22
|
+
import type { SyncState, FileChangeSet } from "./types.js";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Constants
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const IGNORE_FILES = [".gitignore", ".llmkbignore"] as const;
|
|
29
|
+
|
|
30
|
+
/** Default patterns always skipped (binary, hidden dirs, large artifacts, test artifacts).
|
|
31
|
+
*
|
|
32
|
+
* Python-side mirror at ``api/app/mcp/tools/entity_filter.py`` in the llmkb-ai repo.
|
|
33
|
+
* Keep both lists in sync. */
|
|
34
|
+
const DEFAULT_SKIP_PATTERNS = [
|
|
35
|
+
"node_modules/**",
|
|
36
|
+
".git/**",
|
|
37
|
+
".llmkb/**",
|
|
38
|
+
".claude/**",
|
|
39
|
+
".cursor/**",
|
|
40
|
+
".next/**",
|
|
41
|
+
"dist/**",
|
|
42
|
+
".venv/**",
|
|
43
|
+
"__pycache__/**",
|
|
44
|
+
"*.pyc",
|
|
45
|
+
"*.exe",
|
|
46
|
+
"*.dll",
|
|
47
|
+
"*.so",
|
|
48
|
+
"*.dylib",
|
|
49
|
+
"*.bin",
|
|
50
|
+
// Test & ephemeral artifacts — exclude by default to avoid polluting
|
|
51
|
+
// the knowledge graph with test fixtures, snapshots, and build artifacts.
|
|
52
|
+
"**/__tests__/**",
|
|
53
|
+
"**/__snapshots__/**",
|
|
54
|
+
"**/test/**",
|
|
55
|
+
"**/tests/**",
|
|
56
|
+
"**/*.test.*",
|
|
57
|
+
"**/*.spec.*",
|
|
58
|
+
"**/fixtures/**",
|
|
59
|
+
"**/coverage/**",
|
|
60
|
+
"*.log",
|
|
61
|
+
"tmp/**",
|
|
62
|
+
"temp/**",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// File Walking
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export interface WalkOptions {
|
|
70
|
+
/** Project root directory (for ignore file lookup). */
|
|
71
|
+
projectDir: string;
|
|
72
|
+
/** Target directory to walk (defaults to projectDir). */
|
|
73
|
+
targetDir?: string;
|
|
74
|
+
/** Whether to respect .gitignore. */
|
|
75
|
+
respectGitignore?: boolean;
|
|
76
|
+
/** Whether to respect .llmkbignore. */
|
|
77
|
+
respectLlmkbignore?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface WalkResult {
|
|
81
|
+
files: Array<[string, string]>; // [relativePath, absolutePath]
|
|
82
|
+
skipped: Array<{ path: string; reason: string }>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build an ignore filter from .gitignore, .llmkbignore, and default patterns.
|
|
86
|
+
|
|
87
|
+
Reads ignore files from the project root first, then from the target
|
|
88
|
+
directory — patterns from either source are applied.
|
|
89
|
+
*/
|
|
90
|
+
async function buildFilter(
|
|
91
|
+
projectDir: string,
|
|
92
|
+
targetDir: string,
|
|
93
|
+
respectGitignore: boolean,
|
|
94
|
+
respectLlmkbignore: boolean,
|
|
95
|
+
): Promise<(path: string) => boolean> {
|
|
96
|
+
const ig = ignore().add(DEFAULT_SKIP_PATTERNS);
|
|
97
|
+
|
|
98
|
+
const searchDirs = [projectDir, targetDir];
|
|
99
|
+
const seen = new Set<string>();
|
|
100
|
+
|
|
101
|
+
for (const baseDir of searchDirs) {
|
|
102
|
+
for (const fileName of IGNORE_FILES) {
|
|
103
|
+
const shouldRespect =
|
|
104
|
+
fileName === ".gitignore" ? respectGitignore : respectLlmkbignore;
|
|
105
|
+
if (!shouldRespect) continue;
|
|
106
|
+
|
|
107
|
+
const filePath = join(baseDir, fileName);
|
|
108
|
+
if (seen.has(filePath)) continue;
|
|
109
|
+
seen.add(filePath);
|
|
110
|
+
|
|
111
|
+
if (existsSync(filePath)) {
|
|
112
|
+
// Skip directories that collide with ignore-file names
|
|
113
|
+
const st = await fsStat(filePath);
|
|
114
|
+
if (!st.isFile()) continue;
|
|
115
|
+
const content = await readFile(filePath, "utf-8");
|
|
116
|
+
ig.add(content);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (testPath: string) => ig.ignores(testPath);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Recursively walk a directory, returning files filtered by ignore rules. */
|
|
125
|
+
export async function walkFiles(
|
|
126
|
+
opts: WalkOptions,
|
|
127
|
+
): Promise<WalkResult> {
|
|
128
|
+
const projectDir = resolve(opts.projectDir);
|
|
129
|
+
const targetDir = resolve(opts.targetDir ?? projectDir);
|
|
130
|
+
const respectGitignore = opts.respectGitignore ?? true;
|
|
131
|
+
const respectLlmkbignore = opts.respectLlmkbignore ?? true;
|
|
132
|
+
|
|
133
|
+
const isIgnored = await buildFilter(projectDir, targetDir, respectGitignore, respectLlmkbignore);
|
|
134
|
+
const files: Array<[string, string]> = [];
|
|
135
|
+
const skipped: Array<{ path: string; reason: string }> = [];
|
|
136
|
+
|
|
137
|
+
async function walk(dir: string): Promise<void> {
|
|
138
|
+
let entries;
|
|
139
|
+
try {
|
|
140
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
141
|
+
} catch {
|
|
142
|
+
skipped.push({ path: relative(targetDir, dir), reason: "unreadable" });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
const fullPath = join(dir, entry.name);
|
|
148
|
+
const relPath = relative(targetDir, fullPath);
|
|
149
|
+
|
|
150
|
+
if (entry.name.startsWith(".") && !IGNORE_FILES.includes(entry.name as typeof IGNORE_FILES[number])) {
|
|
151
|
+
skipped.push({ path: relPath, reason: "hidden" });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (isIgnored(relPath)) {
|
|
156
|
+
skipped.push({ path: relPath, reason: "ignored" });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (entry.isDirectory()) {
|
|
161
|
+
// Recurse into directories
|
|
162
|
+
await walk(fullPath);
|
|
163
|
+
} else if (entry.isFile()) {
|
|
164
|
+
files.push([relPath, fullPath]);
|
|
165
|
+
} else if (entry.isSymbolicLink()) {
|
|
166
|
+
// Follow symlinks — stat to verify it's a file, not a dir symlink
|
|
167
|
+
try {
|
|
168
|
+
const st = await fsStat(fullPath);
|
|
169
|
+
if (st.isFile()) {
|
|
170
|
+
files.push([relPath, fullPath]);
|
|
171
|
+
} else {
|
|
172
|
+
skipped.push({ path: relPath, reason: "symlink-to-dir" });
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
skipped.push({ path: relPath, reason: "broken-symlink" });
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
skipped.push({ path: relPath, reason: "not-a-file" });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await walk(targetDir);
|
|
184
|
+
return { files, skipped };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// TUS Upload
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
export interface UploadOptions {
|
|
192
|
+
/** Target space name. */
|
|
193
|
+
space: string;
|
|
194
|
+
/** Relative path of the file within the space. */
|
|
195
|
+
relativePath: string;
|
|
196
|
+
/** Absolute path to the file on disk. */
|
|
197
|
+
absolutePath: string;
|
|
198
|
+
/** llmkb API endpoint. */
|
|
199
|
+
endpoint: string;
|
|
200
|
+
/** Auth token. */
|
|
201
|
+
token: string;
|
|
202
|
+
/** Upload progress callback. */
|
|
203
|
+
onProgress?: (bytesSent: number, bytesTotal: number) => void;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Upload a single file via TUS protocol (chunks only, no finalization). Returns the upload URL. */
|
|
207
|
+
export async function tusUpload(
|
|
208
|
+
opts: UploadOptions,
|
|
209
|
+
): Promise<string> {
|
|
210
|
+
// Safety check: verify it's a file, not a directory
|
|
211
|
+
const st = await fsStat(opts.absolutePath);
|
|
212
|
+
if (!st.isFile()) {
|
|
213
|
+
throw new Error(`Not a file: ${opts.relativePath}`);
|
|
214
|
+
}
|
|
215
|
+
const fileBuffer = await readFile(opts.absolutePath);
|
|
216
|
+
const fileName = opts.relativePath.split("/").pop() ?? opts.relativePath;
|
|
217
|
+
|
|
218
|
+
return new Promise<string>((resolve, reject) => {
|
|
219
|
+
const upload = new tus.Upload(
|
|
220
|
+
fileBuffer,
|
|
221
|
+
{
|
|
222
|
+
endpoint: `${opts.endpoint}/api/spaces/${opts.space}/uploads`,
|
|
223
|
+
metadata: {
|
|
224
|
+
filename: opts.relativePath,
|
|
225
|
+
filetype: "application/octet-stream",
|
|
226
|
+
},
|
|
227
|
+
headers: {
|
|
228
|
+
Authorization: `Bearer ${opts.token}`,
|
|
229
|
+
},
|
|
230
|
+
chunkSize: 5 * 1024 * 1024, // 5 MB chunks
|
|
231
|
+
retryDelays: [0, 1000, 3000, 5000],
|
|
232
|
+
removeFingerprintOnSuccess: true,
|
|
233
|
+
onError: (err: Error) => reject(err),
|
|
234
|
+
onProgress: (bytesSent: number, bytesTotal: number) => {
|
|
235
|
+
opts.onProgress?.(bytesSent, bytesTotal);
|
|
236
|
+
},
|
|
237
|
+
onSuccess: () => {
|
|
238
|
+
resolve(upload.url ?? "");
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
upload.start();
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Finalize a TUS upload by calling /complete. Returns the upload URL on success. */
|
|
247
|
+
export async function finalizeUpload(
|
|
248
|
+
uploadUrl: string,
|
|
249
|
+
token: string,
|
|
250
|
+
ingestionJobId?: string,
|
|
251
|
+
): Promise<string> {
|
|
252
|
+
const params = new URLSearchParams({ postpone_ingestion: "true" });
|
|
253
|
+
if (ingestionJobId) {
|
|
254
|
+
params.set("ingestion_job_id", ingestionJobId);
|
|
255
|
+
}
|
|
256
|
+
const completeUrl = `${uploadUrl}/complete?${params.toString()}`;
|
|
257
|
+
const res = await fetch(completeUrl, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: {
|
|
260
|
+
Authorization: `Bearer ${token}`,
|
|
261
|
+
"Tus-Resumable": "1.0.0",
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
if (!res.ok) {
|
|
265
|
+
const body = await res.text().catch(() => "");
|
|
266
|
+
const shortId = uploadUrl.split("/").pop() ?? "unknown";
|
|
267
|
+
throw new Error(`Upload completion failed for ${shortId} (${res.status}): ${body}`);
|
|
268
|
+
}
|
|
269
|
+
return uploadUrl;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Sync Engine
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
export interface SyncOptions {
|
|
277
|
+
/** Path to sync (directory or file). */
|
|
278
|
+
path: string;
|
|
279
|
+
/** Comma-separated list of skip reasons to ignore (for --dry-run filtering). */
|
|
280
|
+
skipReasons?: string[];
|
|
281
|
+
/** Callback for each file upload attempt. */
|
|
282
|
+
onFile?: (relPath: string, status: "uploading" | "skipped" | "renamed" | "unchanged" | "failed", detail?: string) => void;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface SyncResult {
|
|
286
|
+
uploaded: number;
|
|
287
|
+
skipped: number;
|
|
288
|
+
unchanged: number;
|
|
289
|
+
renamed: number;
|
|
290
|
+
errors: Array<{ path: string; error: string }>;
|
|
291
|
+
/** Ingestion job ID(s) after batch finalization. */
|
|
292
|
+
ingestionJobIds?: string[];
|
|
293
|
+
/** URL of the first ingestion job (for the sync command to display). */
|
|
294
|
+
ingestionJobUrl?: string;
|
|
295
|
+
/** Machine-readable outcome: "synced" | "no_files" | "up_to_date". */
|
|
296
|
+
status?: "synced" | "no_files" | "up_to_date";
|
|
297
|
+
/** Human-readable explanation when status is not "synced". */
|
|
298
|
+
reason?: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Run the full sync: walk -> compare -> upload -> update state. */
|
|
302
|
+
export async function runSync(
|
|
303
|
+
space: string,
|
|
304
|
+
syncState: SyncState | null,
|
|
305
|
+
opts: SyncOptions,
|
|
306
|
+
): Promise<SyncResult> {
|
|
307
|
+
const projectRoot = findProjectRoot();
|
|
308
|
+
if (!projectRoot) throw new Error("No .llmkb/ directory found. Run `llmkb init` first.");
|
|
309
|
+
|
|
310
|
+
const config = getConfig();
|
|
311
|
+
const endpoint = config.endpoint;
|
|
312
|
+
const token = await getToken();
|
|
313
|
+
|
|
314
|
+
const result: SyncResult = { uploaded: 0, skipped: 0, unchanged: 0, renamed: 0, errors: [] };
|
|
315
|
+
|
|
316
|
+
// Walk files
|
|
317
|
+
const walkResult = await walkFiles({
|
|
318
|
+
projectDir: projectRoot,
|
|
319
|
+
targetDir: resolve(opts.path),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
for (const s of walkResult.skipped) {
|
|
323
|
+
result.skipped++;
|
|
324
|
+
opts.onFile?.(s.path, "skipped", s.reason);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (walkResult.files.length === 0) {
|
|
328
|
+
result.status = "no_files";
|
|
329
|
+
const filteredCount = walkResult.skipped.length;
|
|
330
|
+
if (filteredCount > 0) {
|
|
331
|
+
result.reason = `No files to sync — all ${filteredCount} file(s) were filtered out by .gitignore / .llmkbignore rules.`;
|
|
332
|
+
} else {
|
|
333
|
+
result.reason = "No files found in sync target. The directory appears to be empty.";
|
|
334
|
+
}
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Classify files
|
|
339
|
+
const changes = await import("./sync-state.js").then((m) =>
|
|
340
|
+
m.getChangedFiles(walkResult.files, syncState),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// Process unchanged
|
|
344
|
+
for (const f of changes.unchangedFiles) {
|
|
345
|
+
result.unchanged++;
|
|
346
|
+
opts.onFile?.(f, "unchanged");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Process renamed
|
|
350
|
+
for (const r of changes.renamed) {
|
|
351
|
+
result.renamed++;
|
|
352
|
+
opts.onFile?.(r.newPath, "renamed", `${r.oldPath} → ${r.newPath}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Upload new + changed — collect upload URLs for batch finalization
|
|
356
|
+
const toUpload = [...changes.newFiles, ...changes.changedFiles];
|
|
357
|
+
if (toUpload.length === 0) {
|
|
358
|
+
result.status = "up_to_date";
|
|
359
|
+
result.reason = `No changes detected — all files are up to date (${walkResult.files.length} files, unchanged: ${result.unchanged}, renamed: ${result.renamed}, skipped: ${result.skipped}).`;
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
const uploadUrls: string[] = [];
|
|
363
|
+
const ingestionJobId: string | undefined = undefined;
|
|
364
|
+
// No pre-flight job creation — start_ingestion_batch on the backend
|
|
365
|
+
// handles job creation and document association. Passing undefined as
|
|
366
|
+
// ingestionJobId means /complete sets ingestion_job_id=NULL, and the
|
|
367
|
+
// batch endpoint picks up all pending docs regardless.
|
|
368
|
+
|
|
369
|
+
for (const relPath of toUpload) {
|
|
370
|
+
const absPath = join(resolve(opts.path), relPath);
|
|
371
|
+
|
|
372
|
+
if (!token) {
|
|
373
|
+
result.errors.push({ path: relPath, error: "No token found" });
|
|
374
|
+
opts.onFile?.(relPath, "failed", "no token");
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
const absEntry = walkResult.files.find(([, a]) => a === absPath);
|
|
380
|
+
const absPathResolved = absEntry?.[1] ?? absPath;
|
|
381
|
+
const url = await tusUpload({
|
|
382
|
+
space,
|
|
383
|
+
relativePath: relPath,
|
|
384
|
+
absolutePath: absPathResolved,
|
|
385
|
+
endpoint,
|
|
386
|
+
token,
|
|
387
|
+
});
|
|
388
|
+
uploadUrls.push(url);
|
|
389
|
+
result.uploaded++;
|
|
390
|
+
opts.onFile?.(relPath, "uploading");
|
|
391
|
+
} catch (err) {
|
|
392
|
+
result.errors.push({ path: relPath, error: (err as Error).message });
|
|
393
|
+
opts.onFile?.(relPath, "failed", (err as Error).message);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Finalize all uploads into the pre-created job
|
|
398
|
+
if (uploadUrls.length > 0) {
|
|
399
|
+
for (const url of uploadUrls) {
|
|
400
|
+
try {
|
|
401
|
+
await finalizeUpload(url, token!, ingestionJobId);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
const shortId = url.split("/").pop() ?? "unknown";
|
|
404
|
+
result.errors.push({ path: shortId, error: (err as Error).message });
|
|
405
|
+
opts.onFile?.(shortId, "failed", (err as Error).message);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Trigger the ingestion pipeline for all finalized documents
|
|
409
|
+
if (token) {
|
|
410
|
+
try {
|
|
411
|
+
const ingestRes = await fetch(
|
|
412
|
+
`${endpoint}/api/spaces/${space}/uploads/start-ingest`,
|
|
413
|
+
{
|
|
414
|
+
method: "POST",
|
|
415
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
if (ingestRes.ok) {
|
|
419
|
+
const ingestBody = (await ingestRes.json()) as {
|
|
420
|
+
data?: { attributes?: { job_id?: string; url?: string } };
|
|
421
|
+
};
|
|
422
|
+
// Use the real job URL from the batch endpoint (more accurate
|
|
423
|
+
// than the pre-flight placeholder URL).
|
|
424
|
+
const realUrl = ingestBody.data?.attributes?.url;
|
|
425
|
+
if (realUrl) {
|
|
426
|
+
result.ingestionJobUrl = realUrl;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
// Non-fatal — documents are stored, ingestion can be triggered manually
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
result.status = "synced";
|
|
436
|
+
return result;
|
|
437
|
+
}
|
package/lib/types.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Shared TypeScript interfaces for the llmkb plugin config model.
|
|
2
|
+
|
|
3
|
+
These types define the schema for the per-project configuration files
|
|
4
|
+
(``.llmkb/spaces.yml`` and ``.llmkb/config.yml``) used by all CLI commands,
|
|
5
|
+
the MCP server config, and the sync engine.
|
|
6
|
+
|
|
7
|
+
Conventions:
|
|
8
|
+
- All field names follow the YAML key naming (lower_snake_case).
|
|
9
|
+
- Optional fields are marked with ``?``.
|
|
10
|
+
- Tokens MUST NOT appear in any config file — they live in the OS keychain or env vars.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Space Definition (new PSM-4 format)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** A project space entry — the primary space for file sync. */
|
|
18
|
+
export interface ProjectSpaceDef {
|
|
19
|
+
/** Space UUID. */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Optional human-readable name. */
|
|
22
|
+
name?: string;
|
|
23
|
+
/** Optional slug for MCP tool names. */
|
|
24
|
+
slug?: string;
|
|
25
|
+
/** Optional directory paths to sync from this project. */
|
|
26
|
+
dirs?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A referenced space entry (additional spaces beyond project_space). */
|
|
30
|
+
export interface SpaceDef {
|
|
31
|
+
/** Space UUID. */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Optional human-readable name. */
|
|
34
|
+
name?: string;
|
|
35
|
+
/** Optional slug for MCP tool names. */
|
|
36
|
+
slug?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Full Space Config (``.llmkb/spaces.yml``)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export interface SpaceConfig {
|
|
44
|
+
/** The project's primary space (file sync target). */
|
|
45
|
+
project_space?: ProjectSpaceDef[];
|
|
46
|
+
/** Additional spaces this project has access to. */
|
|
47
|
+
spaces?: SpaceDef[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Plugin Behavior Config (``.llmkb/config.yml``)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export interface WatchSettings {
|
|
55
|
+
enabled?: boolean;
|
|
56
|
+
debounce_ms?: number;
|
|
57
|
+
gitignore?: boolean;
|
|
58
|
+
llmkbignore?: boolean;
|
|
59
|
+
default_verbosity?: "silent" | "verbose" | "debug";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SyncSettings {
|
|
63
|
+
concurrency?: number;
|
|
64
|
+
retry_attempts?: number;
|
|
65
|
+
retry_delay_ms?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface HookSettings {
|
|
69
|
+
timeout_ms?: number;
|
|
70
|
+
skip?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PluginConfig {
|
|
74
|
+
/** Backend API base URL (e.g. https://api.llmkb.ai). */
|
|
75
|
+
llmkb_base_url?: string;
|
|
76
|
+
watch?: WatchSettings;
|
|
77
|
+
sync?: SyncSettings;
|
|
78
|
+
hook?: HookSettings;
|
|
79
|
+
debug?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Version Stamp
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** The version file at ``.llmkb/.llmkb-version`` stores just the semver string. */
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Exported utilities
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export const PACKAGE_VERSION = "0.1.0";
|
|
93
|
+
|
|
94
|
+
/** Returns the default plugin behavior config used when ``config.yml`` is missing or all fields are commented. */
|
|
95
|
+
export function getDefaultPluginConfig(): PluginConfig {
|
|
96
|
+
return {
|
|
97
|
+
llmkb_base_url: "https://api.llmkb.ai",
|
|
98
|
+
watch: {
|
|
99
|
+
enabled: true,
|
|
100
|
+
debounce_ms: 300,
|
|
101
|
+
gitignore: true,
|
|
102
|
+
llmkbignore: true,
|
|
103
|
+
default_verbosity: "silent",
|
|
104
|
+
},
|
|
105
|
+
sync: {
|
|
106
|
+
concurrency: 5,
|
|
107
|
+
retry_attempts: 3,
|
|
108
|
+
retry_delay_ms: 1000,
|
|
109
|
+
},
|
|
110
|
+
hook: {
|
|
111
|
+
timeout_ms: 5000,
|
|
112
|
+
skip: false,
|
|
113
|
+
},
|
|
114
|
+
debug: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Token Types
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/** The only token type used by the plugin. */
|
|
123
|
+
export type TokenType = "access_token";
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Sync State Types
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export type SyncFileStatus = "synced" | "pending" | "failed";
|
|
130
|
+
|
|
131
|
+
export interface SyncFileEntry {
|
|
132
|
+
sha256: string;
|
|
133
|
+
lastUploaded: string | null;
|
|
134
|
+
status: SyncFileStatus;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface SyncState {
|
|
138
|
+
space: string;
|
|
139
|
+
lastSyncAt: string;
|
|
140
|
+
files: Record<string, SyncFileEntry>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface RenameEntry {
|
|
144
|
+
oldPath: string;
|
|
145
|
+
newPath: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface FileChangeSet {
|
|
149
|
+
newFiles: string[];
|
|
150
|
+
changedFiles: string[];
|
|
151
|
+
unchangedFiles: string[];
|
|
152
|
+
renamed: RenameEntry[];
|
|
153
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Advisory file lock for chokidar watch mode.
|
|
2
|
+
|
|
3
|
+
Prevents duplicate watchers in the same project. Uses a simple PID-file
|
|
4
|
+
pattern: writes the current PID to ``.llmkb/.watch.lock`` and checks
|
|
5
|
+
liveness of the owning process before granting the lock.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFile, readFile, unlink } from "node:fs/promises";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
const LOCK_FILE = ".llmkb/.watch.lock";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Acquire an advisory watch lock.
|
|
17
|
+
*
|
|
18
|
+
* Returns ``true`` if the lock was acquired (or if no project root was found).
|
|
19
|
+
* Returns ``false`` if another watcher holds the lock and its PID is still alive.
|
|
20
|
+
*/
|
|
21
|
+
export async function acquireWatchLock(projectRoot: string): Promise<boolean> {
|
|
22
|
+
if (!projectRoot) return true;
|
|
23
|
+
const lockPath = join(projectRoot, LOCK_FILE);
|
|
24
|
+
|
|
25
|
+
if (existsSync(lockPath)) {
|
|
26
|
+
try {
|
|
27
|
+
const content = await readFile(lockPath, "utf-8");
|
|
28
|
+
const pid = parseInt(content.trim(), 10);
|
|
29
|
+
|
|
30
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
31
|
+
try {
|
|
32
|
+
// Check if process is alive (ESRCH = dead, EPERM = alive but cross-user)
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
// Process is alive — lock is held
|
|
35
|
+
return false;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
// ESRCH means process is gone — we can take the lock
|
|
38
|
+
if ((e as NodeJS.ErrnoException).code !== "ESRCH") {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Unreadable or corrupt lock — allow override
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await writeFile(lockPath, String(process.pid), "utf-8");
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Release the advisory watch lock.
|
|
54
|
+
*/
|
|
55
|
+
export async function releaseWatchLock(projectRoot: string): Promise<void> {
|
|
56
|
+
if (!projectRoot) return;
|
|
57
|
+
const lockPath = join(projectRoot, LOCK_FILE);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Only unlink if we still own it
|
|
61
|
+
if (existsSync(lockPath)) {
|
|
62
|
+
const content = await readFile(lockPath, "utf-8");
|
|
63
|
+
if (content.trim() === String(process.pid)) {
|
|
64
|
+
await unlink(lockPath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Best-effort cleanup
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check whether a watch lock currently exists.
|
|
74
|
+
*/
|
|
75
|
+
export function hasWatchLock(projectRoot: string): boolean {
|
|
76
|
+
if (!projectRoot) return false;
|
|
77
|
+
return existsSync(join(projectRoot, LOCK_FILE));
|
|
78
|
+
}
|