lacy 1.8.11 → 1.8.13

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.
Files changed (109) hide show
  1. package/.claude/settings.local.json +26 -0
  2. package/.github/FUNDING.yml +3 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.yml +49 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
  6. package/.github/PULL_REQUEST_TEMPLATE.md +17 -0
  7. package/.github/SECURITY.md +32 -0
  8. package/.github/assets/logo-horizontal-dark.png +0 -0
  9. package/.github/assets/logo-horizontal-dark.svg +17 -0
  10. package/.github/assets/logo-horizontal.png +0 -0
  11. package/.github/assets/logo-horizontal.svg +17 -0
  12. package/.github/assets/logo.png +0 -0
  13. package/.github/assets/logo.svg +12 -0
  14. package/.github/assets/social-preview.png +0 -0
  15. package/.github/assets/social-preview.svg +50 -0
  16. package/.github/dependabot.yml +21 -0
  17. package/.github/workflows/ci.yml +80 -0
  18. package/.github/workflows/dependabot-auto-merge.yml +32 -0
  19. package/CHANGELOG.md +366 -0
  20. package/CLAUDE.md +340 -0
  21. package/CONTRIBUTING.md +141 -0
  22. package/LICENSE +110 -0
  23. package/README.md +201 -31
  24. package/RELEASING.md +148 -0
  25. package/STYLE.md +202 -0
  26. package/assets/hero.jpeg +0 -0
  27. package/assets/mode-indicators.jpeg +0 -0
  28. package/assets/real-time-indicator.jpeg +0 -0
  29. package/assets/supported-tools.jpeg +0 -0
  30. package/bin/lacy +1028 -0
  31. package/docs/ADDING-BACKENDS.md +124 -0
  32. package/docs/DEVTO-ARTICLE.md +94 -0
  33. package/docs/DOCS.md +68 -0
  34. package/docs/GROWTH-STRATEGY.md +119 -0
  35. package/docs/HN-RESPONSES.md +122 -0
  36. package/docs/LAUNCH-COPY-FINAL.md +105 -0
  37. package/docs/MARKETING.md +411 -0
  38. package/docs/NATURAL_LANGUAGE_DETECTION.md +204 -0
  39. package/docs/UGC_VIDEO_SCRIPT.md +114 -0
  40. package/docs/articles/devto-how-i-made-my-terminal-understand-english.md +117 -0
  41. package/docs/demo-color-transition.gif +0 -0
  42. package/docs/demo-full.gif +0 -0
  43. package/docs/demo-indicator.gif +0 -0
  44. package/docs/launch-thread-may6.sh +158 -0
  45. package/docs/videos/README.md +189 -0
  46. package/docs/videos/generate_frames.py +510 -0
  47. package/docs/videos/generate_frames_v2.py +729 -0
  48. package/docs/videos/generate_short.py +328 -0
  49. package/docs/videos/generate_short_v2.py +526 -0
  50. package/docs/videos/lacy-shell-demo-v2.mp4 +0 -0
  51. package/docs/videos/lacy-shell-demo.mp4 +0 -0
  52. package/docs/videos/lacy-shell-short-v2.mp4 +0 -0
  53. package/docs/videos/lacy-shell-short.mp4 +0 -0
  54. package/install.sh +1009 -0
  55. package/lacy.plugin.bash +75 -0
  56. package/lacy.plugin.fish +43 -0
  57. package/lacy.plugin.zsh +65 -0
  58. package/lib/animations.zsh +3 -0
  59. package/lib/bash/completions.bash +40 -0
  60. package/lib/bash/execute.bash +233 -0
  61. package/lib/bash/init.bash +40 -0
  62. package/lib/bash/keybindings.bash +134 -0
  63. package/lib/bash/prompt.bash +85 -0
  64. package/lib/commands/info.sh +25 -0
  65. package/lib/config.zsh +3 -0
  66. package/lib/constants.zsh +3 -0
  67. package/lib/core/animations.sh +271 -0
  68. package/lib/core/commands.sh +297 -0
  69. package/lib/core/config.sh +340 -0
  70. package/lib/core/constants.sh +366 -0
  71. package/lib/core/context.sh +260 -0
  72. package/lib/core/detection.sh +417 -0
  73. package/lib/core/mcp.sh +741 -0
  74. package/lib/core/modes.sh +123 -0
  75. package/lib/core/preheat.sh +496 -0
  76. package/lib/core/spinner.sh +174 -0
  77. package/lib/core/telemetry.sh +99 -0
  78. package/lib/detection.zsh +3 -0
  79. package/lib/execute.zsh +3 -0
  80. package/lib/fish/config.fish +66 -0
  81. package/lib/fish/detection.fish +90 -0
  82. package/lib/fish/execute.fish +105 -0
  83. package/lib/fish/keybindings.fish +42 -0
  84. package/lib/fish/prompt.fish +30 -0
  85. package/lib/keybindings.zsh +3 -0
  86. package/lib/mcp.zsh +3 -0
  87. package/lib/modes.zsh +3 -0
  88. package/lib/preheat.zsh +3 -0
  89. package/lib/prompt.zsh +3 -0
  90. package/lib/spinner.zsh +3 -0
  91. package/lib/zsh/completions.zsh +60 -0
  92. package/lib/zsh/execute.zsh +294 -0
  93. package/lib/zsh/init.zsh +26 -0
  94. package/lib/zsh/keybindings.zsh +551 -0
  95. package/lib/zsh/prompt.zsh +90 -0
  96. package/package.json +42 -27
  97. package/packages/lacy/README.md +61 -0
  98. package/packages/lacy/commands/info.sh +25 -0
  99. package/{index.mjs → packages/lacy/index.mjs} +247 -20
  100. package/packages/lacy/package-lock.json +71 -0
  101. package/packages/lacy/package.json +42 -0
  102. package/script/release.ts +487 -0
  103. package/squirrel.toml +36 -0
  104. package/tests/test_bash.bash +163 -0
  105. package/tests/test_core.sh +607 -0
  106. package/tests/test_gemini.sh +119 -0
  107. package/tests/test_gemini_mcp.sh +126 -0
  108. package/tests/test_preheat_server.zsh +446 -0
  109. package/uninstall.sh +52 -0
@@ -0,0 +1,487 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Release script for lacy-shell
4
+ *
5
+ * Usage:
6
+ * bun run release # interactive — prompts for bump type
7
+ * bun run release patch # patch bump (1.5.3 → 1.5.4)
8
+ * bun run release minor # minor bump (1.5.3 → 1.6.0)
9
+ * bun run release major # major bump (1.5.3 → 2.0.0)
10
+ * bun run release 1.6.0 # explicit version
11
+ * bun run release --beta # beta release (1.5.3 → 1.5.4-beta.0)
12
+ * bun run release:beta # alias for --beta
13
+ */
14
+
15
+ import * as p from "@clack/prompts";
16
+ import pc from "picocolors";
17
+ import { execSync } from "node:child_process";
18
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { resolve } from "node:path";
20
+
21
+ const ROOT = resolve(import.meta.dirname, "..");
22
+ const PACKAGE_JSONS = [
23
+ resolve(ROOT, "package.json"),
24
+ resolve(ROOT, "packages/lacy/package.json"),
25
+ ];
26
+ const BIN_LACY = resolve(ROOT, "bin/lacy");
27
+ const HOMEBREW_TAP = resolve(ROOT, "../homebrew-tap");
28
+ const HOMEBREW_FORMULA = resolve(HOMEBREW_TAP, "Formula/lacy.rb");
29
+
30
+ // ── Helpers ──────────────────────────────────────────────────────────────────
31
+
32
+ function run(cmd: string, opts?: { cwd?: string; stdio?: "inherit" | "pipe" }) {
33
+ return execSync(cmd, {
34
+ cwd: opts?.cwd ?? ROOT,
35
+ stdio: opts?.stdio ?? "pipe",
36
+ encoding: "utf-8",
37
+ shell: "/bin/bash",
38
+ });
39
+ }
40
+
41
+ function readJson(path: string) {
42
+ return JSON.parse(readFileSync(path, "utf-8"));
43
+ }
44
+
45
+ function writeJson(path: string, data: Record<string, unknown>) {
46
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
47
+ }
48
+
49
+ function bumpVersion(
50
+ current: string,
51
+ type: "patch" | "minor" | "major",
52
+ ): string {
53
+ const base = current.replace(/-.*$/, ""); // strip any prerelease suffix
54
+ const [major, minor, patch] = base.split(".").map(Number);
55
+ switch (type) {
56
+ case "major":
57
+ return `${major + 1}.0.0`;
58
+ case "minor":
59
+ return `${major}.${minor + 1}.0`;
60
+ case "patch":
61
+ return `${major}.${minor}.${patch + 1}`;
62
+ }
63
+ }
64
+
65
+ function bumpBeta(current: string, bumpType: "patch" | "minor" | "major"): string {
66
+ const betaMatch = current.match(/^(.+)-beta\.(\d+)$/);
67
+ if (betaMatch) {
68
+ // Already a beta — increment the beta number
69
+ return `${betaMatch[1]}-beta.${Number(betaMatch[2]) + 1}`;
70
+ }
71
+ // Not a beta — bump the base version and start at beta.0
72
+ const base = bumpVersion(current, bumpType);
73
+ return `${base}-beta.0`;
74
+ }
75
+
76
+ function cancelled(): never {
77
+ p.cancel("Release cancelled.");
78
+ process.exit(0);
79
+ }
80
+
81
+ /** Check if an execSync error is an npm OTP error */
82
+ function isOtpError(err: unknown): boolean {
83
+ const check = (s: string) =>
84
+ s.includes("EOTP") || s.includes("one-time pass");
85
+ if (err instanceof Error) {
86
+ if (check(err.message)) return true;
87
+ if ("stderr" in err && typeof err.stderr === "string" && check(err.stderr))
88
+ return true;
89
+ if ("stdout" in err && typeof err.stdout === "string" && check(err.stdout))
90
+ return true;
91
+ }
92
+ return check(String(err));
93
+ }
94
+
95
+ /** Get a human-readable error message from an execSync error */
96
+ function errorText(err: unknown): string {
97
+ if (err instanceof Error) {
98
+ if ("stderr" in err && typeof err.stderr === "string" && err.stderr.trim())
99
+ return err.stderr.trim();
100
+ return err.message;
101
+ }
102
+ return String(err);
103
+ }
104
+
105
+ // ── npm publish ──────────────────────────────────────────────────────────────
106
+
107
+ async function publishNpm(cwd: string, beta = false): Promise<boolean> {
108
+ const tagFlag = beta ? " --tag beta" : "";
109
+ // First attempt: without OTP (works if token is valid and 2FA isn't required)
110
+ const spinner = p.spinner();
111
+ spinner.start(`Publishing to npm${beta ? " (beta)" : ""}`);
112
+ try {
113
+ run(`npm publish --access public${tagFlag}`, { cwd });
114
+ spinner.stop(pc.green(`Published to npm${beta ? " (beta)" : ""}`));
115
+ return true;
116
+ } catch (err: unknown) {
117
+ spinner.stop(pc.yellow("npm publish failed"));
118
+ p.log.message(pc.dim(errorText(err)));
119
+ }
120
+
121
+ // Retry loop — let the user login, provide OTP, or skip
122
+ while (true) {
123
+ const action = await p.select({
124
+ message: "How would you like to proceed?",
125
+ options: [
126
+ {
127
+ value: "otp" as const,
128
+ label: "Enter OTP",
129
+ hint: "publish with one-time password",
130
+ },
131
+ {
132
+ value: "login" as const,
133
+ label: "Log in to npm",
134
+ hint: "run npm login, then retry",
135
+ },
136
+ {
137
+ value: "retry" as const,
138
+ label: "Retry publish",
139
+ hint: "try again without OTP",
140
+ },
141
+ {
142
+ value: "skip" as const,
143
+ label: "Skip npm publish",
144
+ hint: "continue to Homebrew",
145
+ },
146
+ ],
147
+ });
148
+
149
+ if (p.isCancel(action)) {
150
+ p.log.warn("Skipping npm publish");
151
+ return false;
152
+ }
153
+
154
+ if (action === "skip") {
155
+ p.log.info("Skipping npm publish");
156
+ return false;
157
+ }
158
+
159
+ if (action === "login") {
160
+ p.log.info("Running npm login...");
161
+ try {
162
+ run("npm login", { cwd, stdio: "inherit" });
163
+ p.log.success("Logged in to npm");
164
+ } catch {
165
+ p.log.error("npm login failed");
166
+ }
167
+ continue;
168
+ }
169
+
170
+ // "otp" or "retry"
171
+ let otpFlag = "";
172
+ if (action === "otp") {
173
+ const otp = await p.text({
174
+ message: "npm OTP",
175
+ placeholder: "123456",
176
+ validate: (v) => {
177
+ if (!v || !/^\d{6}$/.test(v.trim())) return "OTP must be 6 digits";
178
+ },
179
+ });
180
+
181
+ if (p.isCancel(otp)) continue; // back to menu
182
+ otpFlag = ` --otp ${otp}`;
183
+ }
184
+
185
+ const retrySpinner = p.spinner();
186
+ retrySpinner.start("Publishing to npm");
187
+ try {
188
+ run(`npm publish --access public${tagFlag}${otpFlag}`, { cwd });
189
+ retrySpinner.stop(pc.green("Published to npm"));
190
+ return true;
191
+ } catch (err: unknown) {
192
+ retrySpinner.stop(pc.red("npm publish failed"));
193
+ p.log.message(pc.dim(errorText(err)));
194
+ }
195
+ }
196
+ }
197
+
198
+ // ── Homebrew ─────────────────────────────────────────────────────────────────
199
+
200
+ async function publishHomebrew(tag: string, version: string) {
201
+ if (!existsSync(HOMEBREW_FORMULA)) {
202
+ p.log.warn(
203
+ `Homebrew tap not found at ${pc.dim(HOMEBREW_FORMULA)}. Skipping.`,
204
+ );
205
+ return;
206
+ }
207
+
208
+ const doHomebrew = await p.confirm({
209
+ message: "Update Homebrew formula?",
210
+ initialValue: true,
211
+ });
212
+ if (p.isCancel(doHomebrew) || !doHomebrew) {
213
+ p.log.info("Skipping Homebrew");
214
+ return;
215
+ }
216
+
217
+ const brewSpinner = p.spinner();
218
+ brewSpinner.start("Updating Homebrew formula");
219
+
220
+ try {
221
+ // Pull first to avoid rebase conflicts with unstaged changes
222
+ run("git checkout main", { cwd: HOMEBREW_TAP });
223
+ run("git pull --rebase origin main", { cwd: HOMEBREW_TAP });
224
+
225
+ // Download the release tarball and compute SHA256
226
+ const tarballUrl = `https://github.com/lacymorrow/lacy/archive/refs/tags/${tag}.tar.gz`;
227
+ const sha256 = run(
228
+ `curl -sL "${tarballUrl}" | shasum -a 256 | cut -d' ' -f1`,
229
+ ).trim();
230
+
231
+ if (!sha256 || sha256.length !== 64) {
232
+ brewSpinner.stop(pc.red("Failed to compute SHA256"));
233
+ p.log.error(`Got: ${sha256}`);
234
+ return;
235
+ }
236
+
237
+ // Update the formula
238
+ let formula = readFileSync(HOMEBREW_FORMULA, "utf-8");
239
+ formula = formula.replace(
240
+ /url "https:\/\/github\.com\/lacymorrow\/lacy\/archive\/refs\/tags\/v[^"]+\.tar\.gz"/,
241
+ `url "${tarballUrl}"`,
242
+ );
243
+ formula = formula.replace(
244
+ /sha256 "[a-f0-9]+"/,
245
+ `sha256 "${sha256}"`,
246
+ );
247
+ writeFileSync(HOMEBREW_FORMULA, formula);
248
+
249
+ // Commit and push
250
+ run("git add Formula/lacy.rb", { cwd: HOMEBREW_TAP });
251
+ run(`git commit -m "lacy: update to ${tag}"`, { cwd: HOMEBREW_TAP });
252
+ run("git push", { cwd: HOMEBREW_TAP });
253
+
254
+ brewSpinner.stop(`Homebrew formula updated to ${pc.green(tag)}`);
255
+ } catch (err: unknown) {
256
+ brewSpinner.stop(pc.red("Homebrew update failed"));
257
+ p.log.error(errorText(err));
258
+ p.log.info(
259
+ `Update manually: ${pc.cyan(`edit ${HOMEBREW_FORMULA}`)}`,
260
+ );
261
+ }
262
+ }
263
+
264
+ // ── Main ─────────────────────────────────────────────────────────────────────
265
+
266
+ async function main() {
267
+ const isBeta = process.argv.includes("--beta");
268
+ const args = process.argv.slice(2).filter((a) => a !== "--beta");
269
+
270
+ console.clear();
271
+ p.intro(
272
+ pc.magenta(
273
+ pc.bold(isBeta ? " Lacy Shell — Beta Release " : " Lacy Shell — Release "),
274
+ ),
275
+ );
276
+
277
+ // Preflight checks
278
+ const preflight = p.spinner();
279
+ preflight.start("Running preflight checks");
280
+
281
+ const status = run("git status --porcelain").trim();
282
+ if (status) {
283
+ preflight.stop(pc.red("Working tree is not clean"));
284
+ p.log.error("Commit or stash changes first:");
285
+ console.log(pc.dim(status));
286
+ process.exit(1);
287
+ }
288
+
289
+ const branch = run("git branch --show-current").trim();
290
+ if (!isBeta && branch !== "main") {
291
+ preflight.stop(pc.red(`On branch '${branch}', not 'main'`));
292
+ process.exit(1);
293
+ }
294
+
295
+ preflight.stop("Preflight OK");
296
+
297
+ // Current version
298
+ const rootPkg = readJson(PACKAGE_JSONS[0]);
299
+ const currentVersion: string = rootPkg.version;
300
+
301
+ // Determine new version
302
+ const arg = args[0];
303
+ let newVersion: string;
304
+
305
+ if (isBeta) {
306
+ // Beta: pick a bump type then apply beta suffix
307
+ const isAlreadyBeta = /-beta\.\d+$/.test(currentVersion);
308
+
309
+ if (isAlreadyBeta) {
310
+ // Already on a beta — offer to bump beta number or start fresh
311
+ const selected = await p.select({
312
+ message: `Current version: ${pc.cyan(currentVersion)}. Beta bump?`,
313
+ options: [
314
+ {
315
+ value: "next" as const,
316
+ label: "next beta",
317
+ hint: `${currentVersion} → ${bumpBeta(currentVersion, "patch")}`,
318
+ },
319
+ {
320
+ value: "patch" as const,
321
+ label: "new patch beta",
322
+ hint: `${currentVersion} → ${bumpVersion(currentVersion, "patch")}-beta.0`,
323
+ },
324
+ {
325
+ value: "minor" as const,
326
+ label: "new minor beta",
327
+ hint: `${currentVersion} → ${bumpVersion(currentVersion, "minor")}-beta.0`,
328
+ },
329
+ {
330
+ value: "major" as const,
331
+ label: "new major beta",
332
+ hint: `${currentVersion} → ${bumpVersion(currentVersion, "major")}-beta.0`,
333
+ },
334
+ ],
335
+ });
336
+
337
+ if (p.isCancel(selected)) cancelled();
338
+ newVersion =
339
+ selected === "next"
340
+ ? bumpBeta(currentVersion, "patch")
341
+ : `${bumpVersion(currentVersion, selected)}-beta.0`;
342
+ } else {
343
+ // Not a beta yet — bump type then add -beta.0
344
+ if (arg === "patch" || arg === "minor" || arg === "major") {
345
+ newVersion = bumpBeta(currentVersion, arg);
346
+ } else {
347
+ const selected = await p.select({
348
+ message: `Current version: ${pc.cyan(currentVersion)}. Bump type for beta?`,
349
+ options: [
350
+ {
351
+ value: "patch" as const,
352
+ label: "patch",
353
+ hint: `${currentVersion} → ${bumpBeta(currentVersion, "patch")}`,
354
+ },
355
+ {
356
+ value: "minor" as const,
357
+ label: "minor",
358
+ hint: `${currentVersion} → ${bumpBeta(currentVersion, "minor")}`,
359
+ },
360
+ {
361
+ value: "major" as const,
362
+ label: "major",
363
+ hint: `${currentVersion} → ${bumpBeta(currentVersion, "major")}`,
364
+ },
365
+ ],
366
+ });
367
+
368
+ if (p.isCancel(selected)) cancelled();
369
+ newVersion = bumpBeta(currentVersion, selected);
370
+ }
371
+ }
372
+ } else if (arg === "patch" || arg === "minor" || arg === "major") {
373
+ newVersion = bumpVersion(currentVersion, arg);
374
+ } else if (arg && /^\d+\.\d+\.\d+/.test(arg)) {
375
+ newVersion = arg;
376
+ } else {
377
+ const selected = await p.select({
378
+ message: `Current version: ${pc.cyan(currentVersion)}. Bump type?`,
379
+ options: [
380
+ {
381
+ value: "patch" as const,
382
+ label: "patch",
383
+ hint: `${currentVersion} → ${bumpVersion(currentVersion, "patch")}`,
384
+ },
385
+ {
386
+ value: "minor" as const,
387
+ label: "minor",
388
+ hint: `${currentVersion} → ${bumpVersion(currentVersion, "minor")}`,
389
+ },
390
+ {
391
+ value: "major" as const,
392
+ label: "major",
393
+ hint: `${currentVersion} → ${bumpVersion(currentVersion, "major")}`,
394
+ },
395
+ ],
396
+ });
397
+
398
+ if (p.isCancel(selected)) cancelled();
399
+ newVersion = bumpVersion(currentVersion, selected);
400
+ }
401
+
402
+ const tag = `v${newVersion}`;
403
+
404
+ const proceed = await p.confirm({
405
+ message: `Release ${pc.cyan(currentVersion)} → ${pc.green(newVersion)} (${tag})?`,
406
+ });
407
+ if (p.isCancel(proceed) || !proceed) cancelled();
408
+
409
+ // 1. Bump versions
410
+ const bumpSpinner = p.spinner();
411
+ bumpSpinner.start("Bumping versions");
412
+ for (const path of PACKAGE_JSONS) {
413
+ const pkg = readJson(path);
414
+ pkg.version = newVersion;
415
+ writeJson(path, pkg);
416
+ }
417
+ // Also bump the fallback version in bin/lacy
418
+ if (existsSync(BIN_LACY)) {
419
+ let binContent = readFileSync(BIN_LACY, "utf-8");
420
+ binContent = binContent.replace(
421
+ /^VERSION_FALLBACK="[^"]*"/m,
422
+ `VERSION_FALLBACK="${newVersion}"`,
423
+ );
424
+ writeFileSync(BIN_LACY, binContent);
425
+ }
426
+ bumpSpinner.stop(
427
+ `Updated ${pc.cyan("package.json")} + ${pc.cyan("bin/lacy")} → ${pc.green(newVersion)}`,
428
+ );
429
+
430
+ // 2. Changelog
431
+ const lastTag = run(
432
+ "git describe --tags --abbrev=0 2>/dev/null || echo ''",
433
+ ).trim();
434
+ let changelog = "";
435
+ if (lastTag) {
436
+ changelog = run(
437
+ `git log ${lastTag}..HEAD --pretty=format:"- %s (%h)" --no-merges`,
438
+ ).trim();
439
+ }
440
+ if (!changelog) changelog = `- Release ${tag}`;
441
+
442
+ p.note(changelog, "Changelog");
443
+
444
+ // 3. Commit + tag
445
+ const gitSpinner = p.spinner();
446
+ gitSpinner.start("Committing and tagging");
447
+ run("git add package.json packages/lacy/package.json bin/lacy");
448
+ run(`git commit -m "release: ${tag}" --no-verify`);
449
+ run(`git tag ${tag}`);
450
+ gitSpinner.stop(`Committed and tagged ${pc.green(tag)}`);
451
+
452
+ // 4. Push
453
+ const pushSpinner = p.spinner();
454
+ pushSpinner.start("Pushing to GitHub");
455
+ run(`git push origin ${branch} --no-verify`);
456
+ run(`git push origin ${tag}`);
457
+ pushSpinner.stop("Pushed to GitHub");
458
+
459
+ // 5. GitHub release
460
+ const releaseSpinner = p.spinner();
461
+ releaseSpinner.start("Creating GitHub release");
462
+ const releaseNotes = `## Changes\n\n${changelog}`;
463
+ run(
464
+ `gh release create ${tag} --title "${tag}" --notes "${releaseNotes.replace(/"/g, '\\"')}"${isBeta ? " --prerelease" : ""}`,
465
+ );
466
+ releaseSpinner.stop("GitHub release created");
467
+
468
+ // 6. npm publish
469
+ await publishNpm(resolve(ROOT, "packages/lacy"), isBeta);
470
+
471
+ // 7. Homebrew (skip for beta releases)
472
+ if (!isBeta) {
473
+ await publishHomebrew(tag, newVersion);
474
+ } else {
475
+ p.log.info("Skipping Homebrew for beta release");
476
+ }
477
+
478
+ // Done
479
+ p.outro(
480
+ `${pc.green("✓")} Released ${pc.green(tag)} — ${pc.cyan(`https://github.com/lacymorrow/lacy/releases/tag/${tag}`)}`,
481
+ );
482
+ }
483
+
484
+ main().catch((err) => {
485
+ p.log.error(err.message ?? err);
486
+ process.exit(1);
487
+ });
package/squirrel.toml ADDED
@@ -0,0 +1,36 @@
1
+ [project]
2
+ domains = []
3
+ name = "lacy-sh"
4
+
5
+ [crawler]
6
+ max_pages = 100
7
+ coverage = "surface"
8
+ delay_ms = 100
9
+ timeout_ms = 30000
10
+ user_agent = ""
11
+ follow_redirects = true
12
+ concurrency = 5
13
+ per_host_concurrency = 2
14
+ per_host_delay_ms = 200
15
+ include = []
16
+ exclude = []
17
+ allow_query_params = []
18
+ drop_query_prefixes = [ "utm_", "gclid", "fbclid" ]
19
+ respect_robots = true
20
+ breadth_first = true
21
+ max_prefix_budget = 0.25
22
+
23
+ [rules]
24
+ enable = [ "*" ]
25
+ disable = []
26
+
27
+ [external_links]
28
+ enabled = true
29
+ cache_ttl_days = 7
30
+ timeout_ms = 10000
31
+ concurrency = 5
32
+
33
+ [output]
34
+ format = "console"
35
+
36
+ [rule_options]
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Bash-specific integration tests for Lacy Shell
4
+ # Requires Bash 4+ (macOS: brew install bash)
5
+ #
6
+ # Usage:
7
+ # /opt/homebrew/bin/bash tests/test_bash.bash # macOS with Homebrew bash
8
+ # bash tests/test_bash.bash # Linux
9
+
10
+ # Bail if Bash < 4
11
+ if [[ ${BASH_VERSINFO[0]} -lt 4 ]]; then
12
+ echo "SKIP: Bash 4+ required (have ${BASH_VERSION})"
13
+ echo " macOS: /opt/homebrew/bin/bash tests/test_bash.bash"
14
+ exit 0
15
+ fi
16
+
17
+ # Note: no set -e — tests use functions that return nonzero intentionally
18
+
19
+ echo "Testing Lacy Shell Bash adapter in: bash ${BASH_VERSION}"
20
+ echo "================================================================"
21
+
22
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
24
+
25
+ # Set up environment as the plugin would
26
+ LACY_SHELL_TYPE="bash"
27
+ _LACY_ARR_OFFSET=0
28
+ LACY_SHELL_DIR="$REPO_DIR"
29
+
30
+ # Source core + bash adapter
31
+ source "$REPO_DIR/lib/core/constants.sh"
32
+ source "$REPO_DIR/lib/core/config.sh"
33
+ source "$REPO_DIR/lib/core/modes.sh"
34
+ source "$REPO_DIR/lib/core/spinner.sh"
35
+ source "$REPO_DIR/lib/core/mcp.sh"
36
+ source "$REPO_DIR/lib/core/preheat.sh"
37
+ source "$REPO_DIR/lib/core/detection.sh"
38
+
39
+ # Source bash-specific modules (skip keybindings — needs interactive shell)
40
+ source "$REPO_DIR/lib/bash/prompt.bash"
41
+ source "$REPO_DIR/lib/bash/execute.bash"
42
+
43
+ PASS=0
44
+ FAIL=0
45
+
46
+ assert_eq() {
47
+ local test_name="$1"
48
+ local expected="$2"
49
+ local actual="$3"
50
+
51
+ if [[ "$expected" == "$actual" ]]; then
52
+ PASS=$(( PASS + 1 ))
53
+ else
54
+ echo " FAIL: $test_name"
55
+ echo " Expected: $expected"
56
+ echo " Actual: $actual"
57
+ FAIL=$(( FAIL + 1 ))
58
+ fi
59
+ }
60
+
61
+ # ============================================================================
62
+ # Detection in Bash 4+
63
+ # ============================================================================
64
+
65
+ echo ""
66
+ echo "--- Bash Detection ---"
67
+
68
+ LACY_SHELL_CURRENT_MODE="auto"
69
+
70
+ assert_eq "ls → shell" "shell" "$(lacy_shell_classify_input 'ls -la')"
71
+ assert_eq "what files → agent" "agent" "$(lacy_shell_classify_input 'what files')"
72
+ assert_eq "fix the bug → agent" "agent" "$(lacy_shell_classify_input 'fix the bug')"
73
+ assert_eq "yes → agent" "agent" "$(lacy_shell_classify_input 'yes lets go')"
74
+ assert_eq "empty → neutral" "neutral" "$(lacy_shell_classify_input '')"
75
+
76
+ # Bash-specific: ${var,,} lowercase works
77
+ assert_eq "lowercase" "hello" "$(_lacy_lowercase 'HELLO')"
78
+
79
+ # ============================================================================
80
+ # Bash Prompt
81
+ # ============================================================================
82
+
83
+ echo ""
84
+ echo "--- Bash Prompt ---"
85
+
86
+ # Test prompt building
87
+ PS1='$ '
88
+ LACY_SHELL_ORIGINAL_PS1=""
89
+ LACY_SHELL_BASE_PS1=""
90
+ LACY_SHELL_PROMPT_INITIALIZED=false
91
+ lacy_shell_setup_prompt
92
+ lacy_shell_init_prompt_once
93
+
94
+ # After init, PS1 should contain the mode badge
95
+ if [[ "$PS1" == *"AUTO"* ]]; then
96
+ PASS=$(( PASS + 1 ))
97
+ else
98
+ echo " FAIL: PS1 should contain AUTO badge"
99
+ echo " PS1: $PS1"
100
+ FAIL=$(( FAIL + 1 ))
101
+ fi
102
+
103
+ # Mode switch should update prompt
104
+ lacy_shell_set_mode "shell"
105
+ lacy_shell_update_prompt
106
+ if [[ "$PS1" == *"SHELL"* ]]; then
107
+ PASS=$(( PASS + 1 ))
108
+ else
109
+ echo " FAIL: PS1 should contain SHELL badge after mode switch"
110
+ FAIL=$(( FAIL + 1 ))
111
+ fi
112
+
113
+ # ============================================================================
114
+ # Bash Mode Switching
115
+ # ============================================================================
116
+
117
+ echo ""
118
+ echo "--- Bash Modes ---"
119
+
120
+ LACY_SHELL_MODE_FILE="/tmp/lacy_test_mode_bash_$$"
121
+
122
+ lacy_shell_set_mode "auto"
123
+ assert_eq "auto mode" "auto" "$LACY_SHELL_CURRENT_MODE"
124
+
125
+ lacy_shell_toggle_mode
126
+ assert_eq "toggle → shell" "shell" "$LACY_SHELL_CURRENT_MODE"
127
+
128
+ lacy_shell_toggle_mode
129
+ assert_eq "toggle → agent" "agent" "$LACY_SHELL_CURRENT_MODE"
130
+
131
+ lacy_shell_toggle_mode
132
+ assert_eq "toggle → auto" "auto" "$LACY_SHELL_CURRENT_MODE"
133
+
134
+ rm -f "$LACY_SHELL_MODE_FILE"
135
+
136
+ # ============================================================================
137
+ # Bash Functions
138
+ # ============================================================================
139
+
140
+ echo ""
141
+ echo "--- Bash Functions ---"
142
+
143
+ # Check that command functions exist
144
+ if type ask &>/dev/null; then PASS=$(( PASS + 1 )); else echo " FAIL: ask function missing"; FAIL=$(( FAIL + 1 )); fi
145
+ if type mode &>/dev/null; then PASS=$(( PASS + 1 )); else echo " FAIL: mode function missing"; FAIL=$(( FAIL + 1 )); fi
146
+ if type tool &>/dev/null; then PASS=$(( PASS + 1 )); else echo " FAIL: tool function missing"; FAIL=$(( FAIL + 1 )); fi
147
+ if type quit &>/dev/null; then PASS=$(( PASS + 1 )); else echo " FAIL: quit function missing"; FAIL=$(( FAIL + 1 )); fi
148
+
149
+ # ============================================================================
150
+ # Results
151
+ # ============================================================================
152
+
153
+ echo ""
154
+ echo "================================================================"
155
+ echo "Results: ${PASS} passed, ${FAIL} failed"
156
+
157
+ if [[ $FAIL -gt 0 ]]; then
158
+ echo "FAILED"
159
+ exit 1
160
+ else
161
+ echo "ALL TESTS PASSED"
162
+ exit 0
163
+ fi