@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/output.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Output formatting for sync and query commands.
|
|
2
|
+
|
|
3
|
+
Auto-detects TTY vs pipe output. In TTY mode, renders progress bars
|
|
4
|
+
using ``cli-progress`` and colored output. In pipe/JSON mode, writes
|
|
5
|
+
structured JSON.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { stdout } from "node:process";
|
|
9
|
+
import cliProgress from "cli-progress";
|
|
10
|
+
import { success, error, warning, info, muted, label as colorLabel } from "./color.js";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// TTY detection
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Whether the current output stream is a TTY (interactive terminal). */
|
|
17
|
+
export function isTTY(): boolean {
|
|
18
|
+
return stdout.isTTY ?? false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Progress bar (TTY mode)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface ProgressBarOptions {
|
|
26
|
+
/** Total number of items to process. */
|
|
27
|
+
total: number;
|
|
28
|
+
/** Label shown in the progress bar. */
|
|
29
|
+
label?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A thin wrapper around cli-progress for sync operations. */
|
|
33
|
+
export class ProgressBar {
|
|
34
|
+
private bar: cliProgress.SingleBar | null = null;
|
|
35
|
+
private label: string;
|
|
36
|
+
|
|
37
|
+
constructor(opts: ProgressBarOptions) {
|
|
38
|
+
this.label = opts.label ?? "Progress";
|
|
39
|
+
if (isTTY()) {
|
|
40
|
+
this.bar = new cliProgress.SingleBar(
|
|
41
|
+
{
|
|
42
|
+
format:
|
|
43
|
+
`{label} [{bar}] {percentage}% | {value}/{total} | {eta_formatted}`,
|
|
44
|
+
barCompleteChar: "█",
|
|
45
|
+
barIncompleteChar: "░",
|
|
46
|
+
hideCursor: true,
|
|
47
|
+
},
|
|
48
|
+
cliProgress.Presets.shades_classic,
|
|
49
|
+
);
|
|
50
|
+
this.bar.start(opts.total, 0, { label: this.label });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Advance the progress bar by one step. */
|
|
55
|
+
increment(): void {
|
|
56
|
+
if (this.bar) this.bar.increment();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Set the current value directly. */
|
|
60
|
+
update(value: number): void {
|
|
61
|
+
if (this.bar) this.bar.update(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Finalise the progress bar. */
|
|
65
|
+
stop(): void {
|
|
66
|
+
if (this.bar) this.bar.stop();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// JSON output mode
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/** A stable schema for ``--json`` output from sync and query commands. */
|
|
75
|
+
export interface JsonResult {
|
|
76
|
+
command: string;
|
|
77
|
+
success: boolean;
|
|
78
|
+
data: unknown;
|
|
79
|
+
meta?: {
|
|
80
|
+
durationMs: number;
|
|
81
|
+
totalItems?: number;
|
|
82
|
+
processedItems?: number;
|
|
83
|
+
skippedItems?: number;
|
|
84
|
+
renamedItems?: number;
|
|
85
|
+
};
|
|
86
|
+
error?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Write a JSON result to stdout (used in ``--json`` mode). */
|
|
90
|
+
export function writeJson(result: JsonResult): void {
|
|
91
|
+
stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Line output (TTY mode)
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/** Write a status line with optional icon (auto-colored by icon type). */
|
|
99
|
+
export function writeLine(message: string, icon?: string): void {
|
|
100
|
+
if (icon) {
|
|
101
|
+
stdout.write(`${colorLabel(icon, message)}\n`);
|
|
102
|
+
} else {
|
|
103
|
+
stdout.write(`${message}\n`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Write an indented detail line (dimmed for visual hierarchy). */
|
|
108
|
+
export function writeDetail(message: string): void {
|
|
109
|
+
stdout.write(` ${muted(message)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Write a success line with green checkmark. */
|
|
113
|
+
export function writeSuccess(message: string): void {
|
|
114
|
+
stdout.write(`${success("✔")} ${success(message)}\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Write an error line with red cross. */
|
|
118
|
+
export function writeError(message: string): void {
|
|
119
|
+
stdout.write(`${error("✖")} ${error(message)}\n`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Write a warning line with yellow warning sign. */
|
|
123
|
+
export function writeWarning(message: string): void {
|
|
124
|
+
stdout.write(`${warning("⚠")} ${warning(message)}\n`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Write an info line with cyan info symbol. */
|
|
128
|
+
export function writeInfo(message: string): void {
|
|
129
|
+
stdout.write(`${info("ℹ")} ${info(message)}\n`);
|
|
130
|
+
}
|
package/lib/parser.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/** YAML config reader/writer for .llmkb configuration files.
|
|
2
|
+
|
|
3
|
+
Provides typed, comment-preserving read/write for ``spaces.yml``
|
|
4
|
+
and ``config.yml``, plus a project-root discovery utility.
|
|
5
|
+
|
|
6
|
+
Uses the ``yaml`` package (eemeli/yaml) which preserves comments,
|
|
7
|
+
formatting, and line positions on round-trip via ``parseDocument``.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { join, dirname, resolve } from "node:path";
|
|
13
|
+
import { parse, stringify, parseDocument } from "yaml";
|
|
14
|
+
import type { SpaceConfig, PluginConfig, ProjectSpaceDef, SpaceDef } from "./types.js";
|
|
15
|
+
import { PACKAGE_VERSION } from "./types.js";
|
|
16
|
+
import { writeFile as writeVersion } from "node:fs/promises";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Project Root Discovery
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Walk up from ``startDir`` looking for a ``.llmkb/`` directory.
|
|
24
|
+
* Returns the first ancestor directory that contains ``.llmkb/``,
|
|
25
|
+
* or ``null`` if none is found before the filesystem root.
|
|
26
|
+
*/
|
|
27
|
+
export function findProjectRoot(startDir: string = process.cwd()): string | null {
|
|
28
|
+
let current = resolve(startDir);
|
|
29
|
+
|
|
30
|
+
// Safety: stop at filesystem root
|
|
31
|
+
while (current !== dirname(current)) {
|
|
32
|
+
if (existsSync(join(current, ".llmkb"))) {
|
|
33
|
+
return current;
|
|
34
|
+
}
|
|
35
|
+
current = dirname(current);
|
|
36
|
+
}
|
|
37
|
+
// Check root too (edge case)
|
|
38
|
+
if (existsSync(join(current, ".llmkb"))) {
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Shortcut to get the ``.llmkb/`` directory path from the project root. */
|
|
45
|
+
export function llmkbDir(projectDir: string): string {
|
|
46
|
+
return join(projectDir, ".llmkb");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Space Config
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/** Read and parse ``spaces.yml`` from the given project root.
|
|
54
|
+
*
|
|
55
|
+
* Automatically migrates old-format files (pre-0.1.0 with ``active`` and
|
|
56
|
+
* ``spaces[{name, label, access, endpoint, token_env}]``) to the new format
|
|
57
|
+
* (``project_space`` + ``spaces[{id, name}]``).
|
|
58
|
+
*/
|
|
59
|
+
export async function readSpaceConfig(projectDir: string): Promise<SpaceConfig | null> {
|
|
60
|
+
const filePath = join(projectDir, ".llmkb", "spaces.yml");
|
|
61
|
+
if (!existsSync(filePath)) return null;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const content = await readFile(filePath, "utf-8");
|
|
65
|
+
const raw = parse(content) as Record<string, unknown> | null;
|
|
66
|
+
|
|
67
|
+
// Comment-only YAML files parse to null — treat as empty config
|
|
68
|
+
if (raw === null) {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Detect old format: has "active" field or uses name-based SpaceDef
|
|
73
|
+
if (raw.active !== undefined || isOldSpaceFormat(raw)) {
|
|
74
|
+
const migrated = migrateOldSpaceConfig(raw);
|
|
75
|
+
// Write migrated config back and bump version stamp
|
|
76
|
+
await writeSpaceConfig(projectDir, migrated);
|
|
77
|
+
await writeVersion(
|
|
78
|
+
join(projectDir, ".llmkb", ".llmkb-version"),
|
|
79
|
+
PACKAGE_VERSION + "\n",
|
|
80
|
+
"utf-8",
|
|
81
|
+
);
|
|
82
|
+
return migrated;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return raw as SpaceConfig;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Detect whether a parsed config uses the old space-definition format. */
|
|
92
|
+
function isOldSpaceFormat(raw: Record<string, unknown>): boolean {
|
|
93
|
+
if (!raw.spaces || !Array.isArray(raw.spaces)) return false;
|
|
94
|
+
return raw.spaces.length > 0 && typeof raw.spaces[0] === "object" &&
|
|
95
|
+
raw.spaces[0] !== null && "name" in (raw.spaces[0] as Record<string, unknown>) &&
|
|
96
|
+
!("id" in (raw.spaces[0] as Record<string, unknown>));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Migrate old-format SpaceConfig to new format.
|
|
100
|
+
*
|
|
101
|
+
* Old format:
|
|
102
|
+
* spaces: [{name, label, access, endpoint, token_env}]
|
|
103
|
+
* active: "my-space"
|
|
104
|
+
* token_groups: {...}
|
|
105
|
+
*
|
|
106
|
+
* New format:
|
|
107
|
+
* project_space: [{id, name, dirs}]
|
|
108
|
+
* spaces: [{id, name}]
|
|
109
|
+
*/
|
|
110
|
+
function migrateOldSpaceConfig(old: Record<string, unknown>): SpaceConfig {
|
|
111
|
+
const result: SpaceConfig = {};
|
|
112
|
+
|
|
113
|
+
const oldSpaces = (old.spaces as Array<Record<string, unknown>>) ?? [];
|
|
114
|
+
const activeName = (old.active as string) ?? oldSpaces[0]?.name;
|
|
115
|
+
|
|
116
|
+
if (oldSpaces.length > 0) {
|
|
117
|
+
// If there was an active space, promote it to project_space
|
|
118
|
+
if (activeName) {
|
|
119
|
+
const activeDef = oldSpaces.find((s) => s.name === activeName);
|
|
120
|
+
const projectEntry: ProjectSpaceDef = {
|
|
121
|
+
id: String(activeDef?.name ?? activeName),
|
|
122
|
+
name: String(activeDef?.label ?? activeName),
|
|
123
|
+
};
|
|
124
|
+
result.project_space = [projectEntry];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// All spaces become id-based references
|
|
128
|
+
result.spaces = oldSpaces.map((s) => ({
|
|
129
|
+
id: String(s.name),
|
|
130
|
+
name: s.label ? String(s.label) : String(s.name),
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Write ``spaces.yml`` from a typed object.
|
|
139
|
+
* Uses the YAML stringifier for clean output.
|
|
140
|
+
*/
|
|
141
|
+
export async function writeSpaceConfig(
|
|
142
|
+
projectDir: string,
|
|
143
|
+
config: SpaceConfig,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const filePath = join(projectDir, ".llmkb", "spaces.yml");
|
|
146
|
+
const yaml = stringify(config, { lineWidth: 120 });
|
|
147
|
+
await writeFile(filePath, yaml + "\n", "utf-8");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Append a space entry to the ``spaces`` list in ``spaces.yml``.
|
|
152
|
+
*/
|
|
153
|
+
export async function addSpaceEntry(
|
|
154
|
+
projectDir: string,
|
|
155
|
+
spaceId: string,
|
|
156
|
+
spaceName?: string,
|
|
157
|
+
): Promise<boolean> {
|
|
158
|
+
let config = await readSpaceConfig(projectDir);
|
|
159
|
+
// Initialize empty config for template/empty YAML
|
|
160
|
+
if (!config) config = {};
|
|
161
|
+
if (!config.spaces) config.spaces = [];
|
|
162
|
+
// Avoid duplicates
|
|
163
|
+
if (!config.spaces.some((s) => s.id === spaceId)) {
|
|
164
|
+
config.spaces.push({ id: spaceId, name: spaceName });
|
|
165
|
+
}
|
|
166
|
+
await writeSpaceConfig(projectDir, config);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Remove a space entry from the ``spaces`` list in ``spaces.yml`` by id.
|
|
172
|
+
*/
|
|
173
|
+
export async function removeSpaceEntry(
|
|
174
|
+
projectDir: string,
|
|
175
|
+
spaceId: string,
|
|
176
|
+
): Promise<boolean> {
|
|
177
|
+
const config = await readSpaceConfig(projectDir);
|
|
178
|
+
if (!config?.spaces) return false;
|
|
179
|
+
const idx = config.spaces.findIndex((s) => s.id === spaceId);
|
|
180
|
+
if (idx === -1) return false;
|
|
181
|
+
config.spaces.splice(idx, 1);
|
|
182
|
+
await writeSpaceConfig(projectDir, config);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Update the project_space ID in ``spaces.yml``.
|
|
188
|
+
*/
|
|
189
|
+
export async function updateProjectSpace(
|
|
190
|
+
projectDir: string,
|
|
191
|
+
spaceId: string,
|
|
192
|
+
): Promise<boolean> {
|
|
193
|
+
const config = await readSpaceConfig(projectDir);
|
|
194
|
+
if (!config) return false;
|
|
195
|
+
if (!config.project_space || config.project_space.length === 0) {
|
|
196
|
+
config.project_space = [{ id: spaceId }];
|
|
197
|
+
} else {
|
|
198
|
+
config.project_space[0]!.id = spaceId;
|
|
199
|
+
}
|
|
200
|
+
await writeSpaceConfig(projectDir, config);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Delete all space entries from ``spaces.yml``, keeping project_space.
|
|
206
|
+
*/
|
|
207
|
+
export async function deleteAllSpaces(projectDir: string): Promise<boolean> {
|
|
208
|
+
const config = await readSpaceConfig(projectDir);
|
|
209
|
+
if (!config) return false;
|
|
210
|
+
config.spaces = [];
|
|
211
|
+
await writeSpaceConfig(projectDir, config);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Update a single field in ``spaces.yml`` while preserving comments.
|
|
217
|
+
* Uses ``parseDocument`` for comment-safe round-trip.
|
|
218
|
+
* Returns ``true`` if the field was found and updated.
|
|
219
|
+
*
|
|
220
|
+
* Note: In the new data model, the ``active`` field no longer exists.
|
|
221
|
+
* Use ``updateProjectSpace()`` to update the project space instead.
|
|
222
|
+
*/
|
|
223
|
+
export async function updateSpaceConfigField<T extends keyof SpaceConfig>(
|
|
224
|
+
projectDir: string,
|
|
225
|
+
field: T,
|
|
226
|
+
value: SpaceConfig[T],
|
|
227
|
+
): Promise<boolean> {
|
|
228
|
+
const filePath = join(projectDir, ".llmkb", "spaces.yml");
|
|
229
|
+
if (!existsSync(filePath)) return false;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const content = await readFile(filePath, "utf-8");
|
|
233
|
+
const doc = parseDocument(content);
|
|
234
|
+
doc.set(field, value);
|
|
235
|
+
await writeFile(filePath, String(doc) + "\n", "utf-8");
|
|
236
|
+
return true;
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Plugin Config (config.yml)
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/** Read and parse ``config.yml`` from the given project root. */
|
|
247
|
+
export async function readPluginConfig(projectDir: string): Promise<PluginConfig | null> {
|
|
248
|
+
const filePath = join(projectDir, ".llmkb", "config.yml");
|
|
249
|
+
if (!existsSync(filePath)) return null;
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const content = await readFile(filePath, "utf-8");
|
|
253
|
+
return parse(content) as PluginConfig;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Version Stamp
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/** Read the version stamp from ``.llmkb/.llmkb-version``. */
|
|
264
|
+
export async function readVersionStamp(projectDir: string): Promise<string | null> {
|
|
265
|
+
const filePath = join(projectDir, ".llmkb", ".llmkb-version");
|
|
266
|
+
if (!existsSync(filePath)) return null;
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const content = await readFile(filePath, "utf-8");
|
|
270
|
+
return content.trim();
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|