@spader/dotllm 1.0.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,245 @@
1
+ import yargs from "yargs";
2
+ import { hideBin } from "yargs/helpers";
3
+ import pc from "picocolors";
4
+ import { cols } from "@spader/dotllm/cli/layout";
5
+ import { type Theme, defaultTheme } from "@spader/dotllm/cli/theme";
6
+
7
+ export type OptionDef = {
8
+ alias?: string;
9
+ type: "string" | "number" | "boolean" | "array";
10
+ description: string;
11
+ required?: boolean;
12
+ default?: unknown;
13
+ };
14
+
15
+ export type Options = Record<string, OptionDef>;
16
+
17
+ export type PositionalDef = {
18
+ type: "string" | "number";
19
+ description: string;
20
+ required?: boolean;
21
+ default?: unknown;
22
+ };
23
+
24
+ export type Positionals = Record<string, PositionalDef>;
25
+
26
+ export type CommandDef = {
27
+ description: string;
28
+ summary?: string;
29
+ hidden?: boolean;
30
+ positionals?: Positionals;
31
+ options?: Options;
32
+ commands?: Record<string, CommandDef>;
33
+ handler?: (argv: Record<string, unknown>) => void | Promise<void>;
34
+ };
35
+
36
+ export type CliDef = {
37
+ name: string;
38
+ description: string;
39
+ options?: Options;
40
+ commands: Record<string, CommandDef>;
41
+ };
42
+
43
+ function usage(def: CommandDef | CliDef, path: string[], t: Theme): string {
44
+ const parts: string[] = [];
45
+ const last = path.length - 1;
46
+ for (let i = 0; i < path.length; i++) {
47
+ const fmt = t.command;
48
+ parts.push(i === last ? fmt(path[i]!) : path[i]!);
49
+ }
50
+
51
+ if ("positionals" in def && def.positionals) {
52
+ for (const [k, v] of Object.entries(def.positionals)) {
53
+ const name = t.arg(`$${k}`);
54
+ parts.push(v.required ? name : `[${name}]`);
55
+ }
56
+ }
57
+
58
+ if (def.options && Object.keys(def.options).length > 0) {
59
+ parts.push(t.dim("[options]"));
60
+ }
61
+
62
+ if (
63
+ "commands" in def &&
64
+ def.commands &&
65
+ Object.keys(def.commands).length > 0
66
+ ) {
67
+ parts.push(t.arg("$command"));
68
+ }
69
+
70
+ return parts.join(" ");
71
+ }
72
+
73
+ export function help(
74
+ def: CommandDef | CliDef,
75
+ name: string,
76
+ path: string[] = [],
77
+ t: Theme = defaultTheme,
78
+ ): void {
79
+ let prev = false;
80
+
81
+ if (def.description) {
82
+ console.log(t.description(def.description));
83
+ prev = true;
84
+ }
85
+
86
+ if (prev) console.log("");
87
+ console.log(t.header("usage:"));
88
+ console.log(` ${usage(def, [name, ...path], t)}`);
89
+ prev = true;
90
+
91
+ const pos = "positionals" in def ? def.positionals : undefined;
92
+ if (pos && Object.keys(pos).length > 0) {
93
+ if (prev) console.log("");
94
+ console.log(t.header("arguments"));
95
+ prev = true;
96
+
97
+ const rows: string[][] = [];
98
+ for (const [k, v] of Object.entries(pos)) {
99
+ let desc = v.description;
100
+ if (v.default !== undefined)
101
+ desc += ` ${t.dim(`(default: ${v.default})`)}`;
102
+ if (v.required) desc += ` ${t.dim("(required)")}`;
103
+ rows.push([` ${k}`, v.type, desc]);
104
+ }
105
+ cols(rows, [t.arg, t.type, t.description]);
106
+ }
107
+
108
+ const opts: Record<string, OptionDef> = {
109
+ ...(def.options ?? {}),
110
+ help: { alias: "h", type: "boolean", description: "Show help" },
111
+ };
112
+
113
+ if (Object.keys(opts).length > 0) {
114
+ if (prev) console.log("");
115
+ console.log(t.header("options"));
116
+ prev = true;
117
+
118
+ const rows: string[][] = [];
119
+ for (const [k, v] of Object.entries(opts)) {
120
+ const short = v.alias ? `-${v.alias}, ` : " ";
121
+ let desc = v.description;
122
+ if (v.default !== undefined && v.type !== "boolean") {
123
+ desc += ` ${t.dim(`(default: ${v.default})`)}`;
124
+ }
125
+ rows.push([` ${short}--${k}`, v.type, desc]);
126
+ }
127
+ cols(rows, [t.option, t.type, t.description]);
128
+ }
129
+
130
+ const cmds = "commands" in def ? def.commands : undefined;
131
+ if (cmds && Object.keys(cmds).length > 0) {
132
+ if (prev) console.log("");
133
+ console.log(t.header("commands"));
134
+
135
+ const rows: string[][] = [];
136
+ const rowCmdFmt: ((s: string) => string)[] = [];
137
+ for (const [k, v] of Object.entries(cmds)) {
138
+ if (v.hidden) continue;
139
+ const args = v.positionals ? Object.keys(v.positionals).join(" ") : "";
140
+ rows.push([` ${k}`, args, v.summary ?? v.description]);
141
+ rowCmdFmt.push(t.command);
142
+ }
143
+ let ri = 0;
144
+ cols(rows, [(s) => rowCmdFmt[ri++]!(s), t.arg, t.description]);
145
+ }
146
+ }
147
+
148
+ function fail(def: CommandDef | CliDef, name: string, path: string[] = []) {
149
+ return (msg: string | null): void => {
150
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
151
+ help(def, name, path);
152
+ process.exit(0);
153
+ }
154
+ if (
155
+ msg?.includes("You must specify") ||
156
+ msg?.includes("Not enough non-option arguments")
157
+ ) {
158
+ help(def, name, path);
159
+ process.exit(1);
160
+ }
161
+ console.error(pc.red(msg ?? "Unknown error"));
162
+ process.exit(1);
163
+ };
164
+ }
165
+
166
+ function check(def: CommandDef | CliDef, name: string, path: string[] = []) {
167
+ return (argv: Record<string, unknown>): boolean => {
168
+ const args = argv as Record<string, unknown> & { _: unknown[]; help?: boolean };
169
+ if (args.help && args._.length === path.length) {
170
+ help(def, name, path);
171
+ process.exit(0);
172
+ }
173
+ return true;
174
+ };
175
+ }
176
+
177
+ function configure(
178
+ y: ReturnType<typeof yargs>,
179
+ def: CommandDef | CliDef,
180
+ root: string,
181
+ path: string[],
182
+ ): void {
183
+ if ("positionals" in def && def.positionals) {
184
+ for (const [k, v] of Object.entries(def.positionals)) {
185
+ y.positional(k, {
186
+ type: v.type,
187
+ describe: v.description,
188
+ demandOption: v.required,
189
+ default: v.default,
190
+ });
191
+ }
192
+ }
193
+
194
+ if (def.options) {
195
+ for (const [k, v] of Object.entries(def.options)) {
196
+ y.option(k, {
197
+ alias: v.alias,
198
+ type: v.type,
199
+ describe: v.description,
200
+ demandOption: v.required,
201
+ default: v.default,
202
+ });
203
+ }
204
+ }
205
+
206
+ if ("commands" in def && def.commands) {
207
+ for (const [k, v] of Object.entries(def.commands)) {
208
+ command(y, k, v, root, path);
209
+ }
210
+ y.demandCommand(1, "You must specify a command");
211
+ }
212
+
213
+ y.help(false)
214
+ .option("help", { alias: "h", type: "boolean", describe: "Show help" })
215
+ .check(check(def, root, path))
216
+ .fail(fail(def, root, path));
217
+ }
218
+
219
+ function command(
220
+ y: ReturnType<typeof yargs>,
221
+ name: string,
222
+ def: CommandDef,
223
+ root: string,
224
+ path: string[],
225
+ ): void {
226
+ let cmd = name;
227
+ if (def.positionals) {
228
+ for (const [k, v] of Object.entries(def.positionals)) {
229
+ cmd += v.required ? ` <${k}>` : ` [${k}]`;
230
+ }
231
+ }
232
+ y.command(
233
+ cmd,
234
+ def.description,
235
+ (inner: ReturnType<typeof yargs>) => configure(inner, def, root, [...path, name]),
236
+ def.handler as (argv: Record<string, unknown>) => void,
237
+ );
238
+ }
239
+
240
+ export function build(def: CliDef): ReturnType<typeof yargs> {
241
+ const y = yargs(hideBin(process.argv)).scriptName(def.name);
242
+ configure(y, def, def.name, []);
243
+ y.strict();
244
+ return y;
245
+ }
@@ -0,0 +1,100 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { Config, type RepoEntry } from "@spader/dotllm/core/config";
4
+
5
+ function isUrl(value: string): boolean {
6
+ return value.startsWith("http://") ||
7
+ value.startsWith("https://") ||
8
+ value.startsWith("git@") ||
9
+ value.startsWith("ssh://");
10
+ }
11
+
12
+ function nameFromGitRemote(dir: string): string | null {
13
+ const result = Bun.spawnSync(["git", "remote", "get-url", "origin"], {
14
+ cwd: dir,
15
+ stdout: "pipe",
16
+ stderr: "pipe",
17
+ });
18
+ if (result.exitCode !== 0) return null;
19
+ const url = result.stdout.toString().trim();
20
+ return nameFromUrl(url);
21
+ }
22
+
23
+ function nameFromUrl(url: string): string {
24
+ const base = url.split("/").pop() ?? url;
25
+ return base.replace(/\.git$/, "");
26
+ }
27
+
28
+ export type AddResult = {
29
+ ok: true;
30
+ entry: RepoEntry;
31
+ storePath: string;
32
+ } | {
33
+ ok: false;
34
+ error: string;
35
+ };
36
+
37
+ export async function add(uri: string, name?: string, description?: string): Promise<AddResult> {
38
+ const desc = description ?? "";
39
+
40
+ if (isUrl(uri)) {
41
+ return cloneUrl(uri, name, desc);
42
+ }
43
+
44
+ return linkLocal(uri, name, desc);
45
+ }
46
+
47
+ async function cloneUrl(url: string, name: string | undefined, description: string): Promise<AddResult> {
48
+ const resolved = name ?? nameFromUrl(url);
49
+ const store = Config.storeDir();
50
+ fs.mkdirSync(store, { recursive: true });
51
+
52
+ const target = path.join(store, resolved);
53
+
54
+ if (!fs.existsSync(target)) {
55
+ const proc = Bun.spawn(["git", "clone", url, target], {
56
+ stdout: "pipe",
57
+ stderr: "pipe",
58
+ });
59
+ const code = await proc.exited;
60
+
61
+ if (code !== 0) {
62
+ const msg = await new Response(proc.stderr).text();
63
+ return { ok: false, error: `git clone failed: ${msg.trim()}` };
64
+ }
65
+ }
66
+
67
+ const entry: RepoEntry = { kind: "url", name: resolved, uri: url, description };
68
+ const global = Config.Global.read();
69
+ Config.Global.write(Config.Global.add(global, entry));
70
+
71
+ return { ok: true, entry, storePath: target };
72
+ }
73
+
74
+ function linkLocal(raw: string, name: string | undefined, description: string): AddResult {
75
+ const resolved = path.resolve(raw);
76
+
77
+ if (!fs.existsSync(resolved)) {
78
+ return { ok: false, error: `Path does not exist: ${resolved}` };
79
+ }
80
+
81
+ if (!fs.statSync(resolved).isDirectory()) {
82
+ return { ok: false, error: `Not a directory: ${resolved}` };
83
+ }
84
+
85
+ const store = Config.storeDir();
86
+ fs.mkdirSync(store, { recursive: true });
87
+
88
+ const finalName = name ?? nameFromGitRemote(resolved) ?? path.basename(resolved);
89
+ const target = path.join(store, finalName);
90
+
91
+ if (!fs.existsSync(target)) {
92
+ fs.symlinkSync(resolved, target, "dir");
93
+ }
94
+
95
+ const entry: RepoEntry = { kind: "file", name: finalName, uri: resolved, description };
96
+ const global = Config.Global.read();
97
+ Config.Global.write(Config.Global.add(global, entry));
98
+
99
+ return { ok: true, entry, storePath: target };
100
+ }
@@ -0,0 +1,126 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { z } from "zod";
4
+
5
+ const GLOBAL_DIR = path.join(
6
+ process.env.HOME ?? "",
7
+ ".local",
8
+ "share",
9
+ "dotllm",
10
+ );
11
+ const STORE_DIR = path.join(GLOBAL_DIR, "store");
12
+ const GLOBAL_FILE = path.join(GLOBAL_DIR, "dotllm.json");
13
+ const LOCAL_DIR = ".llm";
14
+ const LOCAL_FILE = path.join(LOCAL_DIR, "dotllm.json");
15
+ const REF_DIR = path.join(LOCAL_DIR, "reference");
16
+
17
+ const RepoEntry = z.object({
18
+ kind: z.enum(["url", "file"]),
19
+ name: z.string(),
20
+ uri: z.string(),
21
+ description: z.string(),
22
+ });
23
+
24
+ export type RepoEntry = z.infer<typeof RepoEntry>;
25
+
26
+ const GlobalShape = z.object({
27
+ repos: z.array(RepoEntry),
28
+ });
29
+
30
+ const LocalShape = z.object({
31
+ refs: z.record(z.string(), RepoEntry),
32
+ });
33
+
34
+ const LocalLegacyShape = z.object({
35
+ refs: z.array(z.string()),
36
+ });
37
+
38
+ export namespace Config {
39
+ export function storeDir(): string {
40
+ return STORE_DIR;
41
+ }
42
+
43
+ export function refDir(): string {
44
+ return REF_DIR;
45
+ }
46
+
47
+ export namespace Global {
48
+ export type Shape = z.infer<typeof GlobalShape>;
49
+
50
+ export function read(): Shape {
51
+ const raw = readJson(GLOBAL_FILE);
52
+ if (!raw) return { repos: [] };
53
+ const result = GlobalShape.safeParse(raw);
54
+ if (!result.success) return { repos: [] };
55
+ return result.data;
56
+ }
57
+
58
+ export function write(config: Shape): void {
59
+ fs.mkdirSync(GLOBAL_DIR, { recursive: true });
60
+ fs.writeFileSync(GLOBAL_FILE, JSON.stringify(config, null, 2) + "\n");
61
+ }
62
+
63
+ export function find(config: Shape, name: string): RepoEntry | undefined {
64
+ return config.repos.find((r) => r.name === name);
65
+ }
66
+
67
+ export function add(config: Shape, entry: RepoEntry): Shape {
68
+ const filtered = config.repos.filter((r) => r.name !== entry.name);
69
+ return { repos: [...filtered, entry] };
70
+ }
71
+
72
+ export function remove(config: Shape, name: string): Shape {
73
+ return { repos: config.repos.filter((r) => r.name !== name) };
74
+ }
75
+ }
76
+
77
+ export namespace Local {
78
+ export type Shape = z.infer<typeof LocalShape>;
79
+
80
+ export function read(): Shape {
81
+ const raw = readJson(LOCAL_FILE);
82
+ if (!raw) return { refs: {} };
83
+ const result = LocalShape.safeParse(raw);
84
+ if (result.success) return result.data;
85
+ const legacy = LocalLegacyShape.safeParse(raw);
86
+ if (!legacy.success) return { refs: {} };
87
+
88
+ const global = Global.read();
89
+ const rows = legacy.data.refs
90
+ .map((name) => {
91
+ const repo = Global.find(global, name);
92
+ if (!repo) return null;
93
+ return [name, repo] as const;
94
+ })
95
+ .filter((row): row is readonly [string, RepoEntry] => row !== null);
96
+
97
+ return { refs: Object.fromEntries(rows) };
98
+ }
99
+
100
+ export function write(config: Shape): void {
101
+ fs.mkdirSync(LOCAL_DIR, { recursive: true });
102
+ fs.writeFileSync(LOCAL_FILE, JSON.stringify(config, null, 2) + "\n");
103
+ }
104
+
105
+ export function has(config: Shape, name: string): boolean {
106
+ return Object.prototype.hasOwnProperty.call(config.refs, name);
107
+ }
108
+
109
+ export function add(config: Shape, repo: RepoEntry): Shape {
110
+ return { refs: { ...config.refs, [repo.name]: repo } };
111
+ }
112
+
113
+ export function remove(config: Shape, name: string): Shape {
114
+ const refs = Object.fromEntries(
115
+ Object.entries(config.refs).filter(([key]) => key !== name),
116
+ );
117
+ return { refs };
118
+ }
119
+ }
120
+ }
121
+
122
+ function readJson(filepath: string): unknown {
123
+ if (!fs.existsSync(filepath)) return null;
124
+ const raw = fs.readFileSync(filepath, "utf-8");
125
+ return JSON.parse(raw);
126
+ }
@@ -0,0 +1,6 @@
1
+ export { Config, type RepoEntry } from "@spader/dotllm/core/config";
2
+ export { add, type AddResult } from "@spader/dotllm/core/add";
3
+ export { remove, type RemoveResult } from "@spader/dotllm/core/remove";
4
+ export { link } from "@spader/dotllm/core/link";
5
+ export { unlink, type UnlinkResult } from "@spader/dotllm/core/unlink";
6
+ export { pull, sync, type PullError, type PullResult, type SyncResult } from "@spader/dotllm/core/sync";
@@ -0,0 +1,16 @@
1
+ import { Config } from "@spader/dotllm/core/config";
2
+ import { sync, type SyncResult } from "@spader/dotllm/core/sync";
3
+
4
+ export function link(names: string[]): SyncResult {
5
+ const global = Config.Global.read();
6
+ const rows = names
7
+ .map((name) => {
8
+ const repo = Config.Global.find(global, name);
9
+ if (!repo) return null;
10
+ return [name, repo] as const;
11
+ })
12
+ .filter((row): row is readonly [string, (typeof global.repos)[number]] => row !== null);
13
+
14
+ Config.Local.write({ refs: Object.fromEntries(rows) });
15
+ return sync();
16
+ }
@@ -0,0 +1,33 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { Config } from "@spader/dotllm/core/config";
4
+
5
+ export type RemoveResult = {
6
+ ok: true;
7
+ } | {
8
+ ok: false;
9
+ error: string;
10
+ };
11
+
12
+ export function remove(name: string): RemoveResult {
13
+ const global = Config.Global.read();
14
+ const found = Config.Global.find(global, name);
15
+
16
+ if (!found) {
17
+ return { ok: false, error: `No repo named "${name}" in registry` };
18
+ }
19
+
20
+ const target = path.join(Config.storeDir(), name);
21
+ if (fs.existsSync(target)) {
22
+ const stat = fs.lstatSync(target);
23
+ if (stat.isSymbolicLink()) {
24
+ fs.unlinkSync(target);
25
+ }
26
+ if (stat.isDirectory()) {
27
+ fs.rmSync(target, { recursive: true, force: true });
28
+ }
29
+ }
30
+
31
+ Config.Global.write(Config.Global.remove(global, name));
32
+ return { ok: true };
33
+ }
@@ -0,0 +1,136 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { Config } from "@spader/dotllm/core/config";
4
+
5
+ export type SyncResult = {
6
+ linked: string[];
7
+ removed: string[];
8
+ missing: string[];
9
+ unchanged: string[];
10
+ };
11
+
12
+ type PullState = {
13
+ name: string;
14
+ error: string;
15
+ } | null;
16
+
17
+ export type PullError = {
18
+ name: string;
19
+ error: string;
20
+ };
21
+
22
+ export type PullResult = {
23
+ count: number;
24
+ failed: PullError[];
25
+ };
26
+
27
+ export async function pull(names: string[]): Promise<PullResult> {
28
+ const states = await Promise.all(
29
+ names.map(async (name): Promise<PullState> => {
30
+ const cwd = path.join(Config.refDir(), name);
31
+ if (!fs.existsSync(cwd)) {
32
+ return { name, error: "reference directory missing" };
33
+ }
34
+
35
+ const proc = Bun.spawn(["git", "pull", "--ff-only"], {
36
+ cwd,
37
+ stdout: "pipe",
38
+ stderr: "pipe",
39
+ });
40
+ const [code, out, err] = await Promise.all([
41
+ proc.exited,
42
+ new Response(proc.stdout).text(),
43
+ new Response(proc.stderr).text(),
44
+ ]);
45
+ const msg = `${out}\n${err}`.trim();
46
+
47
+ if (code !== 0) {
48
+ return { name, error: msg || "git pull failed" };
49
+ }
50
+
51
+ return null;
52
+ }),
53
+ );
54
+
55
+ const failed = states.filter((state): state is PullError => state !== null);
56
+
57
+ return { count: names.length, failed };
58
+ }
59
+
60
+ export function sync(): SyncResult {
61
+ const local = Config.Local.read();
62
+ const global = Config.Global.read();
63
+ const merged = Object.values(local.refs).reduce(
64
+ (config, repo) => Config.Global.add(config, repo),
65
+ global,
66
+ );
67
+ if (Object.keys(local.refs).length > 0) {
68
+ Config.Global.write(merged);
69
+ }
70
+
71
+ const refDir = Config.refDir();
72
+ fs.mkdirSync(refDir, { recursive: true });
73
+ fs.mkdirSync(Config.storeDir(), { recursive: true });
74
+
75
+ const linked: string[] = [];
76
+ const removed: string[] = [];
77
+ const missing: string[] = [];
78
+ const unchanged: string[] = [];
79
+
80
+ const wanted = new Set(Object.keys(local.refs));
81
+ for (const entry of fs.readdirSync(refDir)) {
82
+ if (wanted.has(entry)) continue;
83
+ const target = path.join(refDir, entry);
84
+ const stat = fs.lstatSync(target);
85
+ if (stat.isSymbolicLink()) {
86
+ fs.unlinkSync(target);
87
+ removed.push(entry);
88
+ }
89
+ }
90
+
91
+ for (const [name, repo] of Object.entries(local.refs)) {
92
+ const store = path.join(Config.storeDir(), name);
93
+ if (!fs.existsSync(store)) {
94
+ fs.rmSync(store, { recursive: true, force: true });
95
+ }
96
+
97
+ if (!fs.existsSync(store) && repo.kind === "url") {
98
+ const clone = Bun.spawnSync(["git", "clone", repo.uri, store], {
99
+ stdout: "pipe",
100
+ stderr: "pipe",
101
+ });
102
+ if (clone.exitCode !== 0) {
103
+ missing.push(name);
104
+ continue;
105
+ }
106
+ }
107
+
108
+ if (!fs.existsSync(store) && repo.kind === "file") {
109
+ if (!fs.existsSync(repo.uri)) {
110
+ missing.push(name);
111
+ continue;
112
+ }
113
+ if (!fs.statSync(repo.uri).isDirectory()) {
114
+ missing.push(name);
115
+ continue;
116
+ }
117
+ fs.symlinkSync(repo.uri, store, "dir");
118
+ }
119
+
120
+ if (!fs.existsSync(store)) {
121
+ missing.push(name);
122
+ continue;
123
+ }
124
+
125
+ const target = path.join(refDir, name);
126
+ if (fs.existsSync(target)) {
127
+ unchanged.push(name);
128
+ continue;
129
+ }
130
+
131
+ fs.symlinkSync(store, target, "dir");
132
+ linked.push(name);
133
+ }
134
+
135
+ return { linked, removed, missing, unchanged };
136
+ }
@@ -0,0 +1,26 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { Config } from "@spader/dotllm/core/config";
4
+
5
+ export type UnlinkResult = {
6
+ ok: true;
7
+ } | {
8
+ ok: false;
9
+ error: string;
10
+ };
11
+
12
+ export function unlink(name: string): UnlinkResult {
13
+ const local = Config.Local.read();
14
+
15
+ if (!Config.Local.has(local, name)) {
16
+ return { ok: false, error: `"${name}" is not linked in local config` };
17
+ }
18
+
19
+ const target = path.join(Config.refDir(), name);
20
+ if (fs.existsSync(target)) {
21
+ fs.unlinkSync(target);
22
+ }
23
+
24
+ Config.Local.write(Config.Local.remove(local, name));
25
+ return { ok: true };
26
+ }