lacy 1.8.23 → 1.8.25

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/index.mjs CHANGED
@@ -2,30 +2,72 @@
2
2
 
3
3
  import * as p from "@clack/prompts";
4
4
  import pc from "picocolors";
5
- import { execSync, spawn } from "child_process";
5
+ import { execFileSync, execSync, spawn, spawnSync } from "child_process";
6
6
  import {
7
+ appendFileSync,
8
+ copyFileSync,
9
+ cpSync,
7
10
  existsSync,
11
+ lstatSync,
8
12
  mkdirSync,
9
- writeFileSync,
13
+ mkdtempSync,
10
14
  readFileSync,
11
- appendFileSync,
15
+ renameSync,
12
16
  rmSync,
17
+ writeFileSync,
13
18
  } from "fs";
14
19
  import { homedir, tmpdir } from "os";
15
- import { join, dirname } from "path";
20
+ import { dirname, join } from "path";
16
21
  import { fileURLToPath } from "url";
17
22
 
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+
18
25
  const INSTALL_DIR = join(homedir(), ".lacy");
19
26
  const INSTALL_DIR_OLD = join(homedir(), ".lacy-shell");
20
27
  const CONFIG_FILE = join(INSTALL_DIR, "config.yaml");
21
- const REPO_URL = "https://github.com/lacymorrow/lacy.git";
22
- const TARBALL_URL = "https://github.com/lacymorrow/lacy/archive/refs/heads";
28
+ const DEFAULT_REPO_URL = "https://github.com/lacymorrow/lacy.git";
29
+ // LACY_REPO_URL and LACY_REF let tests and CI install a local checkout.
30
+ const REPO_URL = process.env.LACY_REPO_URL || DEFAULT_REPO_URL;
31
+ const TARBALL_BASE =
32
+ process.env.LACY_TARBALL_URL ||
33
+ (REPO_URL === DEFAULT_REPO_URL
34
+ ? "https://github.com/lacymorrow/lacy/archive/refs"
35
+ : "");
36
+ const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
37
+
38
+ // User state carried across a reinstall
39
+ const STATE_FILES = ["config.yaml", "current_mode", "logs", ".last_session", ".server.pid"];
40
+
41
+ // Keep in sync with LACY_TOOL_LIST in lib/core/constants.sh (tests check).
42
+ const TOOL_LIST = ["lash", "claude", "opencode", "gemini", "codex", "hermes", "copilot", "goose", "amp", "aider"];
43
+
44
+ const TOOL_HINTS = {
45
+ lash: "AI coding agent, lash.lacy.sh (recommended)",
46
+ claude: "Claude Code CLI",
47
+ opencode: "OpenCode CLI",
48
+ gemini: "Google Gemini CLI",
49
+ codex: "OpenAI Codex CLI",
50
+ hermes: "Hermes Agent by Nous Research",
51
+ copilot: "GitHub Copilot CLI",
52
+ goose: "Goose agent by Block",
53
+ amp: "Sourcegraph Amp CLI",
54
+ aider: "Aider pair programming",
55
+ };
56
+
57
+ const MODES = [
58
+ { value: "auto", label: "Auto", hint: "smart detection (recommended)" },
59
+ { value: "shell", label: "Shell", hint: "all commands execute directly" },
60
+ { value: "agent", label: "Agent", hint: "all input goes to AI" },
61
+ ];
62
+
63
+ // An uncommented line that sources a lacy plugin / puts ~/.lacy/bin on PATH
64
+ const PLUGIN_LINE_RE = /^[ \t]*(source|\.)[ \t]+[^#\n]*lacy\.plugin\.(zsh|bash|fish)/m;
65
+ const PATH_LINE_RE = /^[ \t]*[^#\s][^#\n]*\.lacy\/bin/m;
23
66
 
24
- // Version — read from installed package.json (single source of truth),
67
+ // Version: read from installed package.json (single source of truth),
25
68
  // fall back to this npm package's own package.json
26
69
  function getVersion() {
27
- // Try the installed copy first
28
- for (const dir of [INSTALL_DIR, INSTALL_DIR_OLD]) {
70
+ for (const dir of [INSTALL_DIR, INSTALL_DIR_OLD, __dirname]) {
29
71
  const pkgPath = join(dir, "package.json");
30
72
  if (existsSync(pkgPath)) {
31
73
  try {
@@ -34,25 +76,21 @@ function getVersion() {
34
76
  } catch {}
35
77
  }
36
78
  }
37
- // Fall back to this package's own version
38
- try {
39
- const __dirname = dirname(fileURLToPath(import.meta.url));
40
- const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
41
- if (pkg.version) return pkg.version;
42
- } catch {}
43
79
  return "unknown";
44
80
  }
45
81
 
46
82
  const VERSION = getVersion();
47
83
 
84
+ function isInteractive() {
85
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
86
+ }
87
+
48
88
  // ============================================================================
49
89
  // Terminal state safety net
50
90
  // ============================================================================
51
- // @clack/prompts puts stdin into raw mode during interactive prompts. If the
52
- // process exits abnormally (unhandled error, SIGINT during a prompt, etc.),
53
- // raw mode is never restored and the parent shell's tty is left corrupted —
54
- // breaking Ctrl+C, paste, and other keyboard shortcuts until a new terminal
55
- // window is opened. These handlers ensure we always clean up.
91
+ // @clack/prompts puts stdin into raw mode during prompts. If the process exits
92
+ // abnormally, raw mode is never restored and the parent shell's tty is left
93
+ // broken until a new window is opened. These handlers always clean up.
56
94
 
57
95
  function restoreTerminalState() {
58
96
  try {
@@ -62,8 +100,10 @@ function restoreTerminalState() {
62
100
  } catch {
63
101
  // stdin may already be destroyed
64
102
  }
65
- // Restore cursor visibility and line wrapping
66
- process.stdout.write("\x1b[?25h\x1b[?7h");
103
+ if (process.stdout.isTTY) {
104
+ // Restore cursor visibility and line wrapping
105
+ process.stdout.write("\x1b[?25h\x1b[?7h");
106
+ }
67
107
  }
68
108
 
69
109
  process.on("exit", restoreTerminalState);
@@ -71,40 +111,62 @@ process.on("exit", restoreTerminalState);
71
111
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
72
112
  process.on(signal, () => {
73
113
  restoreTerminalState();
74
- // Re-raise so the parent process sees the correct exit code
75
- process.exit(128 + ({ SIGINT: 2, SIGTERM: 15, SIGHUP: 1 }[signal]));
114
+ process.exit(128 + { SIGINT: 2, SIGTERM: 15, SIGHUP: 1 }[signal]);
76
115
  });
77
116
  }
78
117
 
79
- // Shell detection and per-shell configuration
118
+ // Ctrl+C / Escape at a prompt. 130 tells the calling shell script to stop
119
+ // instead of falling back to the bash installer.
120
+ function cancelled(message) {
121
+ p.cancel(message);
122
+ process.exit(130);
123
+ }
124
+
125
+ // Spinners redraw with escape codes; without a terminal, log plain lines.
126
+ function spinner() {
127
+ if (isInteractive()) return p.spinner();
128
+ return {
129
+ start: (msg) => p.log.step(msg),
130
+ stop: (msg) => p.log.success(msg),
131
+ message: () => {},
132
+ };
133
+ }
134
+
135
+ // ============================================================================
136
+ // Shell detection
137
+ // ============================================================================
138
+
80
139
  function detectShell() {
81
- const shell = process.env.SHELL || "";
82
- const base = shell.split("/").pop();
83
- if (base === "bash") return "bash";
84
- if (base === "zsh") return "zsh";
85
- // Unknown $SHELL — check what's available
86
- if (commandExists("zsh")) return "zsh";
87
- if (commandExists("bash")) return "bash";
88
- return "bash";
140
+ const base = (process.env.SHELL || "").split("/").pop();
141
+ if (base === "bash" || base === "zsh" || base === "fish") return base;
142
+ return commandExists("zsh") ? "zsh" : "bash";
89
143
  }
90
144
 
91
145
  function getShellConfig(shell) {
146
+ const home = homedir();
92
147
  switch (shell) {
93
148
  case "bash":
94
149
  return {
95
- rcFile:
96
- process.platform === "darwin"
97
- ? join(homedir(), ".bash_profile")
98
- : join(homedir(), ".bashrc"),
99
- extraRcFile:
100
- process.platform === "darwin" ? join(homedir(), ".bashrc") : null,
150
+ shell,
151
+ rcFile: process.platform === "darwin" ? join(home, ".bash_profile") : join(home, ".bashrc"),
152
+ extraRcFile: process.platform === "darwin" ? join(home, ".bashrc") : null,
101
153
  pluginFile: "lacy.plugin.bash",
102
154
  shellCmd: "bash",
103
155
  rcName: process.platform === "darwin" ? ".bash_profile" : ".bashrc",
104
156
  };
105
- default: // zsh
157
+ case "fish":
106
158
  return {
107
- rcFile: join(homedir(), ".zshrc"),
159
+ shell,
160
+ rcFile: join(home, ".config", "fish", "conf.d", "lacy.fish"),
161
+ extraRcFile: null,
162
+ pluginFile: "lacy.plugin.fish",
163
+ shellCmd: "fish",
164
+ rcName: "conf.d/lacy.fish",
165
+ };
166
+ default:
167
+ return {
168
+ shell: "zsh",
169
+ rcFile: join(home, ".zshrc"),
108
170
  extraRcFile: null,
109
171
  pluginFile: "lacy.plugin.zsh",
110
172
  shellCmd: "zsh",
@@ -113,29 +175,23 @@ function getShellConfig(shell) {
113
175
  }
114
176
  }
115
177
 
116
- // All RC files that might contain lacy config (for uninstall)
117
- const ALL_RC_FILES = [
118
- join(homedir(), ".zshrc"),
119
- join(homedir(), ".bashrc"),
120
- join(homedir(), ".bash_profile"),
121
- join(homedir(), ".config", "fish", "conf.d", "lacy.fish"),
122
- ];
123
-
124
178
  // ============================================================================
125
- // Analytics — lightweight, anonymous install tracking via Umami
126
- // No PII collected. Respects DO_NOT_TRACK. See: https://umami.is
179
+ // Analytics: anonymous install counts via Umami. No PII. Respects DO_NOT_TRACK.
127
180
  // ============================================================================
128
181
 
129
182
  const UMAMI_URL = process.env.LACY_UMAMI_URL || "https://analytics.lacy.sh";
130
183
  const UMAMI_WEBSITE_ID = process.env.LACY_UMAMI_WEBSITE_ID || "577521d7-3db7-4a77-a45c-3c97f21b5322";
131
184
 
185
+ // Resolves within 2 seconds either way, so callers can await it before exiting.
132
186
  function trackEvent(eventName, method = "npx") {
133
- if (process.env.DO_NOT_TRACK === "1" || process.env.LACY_NO_TELEMETRY === "1") return;
187
+ if (process.env.DO_NOT_TRACK === "1" || process.env.LACY_NO_TELEMETRY === "1") {
188
+ return Promise.resolve();
189
+ }
134
190
 
135
191
  const version = getVersion();
136
- fetch(`${UMAMI_URL}/api/send`, {
192
+ const send = fetch(`${UMAMI_URL}/api/send`, {
137
193
  method: "POST",
138
- signal: AbortSignal.timeout(5000),
194
+ signal: AbortSignal.timeout(2000),
139
195
  headers: {
140
196
  "Content-Type": "application/json",
141
197
  "User-Agent": `lacy-install/${version}`,
@@ -161,29 +217,12 @@ function trackEvent(eventName, method = "npx") {
161
217
  },
162
218
  }),
163
219
  }).catch(() => {});
220
+ return Promise.race([send, new Promise((r) => setTimeout(r, 2000))]);
164
221
  }
165
222
 
166
- const TOOLS = [
167
- { value: "lash", label: "lash", hint: "AI coding agent — lash.lacy.sh (recommended)" },
168
- { value: "claude", label: "claude", hint: "Claude Code CLI" },
169
- { value: "opencode", label: "opencode", hint: "OpenCode CLI" },
170
- { value: "gemini", label: "gemini", hint: "Google Gemini CLI" },
171
- { value: "codex", label: "codex", hint: "OpenAI Codex CLI" },
172
- { value: "hermes", label: "hermes (beta)", hint: "Hermes Agent — Nous Research" },
173
- { value: "copilot", label: "copilot (beta)", hint: "GitHub Copilot CLI" },
174
- { value: "goose", label: "goose (beta)", hint: "Block's Goose AI agent" },
175
- { value: "amp", label: "amp", hint: "Sourcegraph Amp CLI" },
176
- { value: "aider", label: "aider", hint: "Aider AI pair programming — pipx install aider-chat" },
177
- { value: "custom", label: "Custom", hint: "enter your own command" },
178
- { value: "auto", label: "Auto-detect", hint: "use first available" },
179
- { value: "none", label: "None", hint: "I'll install one later" },
180
- ];
181
-
182
- const MODES = [
183
- { value: "auto", label: "Auto", hint: "smart detection (recommended)" },
184
- { value: "shell", label: "Shell", hint: "all commands execute directly" },
185
- { value: "agent", label: "Agent", hint: "all input goes to AI" },
186
- ];
223
+ // ============================================================================
224
+ // Helpers
225
+ // ============================================================================
187
226
 
188
227
  function commandExists(cmd) {
189
228
  if (!/^[a-zA-Z0-9._-]+$/.test(cmd)) return false;
@@ -195,789 +234,597 @@ function commandExists(cmd) {
195
234
  }
196
235
  }
197
236
 
198
- function installViaTarball(branch) {
199
- const tmpFile = join(tmpdir(), `lacy-${Date.now()}.tar.gz`);
200
- execSync(`curl -fsSL "${TARBALL_URL}/${branch}.tar.gz" -o "${tmpFile}"`, { stdio: "pipe" });
201
- mkdirSync(INSTALL_DIR, { recursive: true });
202
- execSync(`tar xzf "${tmpFile}" --strip-components=1 -C "${INSTALL_DIR}"`, { stdio: "pipe" });
203
- try { rmSync(tmpFile); } catch {}
237
+ function pathExists(path) {
238
+ try {
239
+ lstatSync(path);
240
+ return true;
241
+ } catch {
242
+ return false;
243
+ }
204
244
  }
205
245
 
206
- function isInstalled() {
207
- return existsSync(INSTALL_DIR) || existsSync(INSTALL_DIR_OLD);
246
+ function isLacyTree(dir) {
247
+ return existsSync(join(dir, "lacy.plugin.zsh")) && existsSync(join(dir, "lib", "core", "constants.sh"));
208
248
  }
209
249
 
210
- function isInteractive() {
211
- return process.stdin.isTTY && process.stdout.isTTY;
250
+ // A directory only counts as an install when the plugin is actually there.
251
+ function isInstalled() {
252
+ return isLacyTree(INSTALL_DIR) || isLacyTree(INSTALL_DIR_OLD);
212
253
  }
213
254
 
214
- // ============================================================================
215
- // Config helpers
216
- // ============================================================================
255
+ function detectTools() {
256
+ return TOOL_LIST.filter((tool) => commandExists(tool));
257
+ }
217
258
 
218
259
  function readConfigValue(key) {
219
260
  if (!existsSync(CONFIG_FILE)) return "";
220
261
  const content = readFileSync(CONFIG_FILE, "utf-8");
221
- const match = content.match(new RegExp(`^[\\s]*${key}:\\s*(.*)$`, "m"));
262
+ const match = content.match(new RegExp(`^[ \\t]*${key}:[ \\t]*(.*)$`, "m"));
222
263
  if (!match) return "";
223
- return match[1].replace(/["']/g, "").replace(/#.*/, "").trim();
264
+ return match[1].replace(/(^|[ \t])#.*$/, "").replace(/["']/g, "").trim();
224
265
  }
225
266
 
226
267
  function writeConfigValue(key, value) {
227
268
  if (!existsSync(CONFIG_FILE)) return;
228
269
  const content = readFileSync(CONFIG_FILE, "utf-8");
229
- const regex = new RegExp(`^(\\s*${key}:)\\s*.*$`, "m");
270
+ // [ \t], not \s: \s would run past the newline and eat the next line
271
+ const regex = new RegExp(`^([ \\t]*${key}:)[ \\t]*.*$`, "m");
230
272
  if (regex.test(content)) {
231
- writeFileSync(CONFIG_FILE, content.replace(regex, `$1 ${value}`));
273
+ writeFileSync(CONFIG_FILE, content.replace(regex, (_, lead) => (value ? `${lead} ${value}` : lead)));
232
274
  }
233
275
  }
234
276
 
235
- // ============================================================================
236
- // Shell restart
237
- // ============================================================================
238
-
239
- async function restartShell(
240
- message = "Restart shell now to apply changes?",
241
- shellCmd = null,
242
- ) {
243
- if (!isInteractive()) return;
244
-
245
- const restart = await p.confirm({
246
- message,
247
- initialValue: true,
248
- });
249
-
250
- if (p.isCancel(restart)) return;
251
-
252
- if (restart) {
253
- const cmd = shellCmd || getShellConfig(detectShell()).shellCmd;
254
- p.log.info(`Restarting ${cmd}...`);
255
-
256
- // Restore terminal state before handing off to the new shell
257
- restoreTerminalState();
277
+ // Canonical default config. Same text in install.sh and lib/core/config.sh.
278
+ function defaultConfig(active = "", customCommand = "") {
279
+ const activeLine = active ? ` active: ${active}` : " active:";
280
+ const customLine = customCommand
281
+ ? ` custom_command: "${customCommand.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
282
+ : ' # custom_command: "your-command --flags"';
283
+ return `# Lacy Shell configuration
284
+ agent_tools:
285
+ # lash, claude, opencode, gemini, codex, hermes, copilot, goose, amp, aider, custom
286
+ # Leave empty to auto-detect.
287
+ ${activeLine}
288
+ ${customLine}
258
289
 
259
- // Remove signal handlers so Ctrl+C in the child shell doesn't kill Node.
260
- // The child shell manages its own signals; Node just waits for it to exit.
261
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
262
- process.removeAllListeners(sig);
263
- }
264
- process.on("SIGINT", () => {});
265
-
266
- // Spawn a new login shell that inherits our stdio, then exit Node.
267
- // We use spawn (not execSync) to avoid creating a nested shell —
268
- // execSync("exec ...") only replaces the *child* process, not Node,
269
- // leaving the user in a nested shell with corrupted terminal state.
270
- const child = spawn(cmd, ["-l"], {
271
- stdio: "inherit",
272
- // Let the child own the terminal
273
- detached: false,
274
- });
290
+ modes:
291
+ default: auto # shell, agent, or auto
275
292
 
276
- child.on("error", () => {
277
- p.log.warn(`Could not restart. Please run: exec ${cmd} -l`);
278
- process.exit(0);
279
- });
293
+ # preheat:
294
+ # eager: false
295
+ # server_port: 4096
280
296
 
281
- // When the spawned shell exits (user typed 'exit'), exit Node too
282
- child.on("exit", (code) => {
283
- process.exit(code ?? 0);
284
- });
297
+ # logging:
298
+ # queries: false # true writes ~/.lacy/logs/queries.log (owner-only)
299
+ `;
300
+ }
285
301
 
286
- // Prevent Node from exiting while the shell is running
287
- // (the child keeps the event loop alive via stdio, but be explicit)
288
- return new Promise(() => {});
289
- }
302
+ // Run install.sh or uninstall.sh from ~/.lacy. A temp copy is used because
303
+ // both scripts replace or delete the directory they live in.
304
+ // Returns the exit status, or null when the script is not there.
305
+ function runRepoScript(name, args = []) {
306
+ const src = [INSTALL_DIR, INSTALL_DIR_OLD].map((d) => join(d, name)).find((f) => existsSync(f));
307
+ if (!src) return null;
308
+ const tmp = mkdtempSync(join(tmpdir(), "lacy-"));
309
+ const copy = join(tmp, name);
310
+ copyFileSync(src, copy);
311
+ restoreTerminalState();
312
+ const result = spawnSync("bash", [copy, ...args], {
313
+ stdio: "inherit",
314
+ env: { ...process.env, LACY_NO_NODE: "1" },
315
+ });
316
+ rmSync(tmp, { recursive: true, force: true });
317
+ return result.status ?? 1;
290
318
  }
291
319
 
292
320
  // ============================================================================
293
- // Uninstall
321
+ // Download
294
322
  // ============================================================================
295
323
 
296
- // Remove lacy lines from an RC file
297
- function removeLacyFromFile(filePath) {
298
- if (!existsSync(filePath)) return false;
299
- const content = readFileSync(filePath, "utf-8");
300
- if (!content.includes("lacy.plugin") && !content.includes(".lacy/bin"))
301
- return false;
302
- const cleaned = content
303
- .split("\n")
304
- .filter(
305
- (line) =>
306
- !line.includes("lacy.plugin") &&
307
- line.trim() !== "# Lacy Shell" &&
308
- !line.includes(".lacy/bin"),
309
- )
310
- .join("\n");
311
- writeFileSync(filePath, cleaned);
312
- return true;
324
+ // LACY_REF, else the newest stable release tag, else main.
325
+ async function resolveRef() {
326
+ if (process.env.LACY_REF) return process.env.LACY_REF;
327
+
328
+ if (commandExists("git")) {
329
+ try {
330
+ const out = execFileSync("git", ["ls-remote", "--tags", "--refs", REPO_URL], {
331
+ env: GIT_ENV,
332
+ stdio: ["ignore", "pipe", "ignore"],
333
+ timeout: 20000,
334
+ }).toString();
335
+ const tags = [...out.matchAll(/refs\/tags\/v(\d+)\.(\d+)\.(\d+)$/gm)].map((m) => [+m[1], +m[2], +m[3]]);
336
+ tags.sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2]);
337
+ if (tags.length > 0) return `v${tags[0].join(".")}`;
338
+ } catch {}
339
+ }
340
+
341
+ if (REPO_URL === DEFAULT_REPO_URL) {
342
+ try {
343
+ const res = await fetch("https://api.github.com/repos/lacymorrow/lacy/releases/latest", {
344
+ signal: AbortSignal.timeout(10000),
345
+ });
346
+ const json = await res.json();
347
+ if (/^v\d+\.\d+\.\d+$/.test(json.tag_name || "")) return json.tag_name;
348
+ } catch {}
349
+ }
350
+
351
+ return "main";
313
352
  }
314
353
 
315
- // Shared uninstall logic — removes RC lines and install dirs completely
316
- async function doUninstall({ askConfirm = true } = {}) {
317
- if (askConfirm) {
318
- const confirm = await p.confirm({
319
- message: "Are you sure you want to uninstall Lacy Shell?",
320
- initialValue: false,
321
- });
354
+ function stderrOf(e) {
355
+ return (e.stderr ? e.stderr.toString() : e.message || "").trim();
356
+ }
322
357
 
323
- if (p.isCancel(confirm) || !confirm) {
324
- p.cancel("Uninstall cancelled");
325
- process.exit(0);
358
+ // Fetch REF into DEST (which must not exist). Throws with the reason on failure.
359
+ function fetchRelease(ref, dest) {
360
+ const errors = [];
361
+ const gitOpts = { env: GIT_ENV, stdio: ["ignore", "ignore", "pipe"], timeout: 300000 };
362
+
363
+ if (commandExists("git")) {
364
+ try {
365
+ execFileSync("git", ["clone", "--quiet", "--depth", "1", "--branch", ref, REPO_URL, dest], gitOpts);
366
+ if (isLacyTree(dest)) return;
367
+ errors.push("the download does not contain Lacy");
368
+ } catch (e) {
369
+ rmSync(dest, { recursive: true, force: true });
370
+ if (/^[0-9a-f]{7,40}$/.test(ref)) {
371
+ // A commit sha: clone --branch cannot take one
372
+ try {
373
+ execFileSync("git", ["init", "--quiet", dest], gitOpts);
374
+ execFileSync("git", ["-C", dest, "remote", "add", "origin", REPO_URL], gitOpts);
375
+ execFileSync("git", ["-C", dest, "fetch", "--quiet", "--depth", "1", "origin", ref], gitOpts);
376
+ execFileSync("git", ["-C", dest, "checkout", "--quiet", "FETCH_HEAD"], gitOpts);
377
+ if (isLacyTree(dest)) return;
378
+ errors.push("the download does not contain Lacy");
379
+ } catch (e2) {
380
+ errors.push(`git fetch ${REPO_URL} ${ref} failed: ${stderrOf(e2)}`);
381
+ }
382
+ } else {
383
+ errors.push(`git clone ${REPO_URL} (${ref}) failed: ${stderrOf(e)}`);
384
+ }
385
+ }
386
+ rmSync(dest, { recursive: true, force: true });
387
+ }
388
+
389
+ if (TARBALL_BASE && commandExists("curl")) {
390
+ const kind = /^v\d/.test(ref) ? "tags" : "heads";
391
+ const url = `${TARBALL_BASE}/${kind}/${ref}.tar.gz`;
392
+ const tmp = mkdtempSync(join(tmpdir(), "lacy-"));
393
+ const file = join(tmp, "lacy.tar.gz");
394
+ try {
395
+ execFileSync("curl", ["-fsSL", "--max-time", "120", url, "-o", file], { stdio: "ignore" });
396
+ // A captive portal answers with HTML; make sure this is an archive
397
+ execFileSync("tar", ["tzf", file], { stdio: "ignore" });
398
+ mkdirSync(dest, { recursive: true });
399
+ execFileSync("tar", ["xzf", file, "--strip-components=1", "-C", dest], { stdio: "ignore" });
400
+ if (isLacyTree(dest)) return;
401
+ errors.push(`${url} does not contain Lacy`);
402
+ } catch {
403
+ errors.push(`could not download a valid archive from ${url}`);
404
+ } finally {
405
+ rmSync(tmp, { recursive: true, force: true });
326
406
  }
407
+ rmSync(dest, { recursive: true, force: true });
327
408
  }
328
409
 
329
- // Remove from all possible RC files
330
- const rcSpinner = p.spinner();
331
- rcSpinner.start("Removing from shell configs");
410
+ throw new Error(errors.join("; ") || "git or curl is required to download Lacy");
411
+ }
332
412
 
333
- let removedFrom = [];
334
- for (const rcFile of ALL_RC_FILES) {
335
- if (removeLacyFromFile(rcFile)) {
336
- removedFrom.push(rcFile.split("/").pop());
413
+ // Move a verified download into place, keeping user state.
414
+ function swapIn(stage) {
415
+ let old = null;
416
+ if (pathExists(INSTALL_DIR)) {
417
+ for (const name of STATE_FILES) {
418
+ const src = join(INSTALL_DIR, name);
419
+ const dst = join(stage, name);
420
+ if (existsSync(src) && !existsSync(dst)) cpSync(src, dst, { recursive: true });
337
421
  }
422
+ old = `${INSTALL_DIR}.old.${process.pid}`;
423
+ renameSync(INSTALL_DIR, old);
338
424
  }
425
+ renameSync(stage, INSTALL_DIR);
426
+ if (old) rmSync(old, { recursive: true, force: true });
427
+ }
339
428
 
340
- if (removedFrom.length > 0) {
341
- rcSpinner.stop(`Removed from ${removedFrom.join(", ")}`);
342
- } else {
343
- rcSpinner.stop("No shell configs to clean");
344
- }
345
-
346
- // Remove installation directories
347
- const removeSpinner = p.spinner();
348
- removeSpinner.start("Removing installation");
349
-
350
- if (existsSync(INSTALL_DIR)) {
351
- rmSync(INSTALL_DIR, { recursive: true, force: true });
352
- }
353
- if (existsSync(INSTALL_DIR_OLD)) {
354
- rmSync(INSTALL_DIR_OLD, { recursive: true, force: true });
355
- }
429
+ // ============================================================================
430
+ // Shell restart (settings changes only; install never asks)
431
+ // ============================================================================
356
432
 
357
- removeSpinner.stop("Installation removed");
433
+ async function restartShell(message = "Restart shell now to apply changes?", shellCmd = null) {
434
+ if (!isInteractive()) return;
358
435
 
359
- trackEvent("uninstall", "npx");
436
+ const restart = await p.confirm({ message, initialValue: true });
437
+ if (p.isCancel(restart) || !restart) return;
360
438
 
361
- p.log.success("Lacy Shell uninstalled");
439
+ const cmd = shellCmd || getShellConfig(detectShell()).shellCmd;
440
+ p.log.info(`Restarting ${cmd}...`);
441
+ restoreTerminalState();
362
442
 
363
- await restartShell("Restart shell now?");
443
+ // Ctrl+C in the child shell must not kill Node; Node just waits for it.
444
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
445
+ process.removeAllListeners(sig);
446
+ }
447
+ process.on("SIGINT", () => {});
364
448
 
365
- p.outro("Restart your terminal to apply changes.");
449
+ // spawn, not execSync("exec ..."): exec would only replace the child and
450
+ // leave the user in a nested shell with a broken terminal.
451
+ const child = spawn(cmd, ["-l"], { stdio: "inherit", detached: false });
452
+ child.on("error", () => {
453
+ p.log.warn(`Could not restart. Please run: exec ${cmd} -l`);
454
+ process.exit(0);
455
+ });
456
+ child.on("exit", (code) => process.exit(code ?? 0));
457
+ return new Promise(() => {});
366
458
  }
367
459
 
368
- async function uninstall() {
369
- console.clear();
370
- p.intro(pc.magenta(pc.bold(` Lacy Shell `)) + pc.dim(` v${VERSION}`));
460
+ // ============================================================================
461
+ // Uninstall: the work is done by uninstall.sh
462
+ // ============================================================================
371
463
 
372
- if (!isInstalled()) {
373
- p.log.warn("Lacy Shell is not installed");
374
- p.outro("Nothing to uninstall");
375
- process.exit(0);
464
+ async function uninstall({ askConfirm = true } = {}) {
465
+ const anything = pathExists(INSTALL_DIR) || existsSync(INSTALL_DIR_OLD);
466
+
467
+ if (isInteractive()) {
468
+ p.intro(pc.magenta(pc.bold(" Lacy Shell ")) + pc.dim(` v${VERSION}`));
469
+ if (!anything) {
470
+ p.log.warn("Lacy Shell is not installed");
471
+ p.outro("Nothing to uninstall");
472
+ return;
473
+ }
474
+ if (askConfirm) {
475
+ const confirm = await p.confirm({ message: "Uninstall Lacy Shell?", initialValue: false });
476
+ if (p.isCancel(confirm)) cancelled("Uninstall cancelled");
477
+ if (!confirm) {
478
+ p.outro("Uninstall cancelled");
479
+ return;
480
+ }
481
+ }
482
+ } else if (!anything) {
483
+ console.log("Lacy Shell is not installed.");
484
+ return;
376
485
  }
377
486
 
378
- await doUninstall();
487
+ await trackEvent("uninstall");
488
+ const status = runRepoScript("uninstall.sh");
489
+ if (status === null) {
490
+ console.error("uninstall.sh was not found in ~/.lacy. Run: curl -fsSL https://lacy.sh/install | bash -s -- --uninstall");
491
+ process.exit(1);
492
+ }
493
+ process.exit(status);
379
494
  }
380
495
 
381
- // setup() is handled by the already-installed dashboard in main()
382
-
383
496
  // ============================================================================
384
497
  // Install
385
498
  // ============================================================================
386
499
 
387
- async function install() {
388
- console.clear();
389
- p.intro(pc.magenta(pc.bold(` Lacy Shell `)) + pc.dim(` v${VERSION}`));
390
-
391
- // Detect shell
392
- const shell = detectShell();
393
- const shellConfig = getShellConfig(shell);
394
- p.log.info(`Detected shell: ${pc.cyan(shell)}`);
395
-
396
- // Check prerequisites
397
- const prerequisites = p.spinner();
398
- prerequisites.start("Checking prerequisites");
399
-
500
+ function checkPrerequisites(shell) {
400
501
  const missing = [];
401
-
402
- // Check for the target shell
403
502
  if (shell === "bash") {
404
- if (commandExists("bash")) {
405
- try {
406
- // Use $SHELL if it's bash — on macOS, plain `bash` resolves to /bin/bash (3.2)
407
- // even when the user has a newer bash (e.g. 5.x from Homebrew) as their login shell
408
- const shellEnv = process.env.SHELL || "";
409
- const userBash = shellEnv.endsWith("/bash") ? shellEnv : "bash";
410
- let bashVer;
411
- try {
412
- bashVer = execSync(`"${userBash}" -c "echo \\${BASH_VERSINFO[0]}"`, {
413
- stdio: "pipe",
414
- }).toString().trim();
415
- } catch {
416
- bashVer = execSync('bash -c "echo ${BASH_VERSINFO[0]}"', {
417
- stdio: "pipe",
418
- }).toString().trim();
419
- }
420
- if (parseInt(bashVer) < 4) {
421
- missing.push(
422
- `bash 4+ (found bash ${bashVer}, upgrade with: brew install bash)`,
423
- );
424
- }
425
- } catch {
426
- missing.push("bash 4+");
427
- }
503
+ const shellEnv = process.env.SHELL || "";
504
+ // Plain `bash` on macOS is 3.2 even when $SHELL is a newer bash
505
+ const userBash = shellEnv.endsWith("/bash") ? shellEnv : "bash";
506
+ let major = 0;
507
+ try {
508
+ major = parseInt(execFileSync(userBash, ["-c", "echo ${BASH_VERSINFO[0]}"], { stdio: "pipe" }).toString(), 10);
509
+ } catch {}
510
+ if (!(major >= 4)) missing.push(`bash 4+ (found ${major || "none"}; brew install bash)`);
511
+ } else if (shell === "zsh" && !commandExists("zsh")) {
512
+ missing.push("zsh");
513
+ }
514
+ if (!commandExists("git") && !commandExists("curl")) missing.push("git or curl");
515
+ return missing;
516
+ }
517
+
518
+ function installLash() {
519
+ const s = spinner();
520
+ s.start("Installing lash");
521
+ try {
522
+ if (commandExists("npm")) {
523
+ execSync("npm install -g lashcode", { stdio: "pipe" });
524
+ } else if (commandExists("brew")) {
525
+ execSync("brew tap lacymorrow/tap && brew install lash", { stdio: "pipe" });
428
526
  } else {
429
- missing.push("bash");
527
+ s.stop("Could not install lash: npm or Homebrew is needed");
528
+ return false;
430
529
  }
431
- } else {
432
- if (!commandExists("zsh")) missing.push("zsh");
433
- }
434
-
435
- if (missing.length > 0) {
436
- prerequisites.stop("Prerequisites check failed");
437
- p.log.error(`Missing required tools: ${missing.join(", ")}`);
438
- p.outro(pc.red("Please install missing prerequisites and try again."));
439
- process.exit(1);
530
+ } catch {
531
+ s.stop("lash did not install");
532
+ return false;
440
533
  }
534
+ s.stop("lash installed");
535
+ return commandExists("lash");
536
+ }
441
537
 
442
- prerequisites.stop("Prerequisites OK");
538
+ // One tool installed: use it. None: offer lash. Several: ask which.
539
+ // Without a terminal nothing is asked and the default is used.
540
+ async function chooseTool() {
541
+ const detected = detectTools();
443
542
 
444
- // Detect installed tools
445
- let detected = [];
446
- for (const tool of TOOLS.filter((t) => !["custom", "auto", "none"].includes(t.value)).map((t) => t.value)) {
447
- if (commandExists(tool)) {
448
- detected.push(tool);
449
- }
543
+ if (detected.length === 1 || (detected.length > 1 && !isInteractive())) {
544
+ p.log.info(`Using ${pc.green(detected[0])}`);
545
+ return detected[0];
450
546
  }
451
547
 
452
- if (detected.length > 0) {
453
- p.log.info(`Detected: ${detected.map((t) => pc.green(t)).join(", ")}`);
454
- } else {
455
- p.log.warn("No AI CLI tools detected");
456
- p.log.info("Lacy Shell requires an AI CLI tool to work.");
457
-
458
- const installLashNow = await p.confirm({
459
- message: `Would you like to install ${pc.green("lash")}? (AI coding agent — lash.lacy.sh)`,
460
- initialValue: true,
548
+ if (detected.length > 1) {
549
+ const choice = await p.select({
550
+ message: "Which AI tool should Lacy use?",
551
+ options: detected.map((t) => ({ value: t, label: t, hint: TOOL_HINTS[t] })),
552
+ initialValue: detected[0],
461
553
  });
554
+ if (p.isCancel(choice)) cancelled("Installation cancelled");
555
+ return choice;
556
+ }
462
557
 
463
- if (p.isCancel(installLashNow)) {
464
- p.cancel("Installation cancelled");
465
- process.exit(0);
466
- }
467
-
468
- if (installLashNow) {
469
- const lashSpinner = p.spinner();
470
- lashSpinner.start("Installing lash");
471
-
472
- try {
473
- if (commandExists("npm")) {
474
- execSync("npm install -g lashcode", { stdio: "pipe" });
475
- lashSpinner.stop("lash installed");
476
- detected.push("lash");
477
- } else if (commandExists("brew")) {
478
- execSync("brew tap lacymorrow/tap && brew install lash", {
479
- stdio: "pipe",
480
- });
481
- lashSpinner.stop("lash installed");
482
- detected.push("lash");
483
- } else {
484
- lashSpinner.stop("Could not install lash");
485
- p.log.warn(
486
- "Please install npm or homebrew, then run: npm install -g lashcode",
487
- );
488
- }
489
- } catch (e) {
490
- lashSpinner.stop("lash installation failed");
491
- p.log.warn(
492
- "You can install it manually later: npm install -g lashcode",
493
- );
494
- }
495
- }
558
+ if (!isInteractive()) {
559
+ p.log.warn("No AI CLI tool found. Install one later, for example: npm install -g lashcode");
560
+ return "";
496
561
  }
497
562
 
498
- // Tool selection
499
- const selectedTool = await p.select({
500
- message: "Which AI CLI tool do you want to use?",
501
- options: TOOLS.map((t) => ({
502
- value: t.value,
503
- label: t.label,
504
- hint: detected.includes(t.value) ? pc.green("installed") : t.hint,
505
- })),
506
- initialValue: detected[0] || "lash",
563
+ p.log.warn("No AI CLI tool found. Lacy needs one to answer questions.");
564
+ const yes = await p.confirm({
565
+ message: `Install ${pc.green("lash")} (lash.lacy.sh)?`,
566
+ initialValue: true,
507
567
  });
568
+ if (p.isCancel(yes)) cancelled("Installation cancelled");
569
+ if (yes && installLash()) return "lash";
570
+ p.log.info("Install one later, for example: npm install -g lashcode");
571
+ return "";
572
+ }
508
573
 
509
- if (p.isCancel(selectedTool)) {
510
- p.cancel("Installation cancelled");
511
- process.exit(0);
512
- }
574
+ function appendLacyBlock(file, sourceLine, pathLine, content) {
575
+ const needPath = !PATH_LINE_RE.test(content);
576
+ appendFileSync(file, `\n# Lacy Shell\n${sourceLine}\n${needPath ? `${pathLine}\n` : ""}`);
577
+ }
513
578
 
514
- // Prompt for custom command if selected
515
- let customCommand = "";
516
- if (selectedTool === "custom") {
517
- customCommand = await p.text({
518
- message:
519
- "Enter your custom command (query will be appended as a quoted argument):",
520
- placeholder: "claude --dangerously-skip-permissions -p",
521
- validate(value) {
522
- if (!value || value.trim().length === 0)
523
- return "Command cannot be empty";
524
- },
525
- });
579
+ // Returns a one-line summary. Appending and writing both go through symlinks.
580
+ function configureShell({ shell, rcFile, extraRcFile, pluginFile, rcName }) {
581
+ const sourceLine = `source ${INSTALL_DIR}/${pluginFile}`;
582
+ mkdirSync(dirname(rcFile), { recursive: true });
526
583
 
527
- if (p.isCancel(customCommand)) {
528
- p.cancel("Installation cancelled");
529
- process.exit(0);
530
- }
584
+ if (shell === "fish") {
585
+ // conf.d/lacy.fish is Lacy's own file
586
+ writeFileSync(rcFile, `# Lacy Shell\n${sourceLine}\nfish_add_path --path ${INSTALL_DIR}/bin\n`);
587
+ return `Configured ${rcName}`;
588
+ }
589
+
590
+ const pathLine = `export PATH="${INSTALL_DIR}/bin:$PATH"`;
591
+ const content = existsSync(rcFile) ? readFileSync(rcFile, "utf-8") : "";
592
+ let summary;
531
593
 
532
- p.log.info(`Custom command: ${pc.cyan(customCommand)}`);
594
+ if (PLUGIN_LINE_RE.test(content)) {
595
+ if (!PATH_LINE_RE.test(content)) appendFileSync(rcFile, `${pathLine}\n`);
596
+ summary = `Already configured in ${rcName}`;
597
+ } else {
598
+ appendLacyBlock(rcFile, sourceLine, pathLine, content);
599
+ summary = `Added to ${rcName}`;
533
600
  }
534
601
 
535
- // Show which tool auto-detect resolves to
536
- if (selectedTool === "auto" && detected.length > 0) {
537
- p.log.info(`Using: ${pc.green("auto-detect")} (currently: ${pc.green(detected[0])})`);
602
+ if (extraRcFile && existsSync(extraRcFile)) {
603
+ const extra = readFileSync(extraRcFile, "utf-8");
604
+ if (!PLUGIN_LINE_RE.test(extra)) appendLacyBlock(extraRcFile, sourceLine, pathLine, extra);
538
605
  }
539
606
 
540
- // Offer to install lash if selected but not installed,
541
- // or if auto-detect was chosen but no tools are available
542
- const needsLashInstall =
543
- (selectedTool === "lash" && !commandExists("lash")) ||
544
- (selectedTool === "auto" && detected.length === 0);
545
-
546
- if (needsLashInstall) {
547
- const installLash = await p.confirm({
548
- message: selectedTool === "auto"
549
- ? `No AI CLI tools are installed. Would you like to install ${pc.green("lash")} (recommended)?`
550
- : "lash is not installed. Would you like to install it now?",
551
- initialValue: true,
552
- });
607
+ return summary;
608
+ }
553
609
 
554
- if (p.isCancel(installLash)) {
555
- p.cancel("Installation cancelled");
556
- process.exit(0);
557
- }
610
+ async function install() {
611
+ p.intro(pc.magenta(pc.bold(" Lacy Shell ")) + pc.dim(` v${VERSION}`));
558
612
 
559
- if (installLash) {
560
- const lashSpinner = p.spinner();
561
- lashSpinner.start("Installing lash");
613
+ const shell = detectShell();
614
+ const shellConfig = getShellConfig(shell);
562
615
 
563
- try {
564
- if (commandExists("npm")) {
565
- execSync("npm install -g lashcode", { stdio: "pipe" });
566
- lashSpinner.stop("lash installed");
567
- } else if (commandExists("brew")) {
568
- execSync("brew tap lacymorrow/tap && brew install lash", {
569
- stdio: "pipe",
570
- });
571
- lashSpinner.stop("lash installed");
572
- } else {
573
- lashSpinner.stop("Could not install lash");
574
- p.log.warn(
575
- "Please install npm or homebrew, then run: npm install -g lashcode",
576
- );
577
- }
578
- } catch (e) {
579
- lashSpinner.stop("lash installation failed");
580
- p.log.warn(
581
- "You can install it manually later: npm install -g lashcode",
582
- );
583
- }
584
- }
616
+ const missing = checkPrerequisites(shell);
617
+ if (missing.length > 0) {
618
+ p.log.error(`Missing: ${missing.join(", ")}`);
619
+ p.outro(pc.red("Install the missing tools and try again."));
620
+ process.exit(1);
585
621
  }
586
622
 
587
- // Offer to install hermes if selected but not installed
588
- if (selectedTool === "hermes" && !commandExists("hermes")) {
589
- const installHermes = await p.confirm({
590
- message: "hermes is not installed. Would you like to install it now?",
591
- initialValue: true,
592
- });
593
-
594
- if (p.isCancel(installHermes)) {
595
- p.cancel("Installation cancelled");
596
- process.exit(0);
597
- }
623
+ const selectedTool = existsSync(CONFIG_FILE) ? null : await chooseTool();
598
624
 
599
- if (installHermes) {
600
- p.log.info("Running hermes installer...");
601
-
602
- try {
603
- if (commandExists("curl")) {
604
- execSync(
605
- "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
606
- { stdio: "inherit" },
607
- );
608
- p.log.success("hermes installed");
609
- } else {
610
- p.log.warn(
611
- "curl not found. Install manually: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
612
- );
613
- }
614
- } catch (e) {
615
- p.log.warn(
616
- "Installation failed. You can install manually: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
617
- );
618
- }
619
- }
625
+ const ref = await resolveRef();
626
+ const download = spinner();
627
+ download.start(`Downloading Lacy ${ref}`);
628
+ const stage = `${INSTALL_DIR}.new.${process.pid}`;
629
+ rmSync(stage, { recursive: true, force: true });
630
+ try {
631
+ fetchRelease(ref, stage);
632
+ swapIn(stage);
633
+ } catch (e) {
634
+ rmSync(stage, { recursive: true, force: true });
635
+ download.stop("Download failed");
636
+ p.log.error(e.message);
637
+ p.outro(pc.red("Nothing was changed."));
638
+ process.exit(1);
620
639
  }
640
+ download.stop(`Downloaded Lacy ${ref}`);
621
641
 
622
- // Offer to install copilot if selected but not installed
623
- if (selectedTool === "copilot" && !commandExists("copilot")) {
624
- const installCopilot = await p.confirm({
625
- message: "copilot is not installed. Would you like to install it now?",
626
- initialValue: true,
627
- });
642
+ p.log.success(configureShell(shellConfig));
628
643
 
629
- if (p.isCancel(installCopilot)) {
630
- p.cancel("Installation cancelled");
631
- process.exit(0);
632
- }
644
+ if (!existsSync(CONFIG_FILE)) {
645
+ writeFileSync(CONFIG_FILE, defaultConfig(selectedTool || ""));
646
+ }
633
647
 
634
- if (installCopilot) {
635
- p.log.info("Installing copilot via gh extension...");
648
+ await trackEvent("install");
636
649
 
637
- try {
638
- if (commandExists("gh")) {
639
- execSync("gh extension install github/gh-copilot", { stdio: "inherit" });
640
- p.log.success("copilot installed");
641
- } else {
642
- p.log.warn(
643
- "gh CLI not found. Install manually: gh extension install github/gh-copilot",
644
- );
645
- }
646
- } catch (e) {
647
- p.log.warn(
648
- `Installation failed: ${e.message}. You can install manually: gh extension install github/gh-copilot`,
649
- );
650
- }
651
- }
652
- }
650
+ const tool = readConfigValue("active") || "auto-detect";
651
+ p.log.success(`Lacy Shell v${getVersion()} installed for ${shell}, using ${tool}.`);
652
+ p.outro(`Open a new terminal, then type: ${pc.cyan("what files are here")}`);
653
+ }
653
654
 
654
- // Offer to install goose if selected but not installed
655
- if (selectedTool === "goose" && !commandExists("goose")) {
656
- const installGoose = await p.confirm({
657
- message: "goose is not installed. Would you like to install it now?",
658
- initialValue: true,
659
- });
655
+ // ============================================================================
656
+ // Settings dashboard (already installed)
657
+ // ============================================================================
660
658
 
661
- if (p.isCancel(installGoose)) {
662
- p.cancel("Installation cancelled");
663
- process.exit(0);
664
- }
659
+ async function dashboard() {
660
+ p.intro(pc.magenta(pc.bold(" Lacy Shell ")) + pc.dim(` v${VERSION}`));
665
661
 
666
- if (installGoose) {
667
- const gooseSpinner = p.spinner();
668
- gooseSpinner.start("Installing goose");
662
+ const active = readConfigValue("active");
663
+ const mode = readConfigValue("default") || "auto";
664
+ const detected = detectTools();
665
+ const toolsDisplay = detected.length > 0 ? detected.map((t) => pc.green(t)).join(", ") : pc.yellow("none");
669
666
 
670
- try {
671
- if (commandExists("brew")) {
672
- execSync("brew install goose", { stdio: "pipe" });
673
- gooseSpinner.stop("goose installed");
674
- } else {
675
- gooseSpinner.stop("Could not install goose");
676
- p.log.warn(
677
- "Please install homebrew, then run: brew install goose",
678
- );
679
- }
680
- } catch (e) {
681
- gooseSpinner.stop(`goose installation failed: ${e.message}`);
682
- p.log.warn(
683
- "You can install it manually later: brew install goose",
684
- );
685
- }
686
- }
687
- }
667
+ p.note(
668
+ ` Tool: ${pc.cyan(active || "auto-detect")}
669
+ Mode: ${pc.cyan(mode)}
670
+ Installed: ${toolsDisplay}`,
671
+ "Current config",
672
+ );
688
673
 
689
- // Offer to install amp if selected but not installed
690
- if (selectedTool === "amp" && !commandExists("amp")) {
691
- const installAmp = await p.confirm({
692
- message: "amp is not installed. Would you like to install it now?",
693
- initialValue: true,
674
+ while (true) {
675
+ const action = await p.select({
676
+ message: "What would you like to do?",
677
+ options: [
678
+ { value: "tool", label: "Change AI tool", hint: `current: ${active || "auto-detect"}` },
679
+ { value: "mode", label: "Change mode", hint: `current: ${mode}` },
680
+ { value: "config", label: "Edit config", hint: "open in $EDITOR" },
681
+ { value: "status", label: "Status", hint: "show full installation info" },
682
+ { value: "update", label: "Update", hint: "move to the latest release" },
683
+ { value: "reinstall", label: "Reinstall", hint: "fresh copy, keeps your config" },
684
+ { value: "uninstall", label: "Uninstall", hint: "remove Lacy Shell" },
685
+ { value: "done", label: "Done" },
686
+ ],
694
687
  });
695
688
 
696
- if (p.isCancel(installAmp)) {
697
- p.cancel("Installation cancelled");
698
- process.exit(0);
699
- }
700
-
701
- if (installAmp) {
702
- const ampSpinner = p.spinner();
703
- ampSpinner.start("Installing amp");
689
+ if (p.isCancel(action) || action === "done") break;
704
690
 
705
- try {
706
- if (commandExists("npm")) {
707
- execSync("npm install -g @sourcegraph/amp", { stdio: "pipe" });
708
- ampSpinner.stop("amp installed");
691
+ if (action === "tool") {
692
+ const selectedTool = await p.select({
693
+ message: "Which AI CLI tool do you want to use?",
694
+ options: [
695
+ ...TOOL_LIST.map((t) => ({
696
+ value: t,
697
+ label: t,
698
+ hint: detected.includes(t) ? pc.green("installed") : TOOL_HINTS[t],
699
+ })),
700
+ { value: "custom", label: "Custom", hint: "enter your own command" },
701
+ { value: "auto", label: "Auto-detect", hint: "use the first one installed" },
702
+ ],
703
+ initialValue: active || detected[0] || "auto",
704
+ });
705
+ if (p.isCancel(selectedTool)) continue;
706
+
707
+ if (selectedTool === "custom") {
708
+ const customCmd = await p.text({
709
+ message: "Enter your custom command (query will be appended as a quoted argument):",
710
+ placeholder: "claude --dangerously-skip-permissions -p",
711
+ validate(value) {
712
+ if (!value || value.trim().length === 0) return "Command cannot be empty";
713
+ },
714
+ });
715
+ if (p.isCancel(customCmd)) continue;
716
+ writeConfigValue("active", "custom");
717
+ if (readFileSync(CONFIG_FILE, "utf-8").match(/^[ \t]*custom_command:/m)) {
718
+ writeConfigValue("custom_command", `"${customCmd}"`);
709
719
  } else {
710
- ampSpinner.stop("Could not install amp");
711
- p.log.warn(
712
- "Please install npm, then run: npm install -g @sourcegraph/amp",
720
+ writeFileSync(
721
+ CONFIG_FILE,
722
+ readFileSync(CONFIG_FILE, "utf-8").replace(/^([ \t]*active:.*)$/m, (line) => `${line}\n custom_command: "${customCmd}"`),
713
723
  );
714
724
  }
715
- } catch (e) {
716
- ampSpinner.stop("amp installation failed");
717
- p.log.warn(
718
- "You can install it manually later: npm install -g @sourcegraph/amp",
719
- );
725
+ p.log.success(`Tool set to: ${pc.cyan("custom")} (${customCmd})`);
726
+ } else if (selectedTool === "auto") {
727
+ writeConfigValue("active", "");
728
+ p.log.success(`Tool set to: ${pc.cyan("auto-detect")}`);
729
+ } else {
730
+ writeConfigValue("active", selectedTool);
731
+ p.log.success(`Tool set to: ${pc.cyan(selectedTool)}`);
720
732
  }
721
- }
722
- }
723
-
724
- // Offer to install aider if selected but not installed
725
- if (selectedTool === "aider" && !commandExists("aider")) {
726
- const installAider = await p.confirm({
727
- message: "aider is not installed. Would you like to install it now?",
728
- initialValue: true,
729
- });
730
733
 
731
- if (p.isCancel(installAider)) {
732
- p.cancel("Installation cancelled");
733
- process.exit(0);
734
+ await restartShell();
735
+ break;
734
736
  }
735
737
 
736
- if (installAider) {
737
- const aiderSpinner = p.spinner();
738
- aiderSpinner.start("Installing aider");
739
-
740
- try {
741
- if (commandExists("pipx")) {
742
- execSync("pipx install aider-chat", { stdio: "pipe" });
743
- aiderSpinner.stop("aider installed");
744
- } else if (commandExists("pip3")) {
745
- execSync("pip3 install --user aider-chat", { stdio: "pipe" });
746
- aiderSpinner.stop("aider installed");
747
- } else {
748
- aiderSpinner.stop("Could not install aider");
749
- p.log.warn(
750
- "Please install pipx, then run: pipx install aider-chat",
751
- );
752
- }
753
- } catch (e) {
754
- aiderSpinner.stop("aider installation failed");
755
- p.log.warn(
756
- "You can install it manually later: pipx install aider-chat",
757
- );
758
- }
759
- }
760
- }
738
+ if (action === "mode") {
739
+ const selectedMode = await p.select({
740
+ message: "Which default mode?",
741
+ options: MODES,
742
+ initialValue: mode,
743
+ });
744
+ if (p.isCancel(selectedMode)) continue;
761
745
 
762
- // Clone/update repository
763
- const installSpinner = p.spinner();
764
- installSpinner.start("Installing Lacy");
746
+ writeConfigValue("default", `${selectedMode} # shell, agent, or auto`);
747
+ p.log.success(`Mode set to: ${pc.cyan(selectedMode)}`);
765
748
 
766
- const hasGit = commandExists("git");
749
+ await restartShell();
750
+ break;
751
+ }
767
752
 
768
- try {
769
- if (existsSync(INSTALL_DIR)) {
770
- // Update existing
753
+ if (action === "config") {
754
+ const editor = process.env.EDITOR || process.env.VISUAL || "vi";
755
+ p.log.info(`Opening ${pc.cyan(CONFIG_FILE)} in ${editor}...`);
771
756
  try {
772
- if (hasGit && existsSync(join(INSTALL_DIR, ".git"))) {
773
- execSync("git pull origin main", { cwd: INSTALL_DIR, stdio: "pipe" });
774
- } else {
775
- installViaTarball("main");
776
- }
757
+ execSync(`${editor} "${CONFIG_FILE}"`, { stdio: "inherit" });
777
758
  } catch {
778
- // Ignore update errors, use existing
779
- }
780
- installSpinner.stop("Lacy updated");
781
- } else {
782
- if (hasGit) {
783
- try {
784
- execSync(`git clone --depth 1 ${REPO_URL} "${INSTALL_DIR}"`, {
785
- stdio: "pipe",
786
- });
787
- } catch {
788
- installViaTarball("main");
789
- }
790
- } else {
791
- installViaTarball("main");
759
+ p.log.warn("Editor closed");
792
760
  }
793
- installSpinner.stop("Lacy installed");
794
- }
795
- } catch (e) {
796
- installSpinner.stop("Installation failed");
797
- p.log.error(`Could not download repository: ${e.message}`);
798
- p.outro(pc.red("Installation failed"));
799
- process.exit(1);
800
- }
801
-
802
- // Configure shell RC file
803
- const shellSpinner = p.spinner();
804
- shellSpinner.start(`Configuring ${shell}`);
805
-
806
- const { rcFile, extraRcFile, pluginFile, rcName } = shellConfig;
807
- const sourceLine = `source ${INSTALL_DIR}/${pluginFile}`;
808
- const pathLine = `export PATH="${INSTALL_DIR}/bin:$PATH"`;
809
761
 
810
- // Ensure parent directory exists
811
- const rcDir = rcFile.substring(0, rcFile.lastIndexOf("/"));
812
- mkdirSync(rcDir, { recursive: true });
813
-
814
- if (existsSync(rcFile)) {
815
- const rcContent = readFileSync(rcFile, "utf-8");
816
-
817
- if (rcContent.includes("lacy.plugin")) {
818
- shellSpinner.stop("Already configured");
819
-
820
- // Add PATH if missing (upgrade from older install)
821
- if (!rcContent.includes(".lacy/bin")) {
822
- appendFileSync(rcFile, `${pathLine}\n`);
823
- }
824
- } else {
825
- appendFileSync(rcFile, `\n# Lacy Shell\n${sourceLine}\n${pathLine}\n`);
826
- shellSpinner.stop(`Added to ${rcName}`);
762
+ await restartShell();
763
+ break;
827
764
  }
828
- } else {
829
- writeFileSync(rcFile, `# Lacy Shell\n${sourceLine}\n${pathLine}\n`);
830
- shellSpinner.stop(`Created ${rcName}`);
831
- }
832
765
 
833
- // For Bash on macOS, also add to .bashrc if it exists
834
- if (
835
- extraRcFile &&
836
- existsSync(extraRcFile) &&
837
- !readFileSync(extraRcFile, "utf-8").includes("lacy.plugin")
838
- ) {
839
- appendFileSync(extraRcFile, `\n# Lacy Shell\n${sourceLine}\n${pathLine}\n`);
840
- }
766
+ if (action === "status") {
767
+ const dir = isLacyTree(INSTALL_DIR) ? INSTALL_DIR : INSTALL_DIR_OLD;
768
+ let ref = "";
769
+ try {
770
+ ref = execFileSync("git", ["describe", "--tags", "--always"], { cwd: dir, stdio: "pipe" }).toString().trim();
771
+ } catch {}
841
772
 
842
- // Create or update config
843
- const configSpinner = p.spinner();
844
- mkdirSync(INSTALL_DIR, { recursive: true });
773
+ const shell = detectShell();
774
+ const rc = getShellConfig(shell).rcFile;
775
+ const rcConfigured = existsSync(rc) && PLUGIN_LINE_RE.test(readFileSync(rc, "utf-8"));
776
+
777
+ const lines = [
778
+ ` Installed: ${pc.green(dir)}`,
779
+ ` Version: ${pc.cyan("v" + VERSION)}${ref ? pc.dim(` (${ref})`) : ""}`,
780
+ ` Shell: ${pc.cyan(shell)} ${rcConfigured ? pc.green("configured") : pc.yellow("not configured")}`,
781
+ ` Config: ${existsSync(CONFIG_FILE) ? pc.green("exists") : pc.yellow("missing")}`,
782
+ ` Tool: ${pc.cyan(active || "auto-detect")}`,
783
+ ` Mode: ${pc.cyan(mode)}`,
784
+ ``,
785
+ ` ${pc.bold("AI CLI tools:")}`,
786
+ ...TOOL_LIST.map((t) => (commandExists(t) ? ` ${pc.green("✓")} ${t}` : ` ${pc.dim("○")} ${pc.dim(t)}`)),
787
+ ];
788
+
789
+ p.note(lines.join("\n"), "Status");
790
+ continue;
791
+ }
845
792
 
846
- const activeToolValue =
847
- selectedTool === "auto" || selectedTool === "none" ? "" : selectedTool;
793
+ if (action === "uninstall") {
794
+ await uninstall();
795
+ return;
796
+ }
848
797
 
849
- if (existsSync(CONFIG_FILE)) {
850
- // Preserve existing config, only update tool selection
851
- configSpinner.start("Updating configuration");
852
- if (activeToolValue) {
853
- writeConfigValue("active", activeToolValue);
854
- if (selectedTool === "custom" && customCommand) {
855
- writeConfigValue("custom_command", `"${customCommand}"`);
798
+ if (action === "update" || action === "reinstall") {
799
+ const status = runRepoScript("install.sh", [`--${action}`]);
800
+ if (status === null) {
801
+ p.log.error("install.sh was not found in ~/.lacy. Run: curl -fsSL https://lacy.sh/install | bash");
802
+ process.exit(1);
856
803
  }
804
+ process.exit(status);
857
805
  }
858
- configSpinner.stop("Configuration preserved");
859
- } else {
860
- configSpinner.start("Creating configuration");
861
-
862
- const customCommandLine =
863
- selectedTool === "custom" && customCommand
864
- ? ` custom_command: "${customCommand}"`
865
- : ` # custom_command: "your-command -flags"`;
866
-
867
- const configContent = `# Lacy Shell Configuration
868
- # https://github.com/lacymorrow/lacy
869
-
870
- # AI CLI tool selection
871
- # Options: lash, claude, opencode, gemini, codex, custom, or empty for auto-detect
872
- agent_tools:
873
- active: ${activeToolValue}
874
- ${customCommandLine}
875
-
876
- # API Keys (optional - only needed if no CLI tool is installed)
877
- api_keys:
878
- # openai: "your-key-here"
879
- # anthropic: "your-key-here"
880
-
881
- # Operating modes
882
- modes:
883
- default: auto # Options: shell, agent, auto
884
-
885
- # Smart auto-detection settings
886
- auto_detection:
887
- enabled: true
888
- confidence_threshold: 0.7
889
- `;
890
-
891
- writeFileSync(CONFIG_FILE, configContent);
892
- configSpinner.stop("Configuration created");
893
806
  }
894
807
 
895
- // Re-read version after install (repo was just cloned/updated)
896
- const installedVersion = getVersion();
897
-
898
- trackEvent("install", "npx");
899
-
900
- // Success message
901
- p.log.success(pc.green(`Installation complete!`) + pc.dim(` v${installedVersion}`));
902
-
903
- p.note(
904
- `${pc.cyan("what files are here")} ${pc.dim("→ AI answers")}
905
- ${pc.cyan("ls -la")} ${pc.dim("→ runs in shell")}
906
-
907
- Commands:
908
- ${pc.cyan("mode")} ${pc.dim("Show/change mode")}
909
- ${pc.cyan("tool")} ${pc.dim("Show/change AI tool")}
910
- ${pc.cyan('ask "q"')} ${pc.dim("Direct query to AI")}
911
- ${pc.cyan("lacy setup")} ${pc.dim("Interactive settings")}`,
912
- "Try it",
913
- );
914
-
915
- if (
916
- selectedTool === "none" ||
917
- (selectedTool === "auto" && detected.length === 0)
918
- ) {
919
- p.log.warn("Remember to install an AI CLI tool:");
920
- console.log(` ${pc.cyan("npm install -g lashcode")}`);
921
- }
922
-
923
- await restartShell();
924
-
925
- p.outro(pc.dim("Learn more: https://github.com/lacymorrow/lacy"));
808
+ p.outro(pc.dim("https://github.com/lacymorrow/lacy"));
926
809
  }
927
810
 
928
811
  // ============================================================================
929
812
  // Main
930
813
  // ============================================================================
931
814
 
932
- async function main() {
933
- const args = process.argv.slice(2);
934
-
935
- // Handle info subcommand
936
- if (args[0] === "info") {
937
- const infoPath = join(INSTALL_DIR, "lib/commands/info.sh");
938
- if (existsSync(infoPath)) {
939
- const content = readFileSync(infoPath, "utf-8");
940
- console.log(content);
941
- } else {
942
- console.log(`\n${pc.magenta(pc.bold("🔧 Lacy Shell"))} v${VERSION}\n`);
943
- console.log("Lacy Shell detects natural language and routes it to AI coding agents.\n");
944
- console.log("Quick tips:");
945
- console.log(" • Type normally for shell commands");
946
- console.log(" • Type natural language for AI assistance");
947
- console.log(" • Press Ctrl+Space to toggle modes\n");
948
- console.log(`Run '${pc.cyan("lacy setup")}' to configure your AI tool and settings.`);
949
- console.log(`Run '${pc.cyan("lacy mode")}' to see current mode and legend.`);
950
- }
951
- return;
952
- }
953
-
954
- // Handle uninstall subcommand/flag
955
- if (args[0] === "uninstall") {
956
- await uninstall();
957
- return;
958
- }
959
-
960
- if (args.includes("--uninstall") || args.includes("-u")) {
961
- await uninstall();
962
- return;
963
- }
964
-
965
- if (args.includes("--help") || args.includes("-h")) {
966
- console.log(`
967
- ${pc.magenta(pc.bold("Lacy Shell"))} ${pc.dim(`v${VERSION}`)} - Talk directly to your shell
815
+ function printHelp() {
816
+ console.log(`
817
+ ${pc.magenta(pc.bold("Lacy Shell"))} ${pc.dim(`v${VERSION}`)}: talk directly to your shell
968
818
 
969
819
  ${pc.bold("Usage:")}
970
- npx lacy Install Lacy Shell
971
- npx lacy --uninstall Uninstall Lacy Shell
972
- npx lacy setup Interactive settings
820
+ npx lacy Install, or open settings when installed
821
+ npx lacy setup Open settings
822
+ npx lacy info Show a short introduction
823
+ npx lacy --uninstall Remove Lacy Shell
973
824
 
974
825
  ${pc.bold("Options:")}
975
- -h, --help Show this help message
976
- -u, --uninstall Uninstall Lacy Shell
977
-
978
- ${pc.bold("Commands:")}
979
- setup Interactive settings (tool, mode, config)
980
- info Show basic information and help
826
+ -h, --help Show this help
827
+ -u, --uninstall Remove Lacy Shell
981
828
 
982
829
  ${pc.bold("Other install methods:")}
983
830
  curl -fsSL https://lacy.sh/install | bash
@@ -985,261 +832,49 @@ ${pc.bold("Other install methods:")}
985
832
 
986
833
  ${pc.dim("https://github.com/lacymorrow/lacy")}
987
834
  `);
988
- return;
989
- }
990
-
991
- // If already installed, show dashboard + menu
992
- if (isInstalled()) {
993
- console.clear();
994
- p.intro(pc.magenta(pc.bold(` Lacy Shell `)) + pc.dim(` v${VERSION}`));
995
-
996
- // Show current status
997
- const active = readConfigValue("active");
998
- const mode = readConfigValue("default");
999
- const detected = [];
1000
- for (const tool of TOOLS.filter((t) => !["custom", "auto", "none"].includes(t.value)).map((t) => t.value)) {
1001
- if (commandExists(tool)) detected.push(tool);
1002
- }
1003
-
1004
- const toolDisplay = active || "auto-detect";
1005
- const modeDisplay = mode || "auto";
1006
- const toolsDisplay =
1007
- detected.length > 0
1008
- ? detected.map((t) => pc.green(t)).join(", ")
1009
- : pc.yellow("none");
1010
-
1011
- p.note(
1012
- ` Tool: ${pc.cyan(toolDisplay)}
1013
- Mode: ${pc.cyan(modeDisplay)}
1014
- Installed: ${toolsDisplay}`,
1015
- "Current config",
1016
- );
1017
-
1018
- let loop = true;
1019
- while (loop) {
1020
- const action = await p.select({
1021
- message: "What would you like to do?",
1022
- options: [
1023
- {
1024
- value: "tool",
1025
- label: "Change AI tool",
1026
- hint: `current: ${active || "auto-detect"}`,
1027
- },
1028
- {
1029
- value: "mode",
1030
- label: "Change mode",
1031
- hint: `current: ${modeDisplay}`,
1032
- },
1033
- { value: "config", label: "Edit config", hint: "open in $EDITOR" },
1034
- {
1035
- value: "status",
1036
- label: "Status",
1037
- hint: "show full installation info",
1038
- },
1039
- { value: "update", label: "Update", hint: "pull latest changes" },
1040
- {
1041
- value: "reinstall",
1042
- label: "Reinstall",
1043
- hint: "fresh installation",
1044
- },
1045
- {
1046
- value: "uninstall",
1047
- label: "Uninstall",
1048
- hint: "remove Lacy Shell",
1049
- },
1050
- { value: "done", label: "Done" },
1051
- ],
1052
- });
1053
-
1054
- if (p.isCancel(action) || action === "done") {
1055
- loop = false;
1056
- break;
1057
- }
1058
-
1059
- if (action === "tool") {
1060
- const selectedTool = await p.select({
1061
- message: "Which AI CLI tool do you want to use?",
1062
- options: TOOLS.filter((t) => t.value !== "none").map((t) => ({
1063
- value: t.value,
1064
- label: t.label,
1065
- hint: detected.includes(t.value) ? pc.green("installed") : t.hint,
1066
- })),
1067
- initialValue: active || detected[0] || "auto",
1068
- });
1069
-
1070
- if (p.isCancel(selectedTool)) continue;
1071
-
1072
- if (selectedTool === "custom") {
1073
- const customCmd = await p.text({
1074
- message:
1075
- "Enter your custom command (query will be appended as a quoted argument):",
1076
- placeholder: "claude --dangerously-skip-permissions -p",
1077
- validate(value) {
1078
- if (!value || value.trim().length === 0)
1079
- return "Command cannot be empty";
1080
- },
1081
- });
1082
- if (p.isCancel(customCmd)) continue;
1083
- writeConfigValue("active", "custom");
1084
- writeConfigValue("custom_command", `"${customCmd}"`);
1085
- p.log.success(`Tool set to: ${pc.cyan("custom")} (${customCmd})`);
1086
- } else if (selectedTool === "auto") {
1087
- writeConfigValue("active", "");
1088
- p.log.success(`Tool set to: ${pc.cyan("auto-detect")}`);
1089
- } else {
1090
- writeConfigValue("active", selectedTool);
1091
- p.log.success(`Tool set to: ${pc.cyan(selectedTool)}`);
1092
- }
1093
-
1094
- await restartShell("Restart shell now to apply changes?");
1095
- loop = false;
1096
- }
1097
-
1098
- if (action === "mode") {
1099
- const selectedMode = await p.select({
1100
- message: "Which default mode?",
1101
- options: MODES.map((m) => ({
1102
- value: m.value,
1103
- label: m.label,
1104
- hint: m.hint,
1105
- })),
1106
- initialValue: mode || "auto",
1107
- });
1108
-
1109
- if (p.isCancel(selectedMode)) continue;
1110
-
1111
- writeConfigValue(
1112
- "default",
1113
- `${selectedMode} # Options: shell, agent, auto`,
1114
- );
1115
- p.log.success(`Mode set to: ${pc.cyan(selectedMode)}`);
1116
-
1117
- await restartShell("Restart shell now to apply changes?");
1118
- loop = false;
1119
- }
1120
-
1121
- if (action === "config") {
1122
- const editor = process.env.EDITOR || process.env.VISUAL || "vi";
1123
- p.log.info(`Opening ${pc.cyan(CONFIG_FILE)} in ${editor}...`);
1124
- try {
1125
- execSync(`${editor} "${CONFIG_FILE}"`, { stdio: "inherit" });
1126
- } catch {
1127
- p.log.warn("Editor closed");
1128
- }
835
+ }
1129
836
 
1130
- await restartShell("Restart shell now to apply changes?");
1131
- loop = false;
1132
- }
837
+ async function main() {
838
+ const args = process.argv.slice(2);
1133
839
 
1134
- if (action === "status") {
1135
- const dir = existsSync(INSTALL_DIR) ? INSTALL_DIR : INSTALL_DIR_OLD;
1136
- let sha = "";
1137
- try {
1138
- sha = execSync("git rev-parse --short HEAD", {
1139
- cwd: dir,
1140
- stdio: "pipe",
1141
- })
1142
- .toString()
1143
- .trim();
1144
- } catch {}
1145
-
1146
- const shell = detectShell();
1147
- const rc = getShellConfig(shell).rcFile;
1148
- const rcConfigured =
1149
- existsSync(rc) && readFileSync(rc, "utf-8").includes("lacy.plugin");
1150
- const hasConfig = existsSync(CONFIG_FILE);
1151
-
1152
- const lines = [
1153
- ` Installed: ${pc.green(dir)}`,
1154
- ` Version: ${pc.cyan("v" + VERSION)}${sha ? pc.dim(` (${sha})`) : ""}`,
1155
- ` Shell: ${pc.cyan(shell)} ${rcConfigured ? pc.green("configured") : pc.yellow("not configured")}`,
1156
- ` Config: ${hasConfig ? pc.green("exists") : pc.yellow("missing")}`,
1157
- ` Tool: ${pc.cyan(active || "auto-detect")}`,
1158
- ` Mode: ${pc.cyan(modeDisplay)}`,
1159
- ``,
1160
- ` ${pc.bold("AI CLI tools:")}`,
1161
- ...TOOLS.filter((t) => !["custom", "auto", "none"].includes(t.value)).map(({ value: t }) =>
1162
- commandExists(t)
1163
- ? ` ${pc.green("✓")} ${t}`
1164
- : ` ${pc.dim("○")} ${pc.dim(t)}`,
1165
- ),
1166
- ].filter(Boolean);
1167
-
1168
- p.note(lines.join("\n"), "Status");
1169
- // Don't break, let user pick another action
1170
- }
840
+ if (args.includes("--help") || args.includes("-h")) {
841
+ printHelp();
842
+ return;
843
+ }
1171
844
 
1172
- if (action === "uninstall") {
1173
- await doUninstall();
1174
- return;
1175
- }
845
+ if (args[0] === "info") {
846
+ const result = spawnSync("bash", [join(__dirname, "commands", "info.sh")], { stdio: "inherit" });
847
+ process.exit(result.status ?? 1);
848
+ }
1176
849
 
1177
- if (action === "update") {
1178
- const updateSpinner = p.spinner();
1179
- updateSpinner.start("Updating Lacy");
1180
- const updateDir = existsSync(INSTALL_DIR)
1181
- ? INSTALL_DIR
1182
- : INSTALL_DIR_OLD;
1183
- try {
1184
- if (commandExists("git") && existsSync(join(updateDir, ".git"))) {
1185
- execSync("git pull origin main", { cwd: updateDir, stdio: "pipe" });
1186
- } else {
1187
- installViaTarball("main");
1188
- }
1189
- const updatedVersion = getVersion();
1190
- updateSpinner.stop(`Lacy updated to v${updatedVersion}`);
1191
- trackEvent("update", "npx");
1192
- p.log.success("Update complete!");
1193
- await restartShell();
1194
- p.outro("Restart your terminal to apply changes.");
1195
- } catch {
1196
- updateSpinner.stop("Update failed");
1197
- p.log.error("Could not update. Try reinstalling instead.");
1198
- }
1199
- return;
1200
- }
850
+ if (args[0] === "uninstall" || args.includes("--uninstall") || args.includes("-u")) {
851
+ await uninstall();
852
+ return;
853
+ }
1201
854
 
1202
- if (action === "reinstall") {
1203
- const removeSpinner = p.spinner();
1204
- removeSpinner.start("Removing existing installation");
1205
- // Backup config before removing
1206
- let configBackup = null;
1207
- if (existsSync(CONFIG_FILE)) {
1208
- configBackup = readFileSync(CONFIG_FILE, "utf-8");
1209
- }
1210
- if (existsSync(INSTALL_DIR)) {
1211
- rmSync(INSTALL_DIR, { recursive: true, force: true });
1212
- }
1213
- if (existsSync(INSTALL_DIR_OLD)) {
1214
- rmSync(INSTALL_DIR_OLD, { recursive: true, force: true });
1215
- }
1216
- // Restore config so install() sees it and preserves it
1217
- if (configBackup) {
1218
- mkdirSync(INSTALL_DIR, { recursive: true });
1219
- writeFileSync(CONFIG_FILE, configBackup);
1220
- }
1221
- removeSpinner.stop("Removed");
1222
- loop = false;
1223
- // Falls through to install()
1224
- }
855
+ if (!isInteractive()) {
856
+ if (isInstalled()) {
857
+ console.log("No terminal detected. Updating with the bash installer, no prompts.");
858
+ const status = runRepoScript("install.sh", ["--update"]);
859
+ process.exit(status ?? 1);
1225
860
  }
861
+ console.log("No terminal detected. Installing with defaults, no prompts.");
862
+ await install();
863
+ return;
864
+ }
1226
865
 
1227
- // Only reach here if reinstall was selected (or loop ended without return)
1228
- if (!isInstalled()) {
1229
- await install();
1230
- } else {
1231
- p.outro(pc.dim("https://github.com/lacymorrow/lacy"));
1232
- }
866
+ if (isInstalled()) {
867
+ await dashboard();
1233
868
  return;
1234
869
  }
1235
870
 
1236
871
  await install();
1237
872
  }
1238
873
 
1239
- main().then(() => {
1240
- process.exit(0);
1241
- }).catch((e) => {
1242
- restoreTerminalState();
1243
- p.log.error(e.message);
1244
- process.exit(1);
1245
- });
874
+ main()
875
+ .then(() => process.exit(0))
876
+ .catch((e) => {
877
+ restoreTerminalState();
878
+ p.log.error(e.message);
879
+ process.exit(1);
880
+ });