@olwiba/dx 0.0.23 → 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
  }
@@ -1936,17 +2352,33 @@ var init_generate_assets = __esm({
1936
2352
  ];
1937
2353
  }
1938
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
1939
2362
  var DEFAULT_SOURCE = "https://olwiba.com/skills/manifest.json";
1940
2363
  var [command, subcommand] = process.argv.slice(2);
1941
2364
  if (command === "skills" && subcommand === "install") {
1942
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
+ }
1943
2375
  } else if (command === "ascii-gif") {
1944
2376
  await runAsciiGif();
1945
2377
  } else if (command === "generate-assets") {
1946
2378
  await runGenerateAssets();
1947
2379
  } else {
1948
2380
  process.stdout.write(
1949
- "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>] [--og-component <svg-or-image-path>]\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"
1950
2382
  );
1951
2383
  }
1952
2384
  async function runSkillsInstall() {
@@ -1960,7 +2392,7 @@ async function runSkillsInstall() {
1960
2392
  return;
1961
2393
  }
1962
2394
  const targetDir = target === "amp" ? join(".amp", "skills") : join(".claude", "skills");
1963
- process.stdout.write(`Fetching manifest from ${source}
2395
+ process.stdout.write(`Fetching manifest from ${safeUrlForDisplay(source)}
1964
2396
  `);
1965
2397
  let manifest;
1966
2398
  try {
@@ -1968,8 +2400,7 @@ async function runSkillsInstall() {
1968
2400
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1969
2401
  manifest = await res.json();
1970
2402
  } catch (err) {
1971
- const message = err instanceof Error ? err.message : String(err);
1972
- process.stderr.write(`Failed to fetch manifest: ${message}
2403
+ process.stderr.write(`Failed to fetch manifest: ${safeRequestError(err)}
1973
2404
  `);
1974
2405
  process.exitCode = 1;
1975
2406
  return;
@@ -1988,10 +2419,22 @@ async function runSkillsInstall() {
1988
2419
  let installed = 0;
1989
2420
  let failed = 0;
1990
2421
  for (const skill of selected) {
1991
- 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
+ }
1992
2435
  const skillPath = join(skillDir, "SKILL.md");
1993
- const url = new URL(skill.contentUrl, source).toString();
1994
2436
  try {
2437
+ const url = new URL(skill.contentUrl, source).toString();
1995
2438
  const res = await fetch(url);
1996
2439
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1997
2440
  const content = await res.text();
@@ -2001,8 +2444,7 @@ async function runSkillsInstall() {
2001
2444
  `);
2002
2445
  installed++;
2003
2446
  } catch (err) {
2004
- const message = err instanceof Error ? err.message : String(err);
2005
- process.stderr.write(` \u2717 ${skill.slug}: ${message}
2447
+ process.stderr.write(` \u2717 ${skill.slug}: ${safeRequestError(err)}
2006
2448
  `);
2007
2449
  failed++;
2008
2450
  }
@@ -2012,6 +2454,23 @@ ${installed} installed, ${failed} failed
2012
2454
  `);
2013
2455
  process.stdout.write(`Location: ${targetDir}/
2014
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";
2015
2474
  }
2016
2475
  async function selectSkills(skills, flags) {
2017
2476
  if (flags.all === "true") return skills;