@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/color.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Terminal color helpers for llmkb output.
|
|
2
|
+
|
|
3
|
+
Uses picocolors (14× smaller, 2× faster than chalk).
|
|
4
|
+
Respects NO_COLOR env var automatically.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Severity helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export const success = pc.green;
|
|
14
|
+
export const error = pc.red;
|
|
15
|
+
export const warning = pc.yellow;
|
|
16
|
+
export const info = pc.cyan;
|
|
17
|
+
export const highlight = (s: string) => pc.bold(pc.white(s));
|
|
18
|
+
export const muted = pc.dim;
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Icon-label mapping
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const ICON_COLORS: Record<string, (s: string) => string> = {
|
|
25
|
+
"✔": success,
|
|
26
|
+
"✓": success,
|
|
27
|
+
"✖": error,
|
|
28
|
+
"✗": error,
|
|
29
|
+
"⚠": warning,
|
|
30
|
+
"ℹ": info,
|
|
31
|
+
"⌚": info,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Apply color to an icon label (e.g. ``"✔"`` → green). */
|
|
35
|
+
export function label(icon: string, text: string): string {
|
|
36
|
+
const colorFn = ICON_COLORS[icon];
|
|
37
|
+
if (colorFn) {
|
|
38
|
+
return `${colorFn(icon)} ${text}`;
|
|
39
|
+
}
|
|
40
|
+
return `${icon} ${text}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Color a status badge: active→green, expired→yellow, revoked→red, etc. */
|
|
44
|
+
export function badge(status: string): string {
|
|
45
|
+
switch (status?.toLowerCase()) {
|
|
46
|
+
case "active":
|
|
47
|
+
case "present":
|
|
48
|
+
case "complete":
|
|
49
|
+
case "passed":
|
|
50
|
+
return pc.green(status);
|
|
51
|
+
case "expired":
|
|
52
|
+
case "missing":
|
|
53
|
+
case "failed":
|
|
54
|
+
return pc.red(status);
|
|
55
|
+
case "pending":
|
|
56
|
+
case "warning":
|
|
57
|
+
return pc.yellow(status);
|
|
58
|
+
default:
|
|
59
|
+
return status;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/** Configuration validation utilities for the ``llmkb doctor`` command.
|
|
2
|
+
|
|
3
|
+
Provides functions to validate ``.llmkb/config.yml``, ``.llmkb/spaces.yml``,
|
|
4
|
+
access token status, and backend connectivity.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { parse } from "yaml";
|
|
11
|
+
import { getToken } from "./credentials.js";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Validation Result
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface ValidationResult {
|
|
18
|
+
name: string;
|
|
19
|
+
passed: boolean;
|
|
20
|
+
message: string;
|
|
21
|
+
detail?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// URL Validation
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** Validate that a URL has a proper scheme and host. */
|
|
29
|
+
export function validateBaseUrl(url: string): ValidationResult {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = new URL(url);
|
|
32
|
+
if (!parsed.protocol || !parsed.host) {
|
|
33
|
+
return {
|
|
34
|
+
name: "Base URL format",
|
|
35
|
+
passed: false,
|
|
36
|
+
message: "URL missing scheme or host",
|
|
37
|
+
detail: `Got: ${url}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
41
|
+
return {
|
|
42
|
+
name: "Base URL format",
|
|
43
|
+
passed: false,
|
|
44
|
+
message: `Unsupported protocol: ${parsed.protocol}`,
|
|
45
|
+
detail: `Got: ${url}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
name: "Base URL format",
|
|
50
|
+
passed: true,
|
|
51
|
+
message: `Valid URL: ${url}`,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return {
|
|
55
|
+
name: "Base URL format",
|
|
56
|
+
passed: false,
|
|
57
|
+
message: "Invalid URL format",
|
|
58
|
+
detail: `Got: ${url}. Must include scheme (e.g., https://api.llmkb.ai)`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Config.yml Validation
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/** Validate ``.llmkb/config.yml`` — parse, check required keys, check for invalid keys. */
|
|
68
|
+
export async function validateConfigYml(projectDir: string): Promise<ValidationResult> {
|
|
69
|
+
const filePath = join(projectDir, ".llmkb", "config.yml");
|
|
70
|
+
if (!existsSync(filePath)) {
|
|
71
|
+
return {
|
|
72
|
+
name: "config.yml",
|
|
73
|
+
passed: false,
|
|
74
|
+
message: "File not found",
|
|
75
|
+
detail: "Run `llmkb init` to create it.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const content = await readFile(filePath, "utf-8");
|
|
81
|
+
const cfg = parse(content) as Record<string, unknown> | null;
|
|
82
|
+
|
|
83
|
+
// Comment-only YAML files parse to null — treat as empty config
|
|
84
|
+
if (cfg === null) {
|
|
85
|
+
return {
|
|
86
|
+
name: "config.yml",
|
|
87
|
+
passed: true,
|
|
88
|
+
message: "Valid config file (all defaults)",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (cfg.llmkb_base_url !== undefined) {
|
|
93
|
+
const urlResult = validateBaseUrl(String(cfg.llmkb_base_url));
|
|
94
|
+
if (!urlResult.passed) {
|
|
95
|
+
return {
|
|
96
|
+
name: "config.yml",
|
|
97
|
+
passed: false,
|
|
98
|
+
message: "llmkb_base_url has invalid value",
|
|
99
|
+
detail: urlResult.detail,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: "config.yml",
|
|
106
|
+
passed: true,
|
|
107
|
+
message: "Valid config file",
|
|
108
|
+
};
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
name: "config.yml",
|
|
112
|
+
passed: false,
|
|
113
|
+
message: "Invalid YAML",
|
|
114
|
+
detail: (err as Error).message,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Spaces.yml Validation
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/** Validate ``.llmkb/spaces.yml`` — parse, check required fields. */
|
|
124
|
+
export async function validateSpacesYml(projectDir: string): Promise<ValidationResult> {
|
|
125
|
+
const filePath = join(projectDir, ".llmkb", "spaces.yml");
|
|
126
|
+
if (!existsSync(filePath)) {
|
|
127
|
+
return {
|
|
128
|
+
name: "spaces.yml",
|
|
129
|
+
passed: false,
|
|
130
|
+
message: "File not found",
|
|
131
|
+
detail: "Run `llmkb init` or `llmkb login` to create it.",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const content = await readFile(filePath, "utf-8");
|
|
137
|
+
const cfg = parse(content) as Record<string, unknown>;
|
|
138
|
+
|
|
139
|
+
if (cfg.project_space && Array.isArray(cfg.project_space)) {
|
|
140
|
+
for (const ps of cfg.project_space) {
|
|
141
|
+
if (!ps.id) {
|
|
142
|
+
return {
|
|
143
|
+
name: "spaces.yml",
|
|
144
|
+
passed: false,
|
|
145
|
+
message: "project_space entry missing required 'id' field",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (cfg.spaces && Array.isArray(cfg.spaces)) {
|
|
152
|
+
for (const s of cfg.spaces) {
|
|
153
|
+
if (!s.id) {
|
|
154
|
+
return {
|
|
155
|
+
name: "spaces.yml",
|
|
156
|
+
passed: false,
|
|
157
|
+
message: "spaces entry missing required 'id' field",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
name: "spaces.yml",
|
|
165
|
+
passed: true,
|
|
166
|
+
message: `Valid config (${(cfg.spaces as unknown[])?.length ?? 0} space(s))`,
|
|
167
|
+
};
|
|
168
|
+
} catch (err) {
|
|
169
|
+
return {
|
|
170
|
+
name: "spaces.yml",
|
|
171
|
+
passed: false,
|
|
172
|
+
message: "Invalid YAML",
|
|
173
|
+
detail: (err as Error).message,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Access Token Validation
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/** Check if an access token is stored in the keychain and is still valid. */
|
|
183
|
+
export async function validateAccessToken(endpoint: string): Promise<ValidationResult> {
|
|
184
|
+
const token = await getToken();
|
|
185
|
+
if (!token) {
|
|
186
|
+
return {
|
|
187
|
+
name: "Access token",
|
|
188
|
+
passed: false,
|
|
189
|
+
message: "No access token found",
|
|
190
|
+
detail: "Set LLMKB_ACCESS_TOKEN env var or run `llmkb login --project-space <id>`.",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const url = `${endpoint.replace(/\/+$/, "")}/api/v1/auth/me`;
|
|
196
|
+
const res = await fetch(url, {
|
|
197
|
+
method: "GET",
|
|
198
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (res.ok) {
|
|
202
|
+
const body = (await res.json()) as {
|
|
203
|
+
data?: { attributes?: { token_status?: string; email?: string } };
|
|
204
|
+
};
|
|
205
|
+
const attrs = body.data?.attributes;
|
|
206
|
+
const status = attrs?.token_status;
|
|
207
|
+
if (status === "active") {
|
|
208
|
+
return {
|
|
209
|
+
name: "Access token",
|
|
210
|
+
passed: true,
|
|
211
|
+
message: `Token is valid (user: ${attrs?.email ?? "unknown"})`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
name: "Access token",
|
|
216
|
+
passed: false,
|
|
217
|
+
message: `Token status: ${status}`,
|
|
218
|
+
detail: "Generate a new token at /settings/access-tokens.",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
name: "Access token",
|
|
224
|
+
passed: false,
|
|
225
|
+
message: `Server returned ${res.status}`,
|
|
226
|
+
detail: "Token may be expired or invalid. Run `llmkb login --project-space <id>`.",
|
|
227
|
+
};
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return {
|
|
230
|
+
name: "Access token",
|
|
231
|
+
passed: false,
|
|
232
|
+
message: "Cannot validate — server unreachable",
|
|
233
|
+
detail: (err as Error).message,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Backend Connectivity
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** Check if the backend server is reachable. */
|
|
243
|
+
export async function checkBackendConnectivity(endpoint: string): Promise<ValidationResult> {
|
|
244
|
+
try {
|
|
245
|
+
const url = `${endpoint.replace(/\/+$/, "")}/health`;
|
|
246
|
+
const res = await fetch(url);
|
|
247
|
+
if (res.ok) {
|
|
248
|
+
return {
|
|
249
|
+
name: "Backend connectivity",
|
|
250
|
+
passed: true,
|
|
251
|
+
message: `Server reachable at ${endpoint}`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
name: "Backend connectivity",
|
|
256
|
+
passed: false,
|
|
257
|
+
message: `Server returned ${res.status}`,
|
|
258
|
+
detail: "Check that the server is running.",
|
|
259
|
+
};
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return {
|
|
262
|
+
name: "Backend connectivity",
|
|
263
|
+
passed: false,
|
|
264
|
+
message: "Server unreachable",
|
|
265
|
+
detail: (err as Error).message,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Space Access
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/** Role hierarchy for write-permission checks. */
|
|
275
|
+
const WRITE_ROLES = new Set(["owner", "admin"]);
|
|
276
|
+
|
|
277
|
+
/** Check if the user has access to a specific space and has write permission. */
|
|
278
|
+
export async function checkSpaceAccess(
|
|
279
|
+
endpoint: string,
|
|
280
|
+
spaceId: string,
|
|
281
|
+
token: string,
|
|
282
|
+
checkWrite?: boolean,
|
|
283
|
+
): Promise<ValidationResult> {
|
|
284
|
+
try {
|
|
285
|
+
const url = `${endpoint.replace(/\/+$/, "")}/api/v1/auth/spaces`;
|
|
286
|
+
const res = await fetch(url, {
|
|
287
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (res.ok) {
|
|
291
|
+
const body = (await res.json()) as {
|
|
292
|
+
data?: Array<{ attributes?: { space_id?: string; role?: string } }>;
|
|
293
|
+
};
|
|
294
|
+
const space = body.data?.find((d) => d.attributes?.space_id === spaceId);
|
|
295
|
+
if (space) {
|
|
296
|
+
const role = space.attributes!.role ?? "guest";
|
|
297
|
+
if (checkWrite && !WRITE_ROLES.has(role)) {
|
|
298
|
+
return {
|
|
299
|
+
name: `Space access (${spaceId})`,
|
|
300
|
+
passed: false,
|
|
301
|
+
message: `Access granted (role: ${role}) but no write permission`,
|
|
302
|
+
detail: `Role "${role}" is read-only. Need owner or admin to sync. Ask the space owner to upgrade your role.`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
name: `Space access (${spaceId})`,
|
|
307
|
+
passed: true,
|
|
308
|
+
message: `Access granted (role: ${role})${checkWrite ? " — write permission OK" : ""}`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
name: `Space access (${spaceId})`,
|
|
313
|
+
passed: false,
|
|
314
|
+
message: "No access to this space",
|
|
315
|
+
detail: "Ask the space owner to invite you.",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
name: `Space access (${spaceId})`,
|
|
321
|
+
passed: false,
|
|
322
|
+
message: `Server returned ${res.status}`,
|
|
323
|
+
};
|
|
324
|
+
} catch (err) {
|
|
325
|
+
return {
|
|
326
|
+
name: `Space access (${spaceId})`,
|
|
327
|
+
passed: false,
|
|
328
|
+
message: "Cannot check — server unreachable",
|
|
329
|
+
detail: (err as Error).message,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
package/lib/config.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Configuration for the llmkb plugin.
|
|
2
|
+
|
|
3
|
+
Configuration is resolved in priority order:
|
|
4
|
+
1. ``.llmkb/config.yml`` — project-level settings
|
|
5
|
+
2. Environment variables — CI/headless override
|
|
6
|
+
3. Built-in defaults
|
|
7
|
+
|
|
8
|
+
Call ``getConfig()`` on startup — it is synchronous for env-only,
|
|
9
|
+
but also provides ``resolveEndpoint()`` for config-file-aware resolution.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { env } from "node:process";
|
|
13
|
+
|
|
14
|
+
/** Resolved plugin configuration. */
|
|
15
|
+
export interface PluginConfig {
|
|
16
|
+
/** llmkb API endpoint (default ``http://localhost:8000``). */
|
|
17
|
+
endpoint: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_ENDPOINT = "http://localhost:8000";
|
|
21
|
+
|
|
22
|
+
/** Read configuration from environment variables.
|
|
23
|
+
*
|
|
24
|
+
* For config-file-aware resolution (reading ``llmkb_base_url`` from
|
|
25
|
+
* ``.llmkb/config.yml``), use ``resolveEndpoint()`` or the `doctor`
|
|
26
|
+
* command instead.
|
|
27
|
+
*/
|
|
28
|
+
export function getConfig(): PluginConfig {
|
|
29
|
+
return {
|
|
30
|
+
endpoint: env["LLMKB_ENDPOINT"] ?? DEFAULT_ENDPOINT,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the API endpoint with config-file awareness.
|
|
36
|
+
*
|
|
37
|
+
* Priority: ``.llmkb/config.yml`` → ``LLMKB_ENDPOINT`` env → default.
|
|
38
|
+
*
|
|
39
|
+
* This is async because it reads the config file from disk.
|
|
40
|
+
* CLI commands that are called once per session (login, sync, query)
|
|
41
|
+
* should use this instead of ``getConfig()``.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveEndpoint(): Promise<string> {
|
|
44
|
+
const envEndpoint = env["LLMKB_ENDPOINT"];
|
|
45
|
+
if (envEndpoint) return envEndpoint;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const { findProjectRoot, readPluginConfig } = await import("./parser.js");
|
|
49
|
+
const root = findProjectRoot();
|
|
50
|
+
if (root) {
|
|
51
|
+
const pluginCfg = await readPluginConfig(root);
|
|
52
|
+
if (pluginCfg?.llmkb_base_url) {
|
|
53
|
+
return pluginCfg.llmkb_base_url;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// ignore — fall back to default
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return DEFAULT_ENDPOINT;
|
|
61
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** OS keychain integration with environment-variable fallback for access_token.
|
|
2
|
+
|
|
3
|
+
Tokens are resolved in a 2-step chain:
|
|
4
|
+
1. ``LLMKB_ACCESS_TOKEN`` env var
|
|
5
|
+
2. OS keychain: ``llmkb/user/access_token``
|
|
6
|
+
3. Error
|
|
7
|
+
|
|
8
|
+
Never writes tokens to config files or checked-in files.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { env } from "node:process";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Keychain integration (lazy-loaded)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
let kcGetPassword: any | null = null;
|
|
19
|
+
let kcSetPassword: any | null = null;
|
|
20
|
+
let kcDeletePassword: any | null = null;
|
|
21
|
+
let keychainAvailable = false;
|
|
22
|
+
|
|
23
|
+
async function ensureKeychain(): Promise<boolean> {
|
|
24
|
+
if (keychainAvailable) return true;
|
|
25
|
+
try {
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
const mod: any = await import("keychain");
|
|
28
|
+
const kc = mod.default ?? mod;
|
|
29
|
+
if (typeof kc.setPassword === "function") {
|
|
30
|
+
kcGetPassword = kc.getPassword.bind(kc);
|
|
31
|
+
kcSetPassword = kc.setPassword.bind(kc);
|
|
32
|
+
kcDeletePassword = kc.deletePassword.bind(kc);
|
|
33
|
+
keychainAvailable = true;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Credential Store interface
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export interface CredentialStore {
|
|
47
|
+
getToken(): Promise<string | null>;
|
|
48
|
+
setToken(token: string): Promise<void>;
|
|
49
|
+
deleteToken(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// 2-Step Token Resolution
|
|
54
|
+
//
|
|
55
|
+
// 1. LLMKB_ACCESS_TOKEN env var
|
|
56
|
+
// 2. OS keychain llmkb/user/access_token
|
|
57
|
+
// 3. Error
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the user's access token.
|
|
62
|
+
*
|
|
63
|
+
* Tries environment variable first (CI/headless), then OS keychain.
|
|
64
|
+
*/
|
|
65
|
+
export async function getToken(): Promise<string | null> {
|
|
66
|
+
// Step 1: LLMKB_ACCESS_TOKEN env var
|
|
67
|
+
const envVal = env["LLMKB_ACCESS_TOKEN"];
|
|
68
|
+
if (envVal) return envVal;
|
|
69
|
+
|
|
70
|
+
// Step 2: OS keychain
|
|
71
|
+
const kcToken = await keychainGet();
|
|
72
|
+
if (kcToken) return kcToken;
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve an access token, throwing a descriptive error if not found.
|
|
79
|
+
* Convenience wrapper around ``getToken`` for call sites that expect a
|
|
80
|
+
* mandatory token.
|
|
81
|
+
*/
|
|
82
|
+
export async function requireToken(): Promise<string> {
|
|
83
|
+
const token = await getToken();
|
|
84
|
+
if (!token) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"No access token found. Set LLMKB_ACCESS_TOKEN env var or run `llmkb login --project-space <id>`.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return token;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Keychain primitives
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
async function keychainGet(): Promise<string | null> {
|
|
97
|
+
const available = await ensureKeychain();
|
|
98
|
+
if (!available) return null;
|
|
99
|
+
|
|
100
|
+
return new Promise<string | null>((resolve) => {
|
|
101
|
+
try {
|
|
102
|
+
kcGetPassword(
|
|
103
|
+
{ service: "llmkb", account: "user/access_token" },
|
|
104
|
+
(err: Error | null, password: string | null) => {
|
|
105
|
+
if (err) resolve(null);
|
|
106
|
+
else resolve(password);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
resolve(null);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Token storage (keychain write)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Store an access token in the OS keychain.
|
|
121
|
+
*
|
|
122
|
+
* @throws If the keychain is unavailable (no fallback for writes — env vars
|
|
123
|
+
* are read-only).
|
|
124
|
+
*/
|
|
125
|
+
export async function setToken(token: string): Promise<void> {
|
|
126
|
+
const available = await ensureKeychain();
|
|
127
|
+
if (!available) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Keychain is not available. Set LLMKB_ACCESS_TOKEN env var instead.",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return new Promise<void>((resolve, reject) => {
|
|
134
|
+
kcSetPassword(
|
|
135
|
+
{
|
|
136
|
+
service: "llmkb",
|
|
137
|
+
account: "user/access_token",
|
|
138
|
+
password: token,
|
|
139
|
+
},
|
|
140
|
+
(err: Error | null) => {
|
|
141
|
+
if (err) reject(err);
|
|
142
|
+
else resolve();
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Remove the access token from the OS keychain.
|
|
150
|
+
*/
|
|
151
|
+
export async function deleteToken(): Promise<void> {
|
|
152
|
+
const available = await ensureKeychain();
|
|
153
|
+
if (!available) return;
|
|
154
|
+
|
|
155
|
+
return new Promise<void>((resolve, reject) => {
|
|
156
|
+
kcDeletePassword(
|
|
157
|
+
{ service: "llmkb", account: "user/access_token" },
|
|
158
|
+
(err: Error | null) => {
|
|
159
|
+
if (err) reject(err);
|
|
160
|
+
else resolve();
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
}
|