@olwiba/dx 0.0.23 → 0.0.27

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,169 @@ var init_generate_assets = __esm({
1936
2352
  ];
1937
2353
  }
1938
2354
  });
2355
+
2356
+ // src/env-check.ts
2357
+ var env_check_exports = {};
2358
+ __export(env_check_exports, {
2359
+ checkEnv: () => checkEnv,
2360
+ formatEnvReport: () => formatEnvReport,
2361
+ parseEnv: () => parseEnv
2362
+ });
2363
+ function parseEnv(source) {
2364
+ const keys = /* @__PURE__ */ new Map();
2365
+ const duplicates = [];
2366
+ const malformed = [];
2367
+ source.split(/\r?\n/).forEach((raw, index) => {
2368
+ const line = raw.trim();
2369
+ if (line === "" || line.startsWith("#")) return;
2370
+ const withoutExport = line.replace(/^export\s+/, "");
2371
+ const eq = withoutExport.indexOf("=");
2372
+ if (eq <= 0) {
2373
+ malformed.push({ line: index + 1, text: withoutExport.slice(0, 24) });
2374
+ return;
2375
+ }
2376
+ const key = withoutExport.slice(0, eq).trim();
2377
+ const value = withoutExport.slice(eq + 1).trim();
2378
+ if (keys.has(key)) duplicates.push(key);
2379
+ keys.set(key, value !== "" && value !== '""' && value !== "''");
2380
+ });
2381
+ return { keys, duplicates, malformed };
2382
+ }
2383
+ function findRenameCandidate(unknownKey, knownKeys) {
2384
+ const normalise = (key) => key.replace(/[^A-Z0-9]/gi, "").toUpperCase();
2385
+ const target = normalise(unknownKey);
2386
+ return knownKeys.find((known) => {
2387
+ const candidate = normalise(known);
2388
+ if (candidate === target) return true;
2389
+ return candidate.length >= 6 && (target.endsWith(candidate) || target.startsWith(candidate));
2390
+ });
2391
+ }
2392
+ function checkEnv({
2393
+ example,
2394
+ actual,
2395
+ /** Keys allowed to be absent, e.g. optional credentials. */
2396
+ optional = []
2397
+ }) {
2398
+ const exampleEnv = parseEnv(example);
2399
+ const actualEnv = parseEnv(actual);
2400
+ const optionalSet = new Set(optional);
2401
+ const exampleKeys = [...exampleEnv.keys.keys()];
2402
+ const findings = [];
2403
+ let okCount = 0;
2404
+ for (const key of exampleKeys) {
2405
+ if (!actualEnv.keys.has(key)) {
2406
+ if (!optionalSet.has(key)) findings.push({ kind: "missing", key });
2407
+ continue;
2408
+ }
2409
+ const hasValue = actualEnv.keys.get(key) === true;
2410
+ const exampleHasValue = exampleEnv.keys.get(key) === true;
2411
+ if (!hasValue && exampleHasValue && !optionalSet.has(key)) {
2412
+ findings.push({ kind: "empty", key });
2413
+ continue;
2414
+ }
2415
+ okCount += 1;
2416
+ }
2417
+ for (const key of actualEnv.keys.keys()) {
2418
+ if (exampleEnv.keys.has(key)) continue;
2419
+ const looksLike = findRenameCandidate(key, exampleKeys);
2420
+ findings.push(looksLike ? { kind: "renamed", key, looksLike } : { kind: "unknown", key });
2421
+ }
2422
+ for (const key of actualEnv.duplicates) findings.push({ kind: "duplicate", key });
2423
+ for (const line of actualEnv.malformed) findings.push({ kind: "malformed", ...line });
2424
+ return {
2425
+ findings,
2426
+ okCount,
2427
+ exampleCount: exampleEnv.keys.size,
2428
+ actualCount: actualEnv.keys.size
2429
+ };
2430
+ }
2431
+ function formatEnvReport(result) {
2432
+ const lines = [];
2433
+ lines.push(
2434
+ ` ${result.actualCount} keys checked against ${result.exampleCount} in the example, ${result.okCount} correct`
2435
+ );
2436
+ if (result.findings.length === 0) {
2437
+ lines.push("\nPASS \u2014 environment matches the example.");
2438
+ return lines.join("\n");
2439
+ }
2440
+ for (const kind of ORDER) {
2441
+ const group = result.findings.filter((finding) => finding.kind === kind);
2442
+ if (group.length === 0) continue;
2443
+ lines.push(`
2444
+ ${LABELS[kind]}:`);
2445
+ for (const finding of group) {
2446
+ if (finding.kind === "renamed") {
2447
+ lines.push(` \u2717 ${finding.key} \u2192 did you mean ${finding.looksLike}?`);
2448
+ } else if (finding.kind === "malformed") {
2449
+ lines.push(` \u2717 line ${finding.line}: ${finding.text}\u2026`);
2450
+ } else {
2451
+ lines.push(` \u2717 ${finding.key}`);
2452
+ }
2453
+ }
2454
+ }
2455
+ const renamed = result.findings.filter((finding) => finding.kind === "renamed").length;
2456
+ if (renamed > 0) {
2457
+ lines.push(
2458
+ `
2459
+ ${renamed} key(s) look renamed. The setting they were meant to carry is unset,
2460
+ which usually means a default is in force that nobody chose.`
2461
+ );
2462
+ }
2463
+ lines.push(`
2464
+ FAIL \u2014 ${result.findings.length} finding(s).`);
2465
+ return lines.join("\n");
2466
+ }
2467
+ var ORDER, LABELS;
2468
+ var init_env_check = __esm({
2469
+ "src/env-check.ts"() {
2470
+ ORDER = [
2471
+ "renamed",
2472
+ "missing",
2473
+ "empty",
2474
+ "malformed",
2475
+ "duplicate",
2476
+ "unknown"
2477
+ ];
2478
+ LABELS = {
2479
+ renamed: "Renamed \u2014 set under a name nothing reads",
2480
+ missing: "Missing",
2481
+ empty: "Present but empty",
2482
+ malformed: "Malformed line",
2483
+ duplicate: "Set more than once",
2484
+ unknown: "Unknown \u2014 not in the example"
2485
+ };
2486
+ }
2487
+ });
2488
+
2489
+ // src/skills.ts
2490
+ function isSafeSkillSlug(slug) {
2491
+ return slug !== "." && slug !== ".." && /^[A-Za-z0-9._-]+$/.test(slug);
2492
+ }
2493
+
2494
+ // src/cli.ts
2495
+ var BREAK = "\n";
1939
2496
  var DEFAULT_SOURCE = "https://olwiba.com/skills/manifest.json";
1940
2497
  var [command, subcommand] = process.argv.slice(2);
1941
2498
  if (command === "skills" && subcommand === "install") {
1942
2499
  await runSkillsInstall();
2500
+ } else if ((command === "worktree" || command === "wt") && subcommand === "cleanup") {
2501
+ const { runWorktreeCleanup: runWorktreeCleanup2 } = await Promise.resolve().then(() => (init_worktree_cleanup(), worktree_cleanup_exports));
2502
+ try {
2503
+ process.exitCode = await runWorktreeCleanup2(process.argv.slice(4));
2504
+ } catch (error) {
2505
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}
2506
+ `);
2507
+ process.exitCode = 1;
2508
+ }
1943
2509
  } else if (command === "ascii-gif") {
1944
2510
  await runAsciiGif();
1945
2511
  } else if (command === "generate-assets") {
1946
2512
  await runGenerateAssets();
2513
+ } else if (command === "env-check" || command === "env") {
2514
+ process.exitCode = await runEnvCheck();
1947
2515
  } else {
1948
2516
  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"
2517
+ "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
2518
  );
1951
2519
  }
1952
2520
  async function runSkillsInstall() {
@@ -1960,7 +2528,7 @@ async function runSkillsInstall() {
1960
2528
  return;
1961
2529
  }
1962
2530
  const targetDir = target === "amp" ? join(".amp", "skills") : join(".claude", "skills");
1963
- process.stdout.write(`Fetching manifest from ${source}
2531
+ process.stdout.write(`Fetching manifest from ${safeUrlForDisplay(source)}
1964
2532
  `);
1965
2533
  let manifest;
1966
2534
  try {
@@ -1968,8 +2536,7 @@ async function runSkillsInstall() {
1968
2536
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1969
2537
  manifest = await res.json();
1970
2538
  } catch (err) {
1971
- const message = err instanceof Error ? err.message : String(err);
1972
- process.stderr.write(`Failed to fetch manifest: ${message}
2539
+ process.stderr.write(`Failed to fetch manifest: ${safeRequestError(err)}
1973
2540
  `);
1974
2541
  process.exitCode = 1;
1975
2542
  return;
@@ -1988,10 +2555,22 @@ async function runSkillsInstall() {
1988
2555
  let installed = 0;
1989
2556
  let failed = 0;
1990
2557
  for (const skill of selected) {
1991
- const skillDir = join(installDir, skill.slug);
2558
+ if (!isSafeSkillSlug(skill.slug)) {
2559
+ process.stderr.write(" \u2717 invalid skill slug\n");
2560
+ failed++;
2561
+ continue;
2562
+ }
2563
+ const skillDir = resolve(installDir, skill.slug);
2564
+ const relativeDestination = relative(installDir, skillDir);
2565
+ if (!relativeDestination || relativeDestination === ".." || relativeDestination.startsWith(`..${sep}`) || isAbsolute(relativeDestination)) {
2566
+ process.stderr.write(` \u2717 ${skill.slug}: invalid install destination
2567
+ `);
2568
+ failed++;
2569
+ continue;
2570
+ }
1992
2571
  const skillPath = join(skillDir, "SKILL.md");
1993
- const url = new URL(skill.contentUrl, source).toString();
1994
2572
  try {
2573
+ const url = new URL(skill.contentUrl, source).toString();
1995
2574
  const res = await fetch(url);
1996
2575
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1997
2576
  const content = await res.text();
@@ -2001,8 +2580,7 @@ async function runSkillsInstall() {
2001
2580
  `);
2002
2581
  installed++;
2003
2582
  } catch (err) {
2004
- const message = err instanceof Error ? err.message : String(err);
2005
- process.stderr.write(` \u2717 ${skill.slug}: ${message}
2583
+ process.stderr.write(` \u2717 ${skill.slug}: ${safeRequestError(err)}
2006
2584
  `);
2007
2585
  failed++;
2008
2586
  }
@@ -2012,6 +2590,23 @@ ${installed} installed, ${failed} failed
2012
2590
  `);
2013
2591
  process.stdout.write(`Location: ${targetDir}/
2014
2592
  `);
2593
+ if (failed > 0) process.exitCode = 1;
2594
+ }
2595
+ function safeUrlForDisplay(value) {
2596
+ try {
2597
+ const url = new URL(value);
2598
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "custom source";
2599
+ url.username = "";
2600
+ url.password = "";
2601
+ url.search = "";
2602
+ url.hash = "";
2603
+ return url.toString();
2604
+ } catch {
2605
+ return "custom source";
2606
+ }
2607
+ }
2608
+ function safeRequestError(error) {
2609
+ return error instanceof Error && /^HTTP \d{3}$/.test(error.message) ? error.message : "request failed";
2015
2610
  }
2016
2611
  async function selectSkills(skills, flags) {
2017
2612
  if (flags.all === "true") return skills;
@@ -2138,3 +2733,47 @@ function parseNumberFlag(value) {
2138
2733
  const parsed = Number(value);
2139
2734
  return Number.isFinite(parsed) ? parsed : void 0;
2140
2735
  }
2736
+ async function runEnvCheck() {
2737
+ const { readFileSync: readFileSync3, existsSync: existsSync2 } = await import('fs');
2738
+ const { checkEnv: checkEnv2, formatEnvReport: formatEnvReport2 } = await Promise.resolve().then(() => (init_env_check(), env_check_exports));
2739
+ const flags = parseFlags(process.argv.slice(3));
2740
+ const examplePath = flags.example ?? ".env.example";
2741
+ if (!existsSync2(examplePath)) {
2742
+ process.stderr.write(
2743
+ `No example file at ${examplePath}.${BREAK}It is the schema this compares against; pass --example to point elsewhere.` + BREAK
2744
+ );
2745
+ return 1;
2746
+ }
2747
+ const example = readFileSync3(examplePath, "utf8");
2748
+ let actual;
2749
+ if (flags.file) {
2750
+ if (!existsSync2(flags.file)) {
2751
+ process.stderr.write(`No environment file at ${flags.file}.${BREAK}`);
2752
+ return 1;
2753
+ }
2754
+ actual = readFileSync3(flags.file, "utf8");
2755
+ } else {
2756
+ if (stdin.isTTY) {
2757
+ process.stdout.write(
2758
+ `Paste the environment below, then press ${process.platform === "win32" ? "Ctrl+Z and Enter" : "Ctrl+D"}.${BREAK}Nothing is stored or transmitted.` + BREAK + BREAK
2759
+ );
2760
+ }
2761
+ actual = await readAllStdin();
2762
+ if (actual.trim() === "") {
2763
+ process.stderr.write("Nothing to check. Pass --file <path> or paste an environment." + BREAK);
2764
+ return 1;
2765
+ }
2766
+ }
2767
+ const result = checkEnv2({
2768
+ example,
2769
+ actual,
2770
+ optional: flags.optional ? flags.optional.split(",").map((key) => key.trim()).filter(Boolean) : []
2771
+ });
2772
+ process.stdout.write(`${formatEnvReport2(result)}${BREAK}`);
2773
+ return result.findings.length > 0 ? 1 : 0;
2774
+ }
2775
+ async function readAllStdin() {
2776
+ const chunks = [];
2777
+ for await (const chunk of stdin) chunks.push(Buffer.from(chunk));
2778
+ return Buffer.concat(chunks).toString("utf8");
2779
+ }