@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.
@@ -0,0 +1,180 @@
1
+ /** Sync related spaces from the backend into ``.llmkb/spaces.yml``.
2
+
3
+ Reads the ``project_space`` ID from the local config, fetches all
4
+ related spaces from the backend ``space_relations`` endpoint, and
5
+ appends any that are not yet listed in the ``spaces`` array.
6
+
7
+ Called by:
8
+ - ``llmkb doctor`` (LD-1) — sync before checking space access
9
+ - ``llmkb sync`` (LS-4) — sync as the final post-sync step
10
+ - ``llmkb login`` (LL-2) — sync after discovering user spaces
11
+ */
12
+
13
+ import { readFile, writeFile } from "node:fs/promises";
14
+ import { existsSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { readSpaceConfig, addSpaceEntry, removeSpaceEntry, writeSpaceConfig } from "./parser.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Response shapes from the backend
20
+ // ---------------------------------------------------------------------------
21
+
22
+ interface RelationsResponse {
23
+ data?: Array<{
24
+ attributes?: {
25
+ relatedSpaceId?: string;
26
+ relatedSpaceName?: string;
27
+ relatedSpaceSlug?: string;
28
+ };
29
+ }>;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Public API
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface SyncSpacesResult {
37
+ added: number;
38
+ alreadyPresent: number;
39
+ removed: number;
40
+ }
41
+
42
+ /**
43
+ * Fetch all related spaces from the backend and sync them into
44
+ * ``.llmkb/spaces.yml``.
45
+ *
46
+ * 1. Adds any new related spaces not yet in the ``spaces`` list.
47
+ * 2. Removes any spaces that were previously synced but are no
48
+ * longer related (tracked via ``.llmkb/sync-relations.json``).
49
+ *
50
+ * Manually-added spaces (not added by this sync) are never removed.
51
+ */
52
+ export async function syncRelatedSpaces(
53
+ projectDir: string,
54
+ endpoint: string,
55
+ token: string,
56
+ ): Promise<SyncSpacesResult> {
57
+ const config = await readSpaceConfig(projectDir);
58
+ if (!config?.project_space?.length) {
59
+ return { added: 0, alreadyPresent: 0, removed: 0 };
60
+ }
61
+
62
+ const projectSpaceId = config.project_space[0]!.id;
63
+
64
+ // Fetch related spaces from the backend (authoritative list)
65
+ const relations = await fetchRelatedSpaces(endpoint, token, projectSpaceId);
66
+ const backendIds = new Set(relations.map((r) => r.id));
67
+
68
+ // Read previously synced relation IDs
69
+ const syncedIdsPath = join(projectDir, ".llmkb", "sync-relations.json");
70
+ const prevSyncedIds = await readSyncedIds(syncedIdsPath);
71
+
72
+ let added = 0;
73
+ let alreadyPresent = 0;
74
+ let removed = 0;
75
+
76
+ // Phase 0: Remove duplicate project_space entries from the spaces list
77
+ if (config.spaces) {
78
+ for (const s of config.spaces) {
79
+ if (s.id === projectSpaceId) {
80
+ await removeSpaceEntry(projectDir, s.id);
81
+ removed++;
82
+ }
83
+ }
84
+ }
85
+
86
+ // Phase 1: Add new relations
87
+ for (const rel of relations) {
88
+ if (rel.id === projectSpaceId) continue;
89
+
90
+ const existingIds = new Set([
91
+ ...(config.spaces ?? []).map((s) => s.id),
92
+ ...(config.project_space ?? []).map((s) => s.id),
93
+ ]);
94
+
95
+ if (existingIds.has(rel.id)) {
96
+ alreadyPresent++;
97
+ } else {
98
+ await addSpaceEntry(projectDir, rel.id, rel.name);
99
+ added++;
100
+ }
101
+ }
102
+
103
+ // Phase 2: Remove stale entries that were previously synced
104
+ // Only remove entries that OUR sync added (tracked in sync-relations.json),
105
+ // never manually-added spaces.
106
+ if (prevSyncedIds.size > 0 && config.spaces) {
107
+ for (const s of config.spaces) {
108
+ if (prevSyncedIds.has(s.id) && !backendIds.has(s.id)) {
109
+ await removeSpaceEntry(projectDir, s.id);
110
+ removed++;
111
+ }
112
+ }
113
+ }
114
+
115
+ // Update the synced IDs tracking file
116
+ await writeSyncedIds(syncedIdsPath, backendIds);
117
+
118
+ return { added, alreadyPresent, removed };
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Helpers
123
+ // ---------------------------------------------------------------------------
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Synced-IDs tracking
127
+ // ---------------------------------------------------------------------------
128
+
129
+ const SYNC_RELATIONS_FILE = "sync-relations.json";
130
+
131
+ async function readSyncedIds(filePath: string): Promise<Set<string>> {
132
+ try {
133
+ if (existsSync(filePath)) {
134
+ const raw = await readFile(filePath, "utf-8");
135
+ const parsed = JSON.parse(raw) as string[];
136
+ return new Set(parsed);
137
+ }
138
+ } catch {
139
+ // Corrupted file — start fresh
140
+ }
141
+ return new Set();
142
+ }
143
+
144
+ async function writeSyncedIds(
145
+ filePath: string,
146
+ ids: Set<string>,
147
+ ): Promise<void> {
148
+ await writeFile(filePath, JSON.stringify([...ids], null, 2) + "\n", "utf-8");
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Helpers
153
+ // ---------------------------------------------------------------------------
154
+
155
+ async function fetchRelatedSpaces(
156
+ endpoint: string,
157
+ token: string,
158
+ spaceId: string,
159
+ ): Promise<Array<{ id: string; name?: string }>> {
160
+ try {
161
+ const url = `${endpoint.replace(/\/+$/, "")}/api/spaces/${encodeURIComponent(spaceId)}/relations`;
162
+ const res = await fetch(url, {
163
+ headers: { Authorization: `Bearer ${token}` },
164
+ });
165
+
166
+ if (!res.ok) return [];
167
+
168
+ const body = (await res.json()) as RelationsResponse;
169
+ if (!body.data) return [];
170
+
171
+ return body.data
172
+ .filter((d) => d.attributes?.relatedSpaceId)
173
+ .map((d) => ({
174
+ id: d.attributes!.relatedSpaceId!,
175
+ name: d.attributes?.relatedSpaceName ?? undefined,
176
+ }));
177
+ } catch {
178
+ return [];
179
+ }
180
+ }
@@ -0,0 +1,152 @@
1
+ /** Content-addressed sync-state manager (.llmkb/sync-state.json).
2
+
3
+ Tracks file uploads by SHA256 hash, enabling incremental sync where
4
+ unchanged files skip server reprocessing entirely.
5
+
6
+ Pattern: read -> compute -> compare -> classify -> write
7
+ */
8
+
9
+ import { readFile, writeFile, unlink, mkdir } from "node:fs/promises";
10
+ import { existsSync } from "node:fs";
11
+ import { createHash } from "node:crypto";
12
+ import { createReadStream } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+ import type { SyncState, SyncFileEntry, FileChangeSet, RenameEntry } from "./types.js";
15
+
16
+ const SYNC_STATE_FILE = ".llmkb/sync-state.json";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Path helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ function statePath(projectDir: string): string {
23
+ return resolve(projectDir, SYNC_STATE_FILE);
24
+ }
25
+
26
+ function llmkbDir(projectDir: string): string {
27
+ return resolve(projectDir, ".llmkb");
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // SHA256 streaming computation (memory-safe for large files)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Compute SHA256 hash of a file using streaming reads (O(1) memory). */
35
+ export async function computeSha256(filePath: string): Promise<string> {
36
+ return new Promise<string>((resolve, reject) => {
37
+ const hash = createHash("sha256");
38
+ const stream = createReadStream(filePath);
39
+ stream.on("data", (chunk) => hash.update(chunk));
40
+ stream.on("end", () => resolve(hash.digest("hex")));
41
+ stream.on("error", reject);
42
+ });
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Read / Write
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /** Read the sync state file. Returns null if missing or malformed. */
50
+ export async function readSyncState(projectDir: string): Promise<SyncState | null> {
51
+ const filePath = statePath(projectDir);
52
+ if (!existsSync(filePath)) return null;
53
+ try {
54
+ const raw = await readFile(filePath, "utf-8");
55
+ return JSON.parse(raw) as SyncState;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** Write the sync state file. Creates .llmkb/ if missing. */
62
+ export async function writeSyncState(projectDir: string, state: SyncState): Promise<void> {
63
+ const dir = llmkbDir(projectDir);
64
+ if (!existsSync(dir)) {
65
+ await mkdir(dir, { recursive: true });
66
+ }
67
+ await writeFile(statePath(projectDir), JSON.stringify(state, null, 2) + "\n", "utf-8");
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Update helpers
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /** Update a single file entry in the sync state. */
75
+ export async function updateSyncEntry(
76
+ projectDir: string,
77
+ relativePath: string,
78
+ entry: SyncFileEntry,
79
+ ): Promise<void> {
80
+ const state = (await readSyncState(projectDir)) ?? {
81
+ space: "",
82
+ lastSyncAt: new Date().toISOString(),
83
+ files: {},
84
+ };
85
+ state.files[relativePath] = entry;
86
+ state.lastSyncAt = new Date().toISOString();
87
+ await writeSyncState(projectDir, state);
88
+ }
89
+
90
+ /** Clear the sync state entirely (e.g., on space switch). */
91
+ export async function clearSyncState(projectDir: string): Promise<void> {
92
+ const filePath = statePath(projectDir);
93
+ if (existsSync(filePath)) {
94
+ await unlink(filePath);
95
+ }
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Comparison
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /** Classify an array of (relativePath, absolutePath) pairs against the current
103
+ * sync state into new/changed/unchanged/renamed groups.
104
+ *
105
+ * @param files Array of [relativePath, absolutePath] tuples.
106
+ * @param state Current sync state (null for first run).
107
+ * @returns Classified file sets with computed SHA256 hashes.
108
+ */
109
+ export async function getChangedFiles(
110
+ files: Array<[string, string]>,
111
+ state: SyncState | null,
112
+ ): Promise<FileChangeSet> {
113
+ const newFiles: string[] = [];
114
+ const changedFiles: string[] = [];
115
+ const unchangedFiles: string[] = [];
116
+ const renamed: RenameEntry[] = [];
117
+
118
+ // Build a reverse index: sha256 -> old path (for rename detection)
119
+ const hashToPath = new Map<string, string>();
120
+ if (state) {
121
+ for (const [path, entry] of Object.entries(state.files)) {
122
+ hashToPath.set(entry.sha256, path);
123
+ }
124
+ }
125
+
126
+ for (const [relPath, absPath] of files) {
127
+ const hash = await computeSha256(absPath);
128
+
129
+ if (!state) {
130
+ // First run — everything is new
131
+ newFiles.push(relPath);
132
+ continue;
133
+ }
134
+
135
+ const existingEntry = state.files[relPath];
136
+ if (!existingEntry) {
137
+ // Check if it's a rename (same hash, different path)
138
+ const oldPath = hashToPath.get(hash);
139
+ if (oldPath && oldPath !== relPath) {
140
+ renamed.push({ oldPath, newPath: relPath });
141
+ continue;
142
+ }
143
+ newFiles.push(relPath);
144
+ } else if (existingEntry.sha256 !== hash) {
145
+ changedFiles.push(relPath);
146
+ } else {
147
+ unchangedFiles.push(relPath);
148
+ }
149
+ }
150
+
151
+ return { newFiles, changedFiles, unchangedFiles, renamed };
152
+ }