@olwiba/dx 0.0.22 → 0.0.24

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/dist/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, writeFileSync, readFileSync } from 'fs';
3
- import path, { join, resolve } from 'path';
4
- import { createRequire } from 'module';
2
+ import { spawnSync } from 'child_process';
3
+ import { mkdirSync, writeFileSync, existsSync, realpathSync, readFileSync, lstatSync, readdirSync } from 'fs';
4
+ import path, { join, resolve, relative, sep, isAbsolute, basename } from 'path';
5
5
  import { createInterface } from 'readline/promises';
6
6
  import { stdout, stdin } from 'process';
7
+ import { createRequire } from 'module';
7
8
 
8
9
  var __defProp = Object.defineProperty;
9
10
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -15,6 +16,414 @@ var __export = (target, all) => {
15
16
  __defProp(target, name, { get: all[name], enumerable: true });
16
17
  };
17
18
 
19
+ // src/worktree-cleanup.ts
20
+ var worktree_cleanup_exports = {};
21
+ __export(worktree_cleanup_exports, {
22
+ classifyWorktree: () => classifyWorktree,
23
+ parseWorktreeList: () => parseWorktreeList,
24
+ runWorktreeCleanup: () => runWorktreeCleanup
25
+ });
26
+ function parseWorktreeList(output3) {
27
+ return output3.trim().split(/\r?\n\r?\n/).filter(Boolean).map((record) => {
28
+ const fields = /* @__PURE__ */ new Map();
29
+ for (const line of record.split(/\r?\n/)) {
30
+ const separator = line.indexOf(" ");
31
+ if (separator === -1) {
32
+ fields.set(line, "");
33
+ } else {
34
+ fields.set(line.slice(0, separator), line.slice(separator + 1));
35
+ }
36
+ }
37
+ const path2 = fields.get("worktree");
38
+ const head = fields.get("HEAD");
39
+ if (!path2 || !head) throw new Error("Unexpected output from git worktree list");
40
+ const branchRef = fields.get("branch");
41
+ return {
42
+ path: path2,
43
+ head,
44
+ ...branchRef ? { branch: branchRef.replace(/^refs\/heads\//, "") } : {}
45
+ };
46
+ });
47
+ }
48
+ function classifyWorktree(state) {
49
+ const reasons = [];
50
+ let removalReason;
51
+ if (state.isCurrent) reasons.push("current working directory");
52
+ if (!state.branch) {
53
+ if (state.headMerged) {
54
+ removalReason = `detached HEAD merged into ${state.defaultBranch}`;
55
+ } else {
56
+ reasons.push(`detached HEAD is not merged into ${state.defaultBranch}`);
57
+ }
58
+ } else if (state.remoteBranchExists) {
59
+ if (state.remoteBranchMerged) {
60
+ removalReason = `branch merged into ${state.defaultBranch}`;
61
+ } else {
62
+ reasons.push(`remote branch exists; merge into ${state.defaultBranch} not confirmed`);
63
+ }
64
+ } else if (!state.headMerged) {
65
+ reasons.push(`remote branch deleted; merge into ${state.defaultBranch} not confirmed`);
66
+ } else if (state.head === state.defaultSha) {
67
+ reasons.push(`branch points at current ${state.defaultBranch}; automatic removal skipped`);
68
+ } else {
69
+ removalReason = `branch merged into ${state.defaultBranch} (remote branch deleted)`;
70
+ }
71
+ if (!state.pathExists) reasons.push("worktree path missing");
72
+ if (state.dirty) reasons.push("uncommitted changes");
73
+ if (state.aheadCount > 0) {
74
+ reasons.push(
75
+ `${state.aheadCount} commit(s) ahead of ${state.remote}/${state.defaultBranch}`
76
+ );
77
+ }
78
+ return reasons.length > 0 ? { removable: false, reasons } : { removable: true, reason: removalReason ?? "safe to remove" };
79
+ }
80
+ async function runWorktreeCleanup(args, hooks = {}) {
81
+ let options;
82
+ try {
83
+ options = parseCleanupArgs(args);
84
+ } catch (error) {
85
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}
86
+ `);
87
+ writeUsage(process.stderr);
88
+ return 1;
89
+ }
90
+ if (args.includes("--help") || args.includes("-h")) {
91
+ writeUsage(process.stdout);
92
+ return 0;
93
+ }
94
+ let repoPath;
95
+ try {
96
+ repoPath = resolveRepo(options.repo, options.reposRoot);
97
+ } catch (error) {
98
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}
99
+ `);
100
+ return 1;
101
+ }
102
+ const repoName = basename(repoPath);
103
+ process.stdout.write(`Repo: ${repoName}
104
+ Path: ${repoPath}
105
+ `);
106
+ if (options.fetch) {
107
+ process.stdout.write(`Fetching latest from ${options.remote}...
108
+ `);
109
+ const fetched = runGit(repoPath, ["fetch", options.remote, "--prune"], true);
110
+ if (!fetched.ok) {
111
+ process.stderr.write(fetched.stderr || `git fetch failed for ${repoName}
112
+ `);
113
+ return 1;
114
+ }
115
+ }
116
+ const defaultBranch = resolveDefaultBranch(repoPath, options.remote);
117
+ const defaultRef = `${options.remote}/${defaultBranch}`;
118
+ const defaultSha = gitOutput(repoPath, ["rev-parse", defaultRef]);
119
+ if (!defaultSha) {
120
+ process.stderr.write(`Could not find ${defaultRef} for ${repoName}.
121
+ `);
122
+ return 1;
123
+ }
124
+ const worktrees = parseWorktreeList(requiredGitOutput(repoPath, ["worktree", "list", "--porcelain"]));
125
+ const additionalWorktrees = worktrees.slice(1);
126
+ if (additionalWorktrees.length === 0) {
127
+ process.stdout.write("No additional worktrees found. Nothing to clean up.\n");
128
+ return 0;
129
+ }
130
+ const callerWorktree = canonicalPath(gitOutput(process.cwd(), ["rev-parse", "--show-toplevel"]));
131
+ const removable = [];
132
+ const preserved = [];
133
+ for (const worktree of additionalWorktrees) {
134
+ const pathExists = existsSync(worktree.path);
135
+ const trackedBranch = worktree.branch ? gitOutput(repoPath, [
136
+ "for-each-ref",
137
+ "--format=%(upstream:short)",
138
+ `refs/heads/${worktree.branch}`
139
+ ]) : "";
140
+ const remoteBranch = worktree.branch ? trackedBranch.startsWith(`${options.remote}/`) ? trackedBranch : `${options.remote}/${worktree.branch}` : "";
141
+ const remoteBranchExists = Boolean(remoteBranch) && gitSucceeds(repoPath, ["rev-parse", "--verify", remoteBranch]);
142
+ const headMerged = gitIsAncestor(repoPath, worktree.head, defaultRef);
143
+ const classification = classifyWorktree({
144
+ branch: worktree.branch,
145
+ head: worktree.head,
146
+ defaultSha,
147
+ defaultBranch,
148
+ remote: options.remote,
149
+ isCurrent: canonicalPath(worktree.path) === callerWorktree,
150
+ pathExists,
151
+ dirty: pathExists && Boolean(gitOutput(worktree.path, ["status", "--porcelain"])),
152
+ aheadCount: worktree.branch && pathExists ? Number(gitOutput(worktree.path, ["rev-list", "--count", `${defaultRef}..HEAD`]) || 0) : 0,
153
+ remoteBranchExists,
154
+ remoteBranchMerged: remoteBranchExists && gitIsAncestor(repoPath, remoteBranch, defaultRef),
155
+ headMerged
156
+ });
157
+ if (classification.removable) {
158
+ removable.push({
159
+ ...worktree,
160
+ reason: classification.reason,
161
+ sizeMB: directorySizeMB(worktree.path)
162
+ });
163
+ } else {
164
+ preserved.push({ ...worktree, reasons: classification.reasons });
165
+ }
166
+ }
167
+ if (preserved.length > 0) {
168
+ process.stdout.write("\nWorktrees needing attention (not automatically removed):\n");
169
+ for (const item of preserved) {
170
+ process.stdout.write(` ${item.branch ?? "(detached)"}
171
+ ${item.reasons.join("; ")}
172
+ ${item.path}
173
+ `);
174
+ }
175
+ }
176
+ if (removable.length === 0) {
177
+ process.stdout.write(`
178
+ No worktrees are safely removable.
179
+ Summary: 0 safely removable; ${preserved.length} needing attention.
180
+ `);
181
+ return 0;
182
+ }
183
+ const totalMB = removable.reduce((total, item) => total + item.sizeMB, 0);
184
+ process.stdout.write("\nWorktrees to remove:\n");
185
+ for (const item of removable) {
186
+ process.stdout.write(` ${item.branch ?? "(detached)"} - ${item.reason} - ~${item.sizeMB} MB
187
+ ${item.path}
188
+ `);
189
+ }
190
+ process.stdout.write(`Total space to reclaim: ~${totalMB} MB
191
+ `);
192
+ if (options.dryRun) {
193
+ process.stdout.write(`
194
+ [DRY RUN] No changes made.
195
+ Summary: ${removable.length} safely removable; ${preserved.length} needing attention.
196
+ `);
197
+ return 0;
198
+ }
199
+ if (!options.force && !await confirmRemoval()) {
200
+ process.stdout.write(`Aborted.
201
+ Summary: 0 removed; ${preserved.length} needing attention.
202
+ `);
203
+ return 0;
204
+ }
205
+ let removedCount = 0;
206
+ let reclaimedMB = 0;
207
+ const skipped = [];
208
+ for (const item of removable) {
209
+ hooks.beforeRemoval?.(item);
210
+ const current = inspectWorktree(repoPath, item.path, options.remote, defaultBranch);
211
+ if (!current.classification.removable) {
212
+ skipped.push({
213
+ item,
214
+ error: `changed since planning: ${current.classification.reasons.join("; ")}`
215
+ });
216
+ continue;
217
+ }
218
+ process.stdout.write(`Removing worktree: ${item.branch ?? "(detached)"} ...
219
+ `);
220
+ const removal = runGit(repoPath, ["worktree", "remove", item.path, "--force"]);
221
+ if (!removal.ok) {
222
+ skipped.push({ item, error: removal.stderr.trim() || "git worktree remove failed" });
223
+ continue;
224
+ }
225
+ removedCount++;
226
+ reclaimedMB += item.sizeMB;
227
+ hooks.afterWorktreeRemoval?.(item);
228
+ if (current.worktree.branch) {
229
+ const branchRemoval = runGit(repoPath, ["branch", "-D", current.worktree.branch]);
230
+ if (!branchRemoval.ok) {
231
+ skipped.push({
232
+ item,
233
+ error: `worktree removed, but local branch deletion failed: ${branchRemoval.stderr.trim() || "git branch -D failed"}`
234
+ });
235
+ continue;
236
+ }
237
+ }
238
+ }
239
+ if (skipped.length > 0) {
240
+ process.stdout.write("\nWorktree cleanup issues:\n");
241
+ for (const { item, error } of skipped) {
242
+ process.stdout.write(` ${item.branch ?? "(detached)"}: ${error}
243
+ ${item.path}
244
+ `);
245
+ }
246
+ }
247
+ process.stdout.write(`
248
+ Done. Removed ${removedCount} worktree(s), reclaimed ~${reclaimedMB} MB; skipped ${skipped.length}.
249
+ `);
250
+ process.stdout.write(`Summary: ${preserved.length} worktree(s) still need attention (see report above).
251
+ `);
252
+ return skipped.length > 0 ? 1 : 0;
253
+ }
254
+ function inspectWorktree(repoPath, worktreePath, remote, defaultBranch) {
255
+ const listed = parseWorktreeList(requiredGitOutput(repoPath, ["worktree", "list", "--porcelain"]));
256
+ const expectedPath = pathIdentity(worktreePath);
257
+ const worktree = listed.find((candidate) => pathIdentity(candidate.path) === expectedPath) ?? { path: worktreePath, head: "", branch: void 0 };
258
+ const pathExists = existsSync(worktree.path);
259
+ const defaultRef = `${remote}/${defaultBranch}`;
260
+ const defaultSha = gitOutput(repoPath, ["rev-parse", defaultRef]);
261
+ const trackedBranch = worktree.branch ? gitOutput(repoPath, ["for-each-ref", "--format=%(upstream:short)", `refs/heads/${worktree.branch}`]) : "";
262
+ const remoteBranch = worktree.branch ? trackedBranch.startsWith(`${remote}/`) ? trackedBranch : `${remote}/${worktree.branch}` : "";
263
+ const remoteBranchExists = Boolean(remoteBranch) && gitSucceeds(repoPath, ["rev-parse", "--verify", remoteBranch]);
264
+ const currentWorktree = canonicalPath(gitOutput(process.cwd(), ["rev-parse", "--show-toplevel"]));
265
+ return {
266
+ worktree,
267
+ classification: classifyWorktree({
268
+ branch: worktree.branch,
269
+ head: worktree.head,
270
+ defaultSha,
271
+ defaultBranch,
272
+ remote,
273
+ isCurrent: expectedPath !== "" && expectedPath === currentWorktree,
274
+ pathExists,
275
+ dirty: pathExists && Boolean(gitOutput(worktree.path, ["status", "--porcelain"])),
276
+ aheadCount: worktree.branch && pathExists ? Number(gitOutput(worktree.path, ["rev-list", "--count", `${defaultRef}..HEAD`]) || 0) : 0,
277
+ remoteBranchExists,
278
+ remoteBranchMerged: remoteBranchExists && gitIsAncestor(repoPath, remoteBranch, defaultRef),
279
+ headMerged: Boolean(worktree.head) && gitIsAncestor(repoPath, worktree.head, defaultRef)
280
+ })
281
+ };
282
+ }
283
+ function parseCleanupArgs(args) {
284
+ const options = {
285
+ remote: "origin",
286
+ dryRun: false,
287
+ force: false,
288
+ fetch: true
289
+ };
290
+ for (let index = 0; index < args.length; index++) {
291
+ const arg = args[index];
292
+ if (arg === "--dry-run" || arg.toLowerCase() === "-dryrun") options.dryRun = true;
293
+ else if (arg === "--force" || arg.toLowerCase() === "-force") options.force = true;
294
+ else if (arg === "--no-fetch") options.fetch = false;
295
+ else if (arg === "--help" || arg === "-h") continue;
296
+ else if (arg === "--repos-root" || arg === "--remote") {
297
+ const value = args[++index];
298
+ if (!value) throw new Error(`${arg} requires a value`);
299
+ if (arg === "--repos-root") options.reposRoot = value;
300
+ else options.remote = value;
301
+ } else if (arg.startsWith("--repos-root=")) options.reposRoot = arg.slice(13);
302
+ else if (arg.startsWith("--remote=")) options.remote = arg.slice(9);
303
+ else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
304
+ else if (!options.repo) options.repo = arg;
305
+ else throw new Error(`Unexpected argument: ${arg}`);
306
+ }
307
+ return options;
308
+ }
309
+ function resolveRepo(repoInput, reposRootInput) {
310
+ if (!repoInput) {
311
+ const root = gitOutput(process.cwd(), ["rev-parse", "--show-toplevel"]);
312
+ if (!root) throw new Error("Current directory is not inside a git repository. Provide a repo path or name.");
313
+ return realpathSync(root);
314
+ }
315
+ const directPath = isAbsolute(repoInput) ? repoInput : resolve(process.cwd(), repoInput);
316
+ if (existsSync(directPath)) return resolveGitRoot(directPath);
317
+ const reposRoot = resolve(process.cwd(), reposRootInput ?? "repos");
318
+ if (!existsSync(reposRoot)) {
319
+ throw new Error(`Repo '${repoInput}' was not found. Provide its path or use --repos-root.`);
320
+ }
321
+ const matches = findRepos(reposRoot).filter((path2) => basename(path2).toLowerCase() === repoInput.toLowerCase());
322
+ if (matches.length === 1) return matches[0];
323
+ if (matches.length > 1) throw new Error(`Repo name '${repoInput}' is ambiguous. Provide a repo path instead.`);
324
+ throw new Error(`Unknown repo '${repoInput}' under ${reposRoot}.`);
325
+ }
326
+ function resolveGitRoot(path2) {
327
+ const root = gitOutput(path2, ["rev-parse", "--show-toplevel"]);
328
+ if (!root) throw new Error(`Path is not inside a git repository: ${path2}`);
329
+ return realpathSync(root);
330
+ }
331
+ function findRepos(root) {
332
+ const repos = [];
333
+ const visit = (directory) => {
334
+ if (existsSync(join(directory, ".git"))) {
335
+ repos.push(realpathSync(directory));
336
+ return;
337
+ }
338
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
339
+ if (!entry.isDirectory() || entry.name === "node_modules" || entry.name.startsWith(".")) continue;
340
+ visit(join(directory, entry.name));
341
+ }
342
+ };
343
+ visit(root);
344
+ return repos;
345
+ }
346
+ function resolveDefaultBranch(repoPath, remote) {
347
+ const symbolic = gitOutput(repoPath, ["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`]);
348
+ if (symbolic) return symbolic.replace(`${remote}/`, "");
349
+ if (gitSucceeds(repoPath, ["rev-parse", "--verify", `${remote}/main`])) return "main";
350
+ if (gitSucceeds(repoPath, ["rev-parse", "--verify", `${remote}/master`])) return "master";
351
+ throw new Error(`Could not resolve the default branch for remote '${remote}'.`);
352
+ }
353
+ function canonicalPath(path2) {
354
+ if (!path2 || !existsSync(path2)) return "";
355
+ const canonical = realpathSync(path2);
356
+ return process.platform === "win32" ? canonical.toLowerCase() : canonical;
357
+ }
358
+ function pathIdentity(path2) {
359
+ const identity = existsSync(path2) ? realpathSync(path2) : resolve(path2);
360
+ return process.platform === "win32" ? identity.toLowerCase() : identity;
361
+ }
362
+ function gitIsAncestor(repoPath, ancestor, descendant) {
363
+ return gitSucceeds(repoPath, ["merge-base", "--is-ancestor", ancestor, descendant]);
364
+ }
365
+ function gitSucceeds(repoPath, args) {
366
+ return runGit(repoPath, args).ok;
367
+ }
368
+ function requiredGitOutput(repoPath, args) {
369
+ const result = runGit(repoPath, args);
370
+ if (!result.ok) throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
371
+ return result.stdout.trim();
372
+ }
373
+ function gitOutput(repoPath, args) {
374
+ const result = runGit(repoPath, args);
375
+ return result.ok ? result.stdout.trim() : "";
376
+ }
377
+ function runGit(repoPath, args, showOutput = false) {
378
+ const result = spawnSync("git", ["-C", repoPath, ...args], {
379
+ encoding: "utf8",
380
+ stdio: showOutput ? ["inherit", "pipe", "pipe"] : "pipe"
381
+ });
382
+ return {
383
+ ok: !result.error && result.status === 0,
384
+ stdout: result.stdout ?? "",
385
+ stderr: result.error?.message ?? result.stderr ?? ""
386
+ };
387
+ }
388
+ function directorySizeMB(path2) {
389
+ if (!existsSync(path2)) return 0;
390
+ let bytes = 0;
391
+ const visit = (entryPath) => {
392
+ try {
393
+ const stat = lstatSync(entryPath);
394
+ if (stat.isSymbolicLink()) return;
395
+ if (stat.isFile()) {
396
+ bytes += stat.size;
397
+ return;
398
+ }
399
+ if (stat.isDirectory()) {
400
+ for (const entry of readdirSync(entryPath)) visit(join(entryPath, entry));
401
+ }
402
+ } catch {
403
+ }
404
+ };
405
+ visit(path2);
406
+ return Math.round(bytes / 1024 / 1024);
407
+ }
408
+ async function confirmRemoval() {
409
+ const readline = createInterface({ input: stdin, output: stdout });
410
+ try {
411
+ const answer = await readline.question("\nRemove these worktrees? (y/N) ");
412
+ return ["y", "yes"].includes(answer.trim().toLowerCase());
413
+ } finally {
414
+ readline.close();
415
+ }
416
+ }
417
+ function writeUsage(stream) {
418
+ stream.write(
419
+ "Usage: dx worktree cleanup [repo-name-or-path] [--repos-root <path>] [--remote <name>] [--dry-run] [--force] [--no-fetch]\n\nWith no repo, the current git repository is used. A repo name is searched for under ./repos by default.\n"
420
+ );
421
+ }
422
+ var init_worktree_cleanup = __esm({
423
+ "src/worktree-cleanup.ts"() {
424
+ }
425
+ });
426
+
18
427
  // src/ascii/compose.ts
19
428
  function composeAsciiText(font, text) {
20
429
  const charBlocks = [];
@@ -1517,6 +1926,7 @@ function getGlitterIntensity(cell, loop) {
1517
1926
  return Math.max(0.55, Math.min(1, 0.68 + flow + sparkBoost));
1518
1927
  }
1519
1928
  function createRowColorPalette(options) {
1929
+ assertGifPaletteSize("row-color", 1 + options.rowColors.length * options.levels);
1520
1930
  const bg = parseColor(options.backgroundColor);
1521
1931
  const blend = parseColor(options.blendColor);
1522
1932
  const table = [bg.r, bg.g, bg.b];
@@ -1557,6 +1967,7 @@ function resolveFont(font) {
1557
1967
  return readFileSync(font, "utf-8");
1558
1968
  }
1559
1969
  function createMultiAccentPalette(options) {
1970
+ assertGifPaletteSize("accent", 1 + (1 + options.accentColors.length) * options.levels);
1560
1971
  const bg = parseColor(options.backgroundColor);
1561
1972
  const blend = parseColor(options.blendColor);
1562
1973
  const base = parseColor(options.color);
@@ -1577,8 +1988,13 @@ function createMultiAccentPalette(options) {
1577
1988
  while (table.length < 256 * 3) table.push(0, 0, 0);
1578
1989
  return { table: Uint8Array.from(table.slice(0, 256 * 3)) };
1579
1990
  }
1580
- function parseColor(input2) {
1581
- const color = input2.trim();
1991
+ function assertGifPaletteSize(mode, entries) {
1992
+ if (entries > 256) {
1993
+ throw new Error(`GIF palette supports at most 256 entries; ${mode} mode requires ${entries}`);
1994
+ }
1995
+ }
1996
+ function parseColor(input3) {
1997
+ const color = input3.trim();
1582
1998
  const shortHex = /^#([0-9a-f]{3})$/i.exec(color);
1583
1999
  if (shortHex) {
1584
2000
  const [r, g, b] = shortHex[1].split("").map((part) => Number.parseInt(part + part, 16));
@@ -1601,7 +2017,7 @@ function parseColor(input2) {
1601
2017
  b: clampByte(Number.parseInt(rgb[3], 10))
1602
2018
  };
1603
2019
  }
1604
- throw new Error(`Unsupported color "${input2}". Use #rgb, #rrggbb, or rgb(r,g,b).`);
2020
+ throw new Error(`Unsupported color "${input3}". Use #rgb, #rrggbb, or rgb(r,g,b).`);
1605
2021
  }
1606
2022
  function mix(a, b, amount) {
1607
2023
  return {
@@ -1645,8 +2061,8 @@ function lzwEncode(indices, minCodeSize) {
1645
2061
  const clearCode = 1 << minCodeSize;
1646
2062
  const endCode = clearCode + 1;
1647
2063
  let codeSize = minCodeSize + 1;
1648
- const output2 = [];
1649
- const writeCode = createBitWriter(output2);
2064
+ const output3 = [];
2065
+ const writeCode = createBitWriter(output3);
1650
2066
  writeCode(clearCode, codeSize);
1651
2067
  let codesSinceClear = 0;
1652
2068
  for (const index of indices) {
@@ -1660,14 +2076,14 @@ function lzwEncode(indices, minCodeSize) {
1660
2076
  }
1661
2077
  writeCode(endCode, codeSize);
1662
2078
  writeCode(-1, 0);
1663
- return Uint8Array.from(output2);
2079
+ return Uint8Array.from(output3);
1664
2080
  }
1665
- function createBitWriter(output2) {
2081
+ function createBitWriter(output3) {
1666
2082
  let buffer = 0;
1667
2083
  let bitCount = 0;
1668
2084
  return (code, size) => {
1669
2085
  if (code < 0) {
1670
- if (bitCount > 0) output2.push(buffer & 255);
2086
+ if (bitCount > 0) output3.push(buffer & 255);
1671
2087
  buffer = 0;
1672
2088
  bitCount = 0;
1673
2089
  return;
@@ -1675,7 +2091,7 @@ function createBitWriter(output2) {
1675
2091
  buffer |= code << bitCount;
1676
2092
  bitCount += size;
1677
2093
  while (bitCount >= 8) {
1678
- output2.push(buffer & 255);
2094
+ output3.push(buffer & 255);
1679
2095
  buffer >>= 8;
1680
2096
  bitCount -= 8;
1681
2097
  }
@@ -1740,6 +2156,14 @@ function getSvgInner(svgPath) {
1740
2156
  const inner = svg.replace(/<svg[^>]*>/, "").replace(/<\/svg>/, "").trim();
1741
2157
  return { inner, viewBox };
1742
2158
  }
2159
+ function resolveIcon(icon) {
2160
+ const mode = detectIconMode(icon);
2161
+ if (mode === "svg") {
2162
+ const { inner, viewBox } = getSvgInner(icon);
2163
+ return { mode, inner, viewBox };
2164
+ }
2165
+ return { mode, inner: getLucideInner(icon), viewBox: 24 };
2166
+ }
1743
2167
  function buildIconSvgLucide(inner, color, size) {
1744
2168
  const pad = Math.round(size * 0.18);
1745
2169
  const area = size - pad * 2;
@@ -1798,8 +2222,44 @@ function buildOgSvgCustom(inner, viewBox, color) {
1798
2222
  </g>
1799
2223
  </svg>`;
1800
2224
  }
2225
+ function buildOgGlowSvg(box) {
2226
+ const spread = 40;
2227
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${OG_WIDTH}" height="${OG_HEIGHT}">
2228
+ <defs>
2229
+ <filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
2230
+ <feGaussianBlur stdDeviation="46"/>
2231
+ </filter>
2232
+ </defs>
2233
+ <rect x="${box.left - spread}" y="${box.top - spread}" width="${box.width + spread * 2}" height="${box.height + spread * 2}" rx="48" fill="#ffffff" opacity="0.22" filter="url(#glow)"/>
2234
+ </svg>`;
2235
+ }
2236
+ function buildOgSvgWithSmallLogo(mode, inner, viewBox, color, name) {
2237
+ const markSize = OG_HEADER.markSize;
2238
+ const gap = 28;
2239
+ const fontSize = 56;
2240
+ const groupY = OG_HEADER.top;
2241
+ const scale = markSize / viewBox;
2242
+ const textWidth = Math.round(name.length * fontSize * 0.58);
2243
+ const totalWidth = markSize + gap + textWidth;
2244
+ const groupX = Math.round((1200 - totalWidth) / 2);
2245
+ const logoMarkup = mode === "lucide" ? (() => {
2246
+ const sw = (2.5 / scale).toFixed(4);
2247
+ return `<g transform="translate(${groupX} ${groupY}) scale(${scale})" stroke="white" fill="none" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">
2248
+ ${inner}
2249
+ </g>`;
2250
+ })() : `<g transform="translate(${groupX} ${groupY}) scale(${scale})">
2251
+ ${inner}
2252
+ </g>`;
2253
+ const textX = groupX + markSize + gap;
2254
+ const textY = groupY + markSize / 2;
2255
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${OG_WIDTH}" height="${OG_HEIGHT}">
2256
+ <rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="${color}"/>
2257
+ ${logoMarkup}
2258
+ <text x="${textX}" y="${textY}" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" font-size="${fontSize}" font-weight="700" fill="white" dominant-baseline="middle" letter-spacing="-1">${escapeXml(name)}</text>
2259
+ </svg>`;
2260
+ }
1801
2261
  async function generateAssets(config) {
1802
- const { name, icon, color, outputDir } = config;
2262
+ const { name, icon, color, outputDir, ogComponent } = config;
1803
2263
  let sharpFn;
1804
2264
  try {
1805
2265
  sharpFn = (await import('sharp')).default;
@@ -1811,17 +2271,8 @@ async function generateAssets(config) {
1811
2271
  mkdirSync(outputDir, { recursive: true });
1812
2272
  const faviconDir = join(outputDir, "favicon");
1813
2273
  mkdirSync(faviconDir, { recursive: true });
1814
- const mode = detectIconMode(icon);
1815
2274
  const written = [];
1816
- let inner;
1817
- let viewBox = 24;
1818
- if (mode === "svg") {
1819
- const parsed = getSvgInner(icon);
1820
- inner = parsed.inner;
1821
- viewBox = parsed.viewBox;
1822
- } else {
1823
- inner = getLucideInner(icon);
1824
- }
2275
+ const { mode, inner, viewBox } = resolveIcon(icon);
1825
2276
  const buildIcon = (size) => mode === "svg" ? buildIconSvgCustom(inner, viewBox, color, size) : buildIconSvgLucide(inner, color, size);
1826
2277
  for (const size of FAVICON_SIZES) {
1827
2278
  const png = await sharpFn(Buffer.from(buildIcon(size))).png().toBuffer();
@@ -1835,8 +2286,31 @@ async function generateAssets(config) {
1835
2286
  writeFileSync(dest, png);
1836
2287
  written.push(dest);
1837
2288
  }
1838
- const ogSvg = mode === "svg" ? buildOgSvgCustom(inner, viewBox, color) : buildOgSvgLucide(inner, name, color);
1839
- const ogPng = await sharpFn(Buffer.from(ogSvg)).png().toBuffer();
2289
+ const ogSvg = ogComponent ? buildOgSvgWithSmallLogo(mode, inner, viewBox, color, name) : mode === "svg" ? buildOgSvgCustom(inner, viewBox, color) : buildOgSvgLucide(inner, name, color);
2290
+ let ogPng = await sharpFn(Buffer.from(ogSvg)).png().toBuffer();
2291
+ if (ogComponent) {
2292
+ const sideMargin = 72;
2293
+ const maxWidth = OG_WIDTH - sideMargin * 2;
2294
+ const headerBottom = OG_HEADER.top + OG_HEADER.markSize + OG_HEADER.gapBelow;
2295
+ const componentBuf = readFileSync(resolve(process.cwd(), ogComponent));
2296
+ const resized = await sharpFn(componentBuf, { density: 300 }).resize({ width: maxWidth, withoutEnlargement: true }).png().toBuffer();
2297
+ const { width: compWidth = maxWidth, height: compHeight = 0 } = await sharpFn(resized).metadata();
2298
+ const left = Math.round((OG_WIDTH - compWidth) / 2);
2299
+ const fits = headerBottom + compHeight <= OG_HEIGHT;
2300
+ const top = fits ? Math.round(headerBottom + (OG_HEIGHT - headerBottom - compHeight) / 2) : headerBottom;
2301
+ ogPng = await sharpFn(ogPng).composite([
2302
+ // Glow first: it has to sit under the component, and sharp applies
2303
+ // composites in array order.
2304
+ {
2305
+ input: Buffer.from(
2306
+ buildOgGlowSvg({ left, top, width: compWidth, height: compHeight })
2307
+ ),
2308
+ left: 0,
2309
+ top: 0
2310
+ },
2311
+ { input: resized, left, top }
2312
+ ]).png().toBuffer();
2313
+ }
1840
2314
  const ogDest = join(outputDir, "og-image.png");
1841
2315
  writeFileSync(ogDest, ogPng);
1842
2316
  written.push(ogDest);
@@ -1859,9 +2333,17 @@ async function generateAssets(config) {
1859
2333
  written.push(robotsDest);
1860
2334
  return { files: written };
1861
2335
  }
1862
- var FAVICON_SIZES, NAMED_ICONS;
2336
+ var OG_WIDTH, OG_HEIGHT, OG_HEADER, FAVICON_SIZES, NAMED_ICONS;
1863
2337
  var init_generate_assets = __esm({
1864
2338
  "src/generate-assets.ts"() {
2339
+ OG_WIDTH = 1200;
2340
+ OG_HEIGHT = 630;
2341
+ OG_HEADER = {
2342
+ top: 54,
2343
+ markSize: 96,
2344
+ /** Clear space between the wordmark's baseline and the component. */
2345
+ gapBelow: 30
2346
+ };
1865
2347
  FAVICON_SIZES = [16, 32, 48, 64, 192, 512];
1866
2348
  NAMED_ICONS = [
1867
2349
  ["apple-touch-icon.png", 180],
@@ -1870,17 +2352,33 @@ var init_generate_assets = __esm({
1870
2352
  ];
1871
2353
  }
1872
2354
  });
2355
+
2356
+ // src/skills.ts
2357
+ function isSafeSkillSlug(slug) {
2358
+ return slug !== "." && slug !== ".." && /^[A-Za-z0-9._-]+$/.test(slug);
2359
+ }
2360
+
2361
+ // src/cli.ts
1873
2362
  var DEFAULT_SOURCE = "https://olwiba.com/skills/manifest.json";
1874
2363
  var [command, subcommand] = process.argv.slice(2);
1875
2364
  if (command === "skills" && subcommand === "install") {
1876
2365
  await runSkillsInstall();
2366
+ } else if ((command === "worktree" || command === "wt") && subcommand === "cleanup") {
2367
+ const { runWorktreeCleanup: runWorktreeCleanup2 } = await Promise.resolve().then(() => (init_worktree_cleanup(), worktree_cleanup_exports));
2368
+ try {
2369
+ process.exitCode = await runWorktreeCleanup2(process.argv.slice(4));
2370
+ } catch (error) {
2371
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
2372
+ `);
2373
+ process.exitCode = 1;
2374
+ }
1877
2375
  } else if (command === "ascii-gif") {
1878
2376
  await runAsciiGif();
1879
2377
  } else if (command === "generate-assets") {
1880
2378
  await runGenerateAssets();
1881
2379
  } else {
1882
2380
  process.stdout.write(
1883
- "Usage:\n dx skills install [--source <url>] [--target claude|amp] [--all] [--name a,b,c]\n dx ascii-gif --text <text> --out <file.gif>\n dx generate-assets --name <app> --icon <lucide-icon> --color <#hex> [--out <dir>]\n"
2381
+ "Usage:\n dx skills install [--source <url>] [--target claude|amp] [--all] [--name a,b,c]\n dx worktree cleanup [repo-name-or-path] [--repos-root <path>] [--remote <name>] [--dry-run] [--force] [--no-fetch]\n dx ascii-gif --text <text> --out <file.gif>\n dx generate-assets --name <app> --icon <lucide-icon> --color <#hex> [--out <dir>] [--og-component <svg-or-image-path>]\n"
1884
2382
  );
1885
2383
  }
1886
2384
  async function runSkillsInstall() {
@@ -1894,7 +2392,7 @@ async function runSkillsInstall() {
1894
2392
  return;
1895
2393
  }
1896
2394
  const targetDir = target === "amp" ? join(".amp", "skills") : join(".claude", "skills");
1897
- process.stdout.write(`Fetching manifest from ${source}
2395
+ process.stdout.write(`Fetching manifest from ${safeUrlForDisplay(source)}
1898
2396
  `);
1899
2397
  let manifest;
1900
2398
  try {
@@ -1902,8 +2400,7 @@ async function runSkillsInstall() {
1902
2400
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1903
2401
  manifest = await res.json();
1904
2402
  } catch (err) {
1905
- const message = err instanceof Error ? err.message : String(err);
1906
- process.stderr.write(`Failed to fetch manifest: ${message}
2403
+ process.stderr.write(`Failed to fetch manifest: ${safeRequestError(err)}
1907
2404
  `);
1908
2405
  process.exitCode = 1;
1909
2406
  return;
@@ -1922,10 +2419,22 @@ async function runSkillsInstall() {
1922
2419
  let installed = 0;
1923
2420
  let failed = 0;
1924
2421
  for (const skill of selected) {
1925
- const skillDir = join(installDir, skill.slug);
2422
+ if (!isSafeSkillSlug(skill.slug)) {
2423
+ process.stderr.write(" \u2717 invalid skill slug\n");
2424
+ failed++;
2425
+ continue;
2426
+ }
2427
+ const skillDir = resolve(installDir, skill.slug);
2428
+ const relativeDestination = relative(installDir, skillDir);
2429
+ if (!relativeDestination || relativeDestination === ".." || relativeDestination.startsWith(`..${sep}`) || isAbsolute(relativeDestination)) {
2430
+ process.stderr.write(` \u2717 ${skill.slug}: invalid install destination
2431
+ `);
2432
+ failed++;
2433
+ continue;
2434
+ }
1926
2435
  const skillPath = join(skillDir, "SKILL.md");
1927
- const url = new URL(skill.contentUrl, source).toString();
1928
2436
  try {
2437
+ const url = new URL(skill.contentUrl, source).toString();
1929
2438
  const res = await fetch(url);
1930
2439
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1931
2440
  const content = await res.text();
@@ -1935,8 +2444,7 @@ async function runSkillsInstall() {
1935
2444
  `);
1936
2445
  installed++;
1937
2446
  } catch (err) {
1938
- const message = err instanceof Error ? err.message : String(err);
1939
- process.stderr.write(` \u2717 ${skill.slug}: ${message}
2447
+ process.stderr.write(` \u2717 ${skill.slug}: ${safeRequestError(err)}
1940
2448
  `);
1941
2449
  failed++;
1942
2450
  }
@@ -1946,6 +2454,23 @@ ${installed} installed, ${failed} failed
1946
2454
  `);
1947
2455
  process.stdout.write(`Location: ${targetDir}/
1948
2456
  `);
2457
+ if (failed > 0) process.exitCode = 1;
2458
+ }
2459
+ function safeUrlForDisplay(value) {
2460
+ try {
2461
+ const url = new URL(value);
2462
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "custom source";
2463
+ url.username = "";
2464
+ url.password = "";
2465
+ url.search = "";
2466
+ url.hash = "";
2467
+ return url.toString();
2468
+ } catch {
2469
+ return "custom source";
2470
+ }
2471
+ }
2472
+ function safeRequestError(error) {
2473
+ return error instanceof Error && /^HTTP \d{3}$/.test(error.message) ? error.message : "request failed";
1949
2474
  }
1950
2475
  async function selectSkills(skills, flags) {
1951
2476
  if (flags.all === "true") return skills;
@@ -2044,9 +2569,10 @@ async function runGenerateAssets() {
2044
2569
  const icon = flags.icon;
2045
2570
  const color = flags.color;
2046
2571
  const outputDir = flags.out ?? flags.output ?? "public";
2572
+ const ogComponent = flags["og-component"];
2047
2573
  if (!name || !icon || !color) {
2048
2574
  process.stderr.write(
2049
- "Usage: dx generate-assets --name <app> --icon <lucide-icon> --color <#hex> [--out <dir>]\n"
2575
+ "Usage: dx generate-assets --name <app> --icon <lucide-icon> --color <#hex> [--out <dir>] [--og-component <svg-or-image-path>]\n"
2050
2576
  );
2051
2577
  process.exitCode = 1;
2052
2578
  return;
@@ -2054,7 +2580,7 @@ async function runGenerateAssets() {
2054
2580
  process.stdout.write(`Generating assets for "${name}"\u2026
2055
2581
  `);
2056
2582
  try {
2057
- const result = await generateAssets2({ name, icon, color, outputDir });
2583
+ const result = await generateAssets2({ name, icon, color, outputDir, ogComponent });
2058
2584
  process.stdout.write(`Generated ${result.files.length} files in ${outputDir}/
2059
2585
  `);
2060
2586
  for (const f of result.files) process.stdout.write(` ${f}