@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
|
@@ -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
|
+
}
|