lacy 1.6.5 → 1.7.0-beta.1

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 (2) hide show
  1. package/index.mjs +626 -248
  2. package/package.json +4 -2
package/index.mjs CHANGED
@@ -1,32 +1,86 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import * as p from '@clack/prompts';
4
- import pc from 'picocolors';
5
- import { execSync, spawn } from 'child_process';
6
- import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, rmSync } from 'fs';
7
- import { homedir } from 'os';
8
- import { join } from 'path';
9
-
10
- const INSTALL_DIR = join(homedir(), '.lacy');
11
- const INSTALL_DIR_OLD = join(homedir(), '.lacy-shell');
12
- const CONFIG_FILE = join(INSTALL_DIR, 'config.yaml');
13
- const ZSHRC = join(homedir(), '.zshrc');
14
- const REPO_URL = 'https://github.com/lacymorrow/lacy.git';
3
+ import * as p from "@clack/prompts";
4
+ import pc from "picocolors";
5
+ import { execSync } from "child_process";
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ writeFileSync,
10
+ readFileSync,
11
+ appendFileSync,
12
+ rmSync,
13
+ } from "fs";
14
+ import { homedir } from "os";
15
+ import { join } from "path";
16
+
17
+ const INSTALL_DIR = join(homedir(), ".lacy");
18
+ const INSTALL_DIR_OLD = join(homedir(), ".lacy-shell");
19
+ const CONFIG_FILE = join(INSTALL_DIR, "config.yaml");
20
+ const REPO_URL = "https://github.com/lacymorrow/lacy.git";
21
+
22
+ // Shell detection and per-shell configuration
23
+ function detectShell() {
24
+ const shell = process.env.SHELL || "";
25
+ const base = shell.split("/").pop();
26
+ if (base === "bash") return "bash";
27
+ return "zsh"; // default (fish not yet supported)
28
+ }
29
+
30
+ function getShellConfig(shell) {
31
+ switch (shell) {
32
+ case "bash":
33
+ return {
34
+ rcFile:
35
+ process.platform === "darwin"
36
+ ? join(homedir(), ".bash_profile")
37
+ : join(homedir(), ".bashrc"),
38
+ extraRcFile:
39
+ process.platform === "darwin" ? join(homedir(), ".bashrc") : null,
40
+ pluginFile: "lacy.plugin.bash",
41
+ shellCmd: "bash",
42
+ rcName: process.platform === "darwin" ? ".bash_profile" : ".bashrc",
43
+ };
44
+ default: // zsh
45
+ return {
46
+ rcFile: join(homedir(), ".zshrc"),
47
+ extraRcFile: null,
48
+ pluginFile: "lacy.plugin.zsh",
49
+ shellCmd: "zsh",
50
+ rcName: ".zshrc",
51
+ };
52
+ }
53
+ }
54
+
55
+ // All RC files that might contain lacy config (for uninstall)
56
+ const ALL_RC_FILES = [
57
+ join(homedir(), ".zshrc"),
58
+ join(homedir(), ".bashrc"),
59
+ join(homedir(), ".bash_profile"),
60
+ join(homedir(), ".config", "fish", "conf.d", "lacy.fish"),
61
+ ];
15
62
 
16
63
  const TOOLS = [
17
- { value: 'lash', label: 'lash', hint: 'recommended' },
18
- { value: 'claude', label: 'claude', hint: 'Claude Code CLI' },
19
- { value: 'opencode', label: 'opencode', hint: 'OpenCode CLI' },
20
- { value: 'gemini', label: 'gemini', hint: 'Google Gemini CLI' },
21
- { value: 'codex', label: 'codex', hint: 'OpenAI Codex CLI' },
22
- { value: 'custom', label: 'Custom', hint: 'enter your own command' },
23
- { value: 'auto', label: 'Auto-detect', hint: 'use first available' },
24
- { value: 'none', label: 'None', hint: "I'll install one later" },
64
+ { value: "lash", label: "lash", hint: "recommended" },
65
+ { value: "claude", label: "claude", hint: "Claude Code CLI" },
66
+ { value: "opencode", label: "opencode", hint: "OpenCode CLI" },
67
+ { value: "gemini", label: "gemini", hint: "Google Gemini CLI" },
68
+ { value: "codex", label: "codex", hint: "OpenAI Codex CLI" },
69
+ { value: "custom", label: "Custom", hint: "enter your own command" },
70
+ { value: "auto", label: "Auto-detect", hint: "use first available" },
71
+ { value: "none", label: "None", hint: "I'll install one later" },
72
+ ];
73
+
74
+ const MODES = [
75
+ { value: "auto", label: "Auto", hint: "smart detection (recommended)" },
76
+ { value: "shell", label: "Shell", hint: "all commands execute directly" },
77
+ { value: "agent", label: "Agent", hint: "all input goes to AI" },
25
78
  ];
26
79
 
27
80
  function commandExists(cmd) {
81
+ if (!/^[a-zA-Z0-9._-]+$/.test(cmd)) return false;
28
82
  try {
29
- execSync(`command -v ${cmd}`, { stdio: 'ignore' });
83
+ execSync(`command -v ${cmd}`, { stdio: "ignore" });
30
84
  return true;
31
85
  } catch {
32
86
  return false;
@@ -41,7 +95,35 @@ function isInteractive() {
41
95
  return process.stdin.isTTY && process.stdout.isTTY;
42
96
  }
43
97
 
44
- async function restartShell(message = 'Restart shell now to apply changes?') {
98
+ // ============================================================================
99
+ // Config helpers
100
+ // ============================================================================
101
+
102
+ function readConfigValue(key) {
103
+ if (!existsSync(CONFIG_FILE)) return "";
104
+ const content = readFileSync(CONFIG_FILE, "utf-8");
105
+ const match = content.match(new RegExp(`^[\\s]*${key}:\\s*(.*)$`, "m"));
106
+ if (!match) return "";
107
+ return match[1].replace(/["']/g, "").replace(/#.*/, "").trim();
108
+ }
109
+
110
+ function writeConfigValue(key, value) {
111
+ if (!existsSync(CONFIG_FILE)) return;
112
+ const content = readFileSync(CONFIG_FILE, "utf-8");
113
+ const regex = new RegExp(`^(\\s*${key}:)\\s*.*$`, "m");
114
+ if (regex.test(content)) {
115
+ writeFileSync(CONFIG_FILE, content.replace(regex, `$1 ${value}`));
116
+ }
117
+ }
118
+
119
+ // ============================================================================
120
+ // Shell restart
121
+ // ============================================================================
122
+
123
+ async function restartShell(
124
+ message = "Restart shell now to apply changes?",
125
+ shellCmd = null,
126
+ ) {
45
127
  if (!isInteractive()) return;
46
128
 
47
129
  const restart = await p.confirm({
@@ -52,15 +134,16 @@ async function restartShell(message = 'Restart shell now to apply changes?') {
52
134
  if (p.isCancel(restart)) return;
53
135
 
54
136
  if (restart) {
55
- p.log.info('Restarting shell...');
56
- // Use spawn with shell to exec into zsh
57
- const child = spawn('zsh', ['-l'], {
58
- stdio: 'inherit',
59
- shell: false,
60
- });
61
- child.on('exit', () => process.exit(0));
62
- // Keep the process alive until zsh exits
63
- await new Promise(() => {});
137
+ const cmd = shellCmd || getShellConfig(detectShell()).shellCmd;
138
+ // Use exec to replace the current process (no nested shell)
139
+ p.log.info(`Restarting ${cmd}...`);
140
+ try {
141
+ execSync(`exec ${cmd} -l`, { stdio: "inherit" });
142
+ } catch {
143
+ // exec replaces the process so this only runs if it fails
144
+ p.log.warn(`Could not restart. Please run: ${cmd} -l`);
145
+ }
146
+ process.exit(0);
64
147
  }
65
148
  }
66
149
 
@@ -68,46 +151,77 @@ async function restartShell(message = 'Restart shell now to apply changes?') {
68
151
  // Uninstall
69
152
  // ============================================================================
70
153
 
71
- async function uninstall() {
72
- console.clear();
73
- p.intro(pc.magenta(pc.bold(` Lacy Shell `)));
154
+ // Remove lacy lines from an RC file
155
+ function removeLacyFromFile(filePath) {
156
+ if (!existsSync(filePath)) return false;
157
+ const content = readFileSync(filePath, "utf-8");
158
+ if (!content.includes("lacy.plugin") && !content.includes(".lacy/bin"))
159
+ return false;
160
+ const cleaned = content
161
+ .split("\n")
162
+ .filter(
163
+ (line) =>
164
+ !line.includes("lacy.plugin") &&
165
+ line.trim() !== "# Lacy Shell" &&
166
+ !line.includes(".lacy/bin"),
167
+ )
168
+ .join("\n");
169
+ writeFileSync(filePath, cleaned);
170
+ return true;
171
+ }
74
172
 
75
- if (!isInstalled()) {
76
- p.log.warn('Lacy Shell is not installed');
77
- p.outro('Nothing to uninstall');
78
- process.exit(0);
173
+ // Shared uninstall logic — removes RC lines and install dirs, optionally keeps config
174
+ async function doUninstall({ askConfirm = true } = {}) {
175
+ if (askConfirm) {
176
+ const confirm = await p.confirm({
177
+ message: "Are you sure you want to uninstall Lacy Shell?",
178
+ initialValue: false,
179
+ });
180
+
181
+ if (p.isCancel(confirm) || !confirm) {
182
+ p.cancel("Uninstall cancelled");
183
+ process.exit(0);
184
+ }
79
185
  }
80
186
 
81
- const confirm = await p.confirm({
82
- message: 'Are you sure you want to uninstall Lacy Shell?',
83
- initialValue: false,
84
- });
187
+ // Ask about config
188
+ let keepConfig = false;
189
+ if (existsSync(CONFIG_FILE)) {
190
+ const configChoice = await p.confirm({
191
+ message: "Keep configuration for future reinstall?",
192
+ initialValue: true,
193
+ });
194
+ if (!p.isCancel(configChoice)) {
195
+ keepConfig = configChoice;
196
+ }
197
+ }
85
198
 
86
- if (p.isCancel(confirm) || !confirm) {
87
- p.cancel('Uninstall cancelled');
88
- process.exit(0);
199
+ // Backup config if keeping
200
+ let configBackup = null;
201
+ if (keepConfig && existsSync(CONFIG_FILE)) {
202
+ configBackup = readFileSync(CONFIG_FILE, "utf-8");
89
203
  }
90
204
 
91
- // Remove from .zshrc
92
- const zshrcSpinner = p.spinner();
93
- zshrcSpinner.start('Removing from .zshrc');
94
-
95
- if (existsSync(ZSHRC)) {
96
- let content = readFileSync(ZSHRC, 'utf-8');
97
- // Remove source line and comment
98
- content = content
99
- .split('\n')
100
- .filter(line => !line.includes('lacy.plugin.zsh') && line.trim() !== '# Lacy Shell')
101
- .join('\n');
102
- writeFileSync(ZSHRC, content);
103
- zshrcSpinner.stop('Removed from .zshrc');
205
+ // Remove from all possible RC files
206
+ const rcSpinner = p.spinner();
207
+ rcSpinner.start("Removing from shell configs");
208
+
209
+ let removedFrom = [];
210
+ for (const rcFile of ALL_RC_FILES) {
211
+ if (removeLacyFromFile(rcFile)) {
212
+ removedFrom.push(rcFile.split("/").pop());
213
+ }
214
+ }
215
+
216
+ if (removedFrom.length > 0) {
217
+ rcSpinner.stop(`Removed from ${removedFrom.join(", ")}`);
104
218
  } else {
105
- zshrcSpinner.stop('No .zshrc found');
219
+ rcSpinner.stop("No shell configs to clean");
106
220
  }
107
221
 
108
222
  // Remove installation directories
109
223
  const removeSpinner = p.spinner();
110
- removeSpinner.start('Removing installation');
224
+ removeSpinner.start("Removing installation");
111
225
 
112
226
  if (existsSync(INSTALL_DIR)) {
113
227
  rmSync(INSTALL_DIR, { recursive: true, force: true });
@@ -116,15 +230,38 @@ async function uninstall() {
116
230
  rmSync(INSTALL_DIR_OLD, { recursive: true, force: true });
117
231
  }
118
232
 
119
- removeSpinner.stop('Installation removed');
233
+ // Restore config if keeping
234
+ if (configBackup) {
235
+ mkdirSync(INSTALL_DIR, { recursive: true });
236
+ writeFileSync(CONFIG_FILE, configBackup);
237
+ }
238
+
239
+ removeSpinner.stop(
240
+ configBackup ? "Installation removed (config preserved)" : "Installation removed",
241
+ );
120
242
 
121
- p.log.success('Lacy Shell uninstalled');
243
+ p.log.success("Lacy Shell uninstalled");
122
244
 
123
- await restartShell('Restart shell now?');
245
+ await restartShell("Restart shell now?");
124
246
 
125
- p.outro(`Run ${pc.cyan('source ~/.zshrc')} or restart your terminal.`);
247
+ p.outro("Restart your terminal to apply changes.");
126
248
  }
127
249
 
250
+ async function uninstall() {
251
+ console.clear();
252
+ p.intro(pc.magenta(pc.bold(` Lacy Shell `)));
253
+
254
+ if (!isInstalled()) {
255
+ p.log.warn("Lacy Shell is not installed");
256
+ p.outro("Nothing to uninstall");
257
+ process.exit(0);
258
+ }
259
+
260
+ await doUninstall();
261
+ }
262
+
263
+ // setup() is handled by the already-installed dashboard in main()
264
+
128
265
  // ============================================================================
129
266
  // Install
130
267
  // ============================================================================
@@ -133,102 +270,137 @@ async function install() {
133
270
  console.clear();
134
271
  p.intro(pc.magenta(pc.bold(` Lacy Shell `)));
135
272
 
273
+ // Detect shell
274
+ const shell = detectShell();
275
+ const shellConfig = getShellConfig(shell);
276
+ p.log.info(`Detected shell: ${pc.cyan(shell)}`);
277
+
136
278
  // Check prerequisites
137
279
  const prerequisites = p.spinner();
138
- prerequisites.start('Checking prerequisites');
280
+ prerequisites.start("Checking prerequisites");
139
281
 
140
282
  const missing = [];
141
- if (!commandExists('zsh')) missing.push('zsh');
142
- if (!commandExists('git')) missing.push('git');
283
+
284
+ // Check for the target shell
285
+ if (shell === "bash") {
286
+ if (commandExists("bash")) {
287
+ try {
288
+ const bashVer = execSync('bash -c "echo ${BASH_VERSINFO[0]}"', {
289
+ stdio: "pipe",
290
+ })
291
+ .toString()
292
+ .trim();
293
+ if (parseInt(bashVer) < 4) {
294
+ missing.push(
295
+ `bash 4+ (found bash ${bashVer}, upgrade with: brew install bash)`,
296
+ );
297
+ }
298
+ } catch {
299
+ missing.push("bash 4+");
300
+ }
301
+ } else {
302
+ missing.push("bash");
303
+ }
304
+ } else {
305
+ if (!commandExists("zsh")) missing.push("zsh");
306
+ }
307
+
308
+ if (!commandExists("git")) missing.push("git");
143
309
 
144
310
  if (missing.length > 0) {
145
- prerequisites.stop('Prerequisites check failed');
146
- p.log.error(`Missing required tools: ${missing.join(', ')}`);
147
- p.outro(pc.red('Please install missing prerequisites and try again.'));
311
+ prerequisites.stop("Prerequisites check failed");
312
+ p.log.error(`Missing required tools: ${missing.join(", ")}`);
313
+ p.outro(pc.red("Please install missing prerequisites and try again."));
148
314
  process.exit(1);
149
315
  }
150
316
 
151
- prerequisites.stop('Prerequisites OK');
317
+ prerequisites.stop("Prerequisites OK");
152
318
 
153
319
  // Detect installed tools
154
320
  let detected = [];
155
- for (const tool of ['lash', 'claude', 'opencode', 'gemini', 'codex']) {
321
+ for (const tool of ["lash", "claude", "opencode", "gemini", "codex"]) {
156
322
  if (commandExists(tool)) {
157
323
  detected.push(tool);
158
324
  }
159
325
  }
160
326
 
161
327
  if (detected.length > 0) {
162
- p.log.info(`Detected: ${detected.map(t => pc.green(t)).join(', ')}`);
328
+ p.log.info(`Detected: ${detected.map((t) => pc.green(t)).join(", ")}`);
163
329
  } else {
164
- p.log.warn('No AI CLI tools detected');
165
- p.log.info('Lacy Shell requires an AI CLI tool to work.');
330
+ p.log.warn("No AI CLI tools detected");
331
+ p.log.info("Lacy Shell requires an AI CLI tool to work.");
166
332
 
167
333
  const installLashNow = await p.confirm({
168
- message: `Would you like to install ${pc.green('lash')} (recommended)?`,
334
+ message: `Would you like to install ${pc.green("lash")} (recommended)?`,
169
335
  initialValue: true,
170
336
  });
171
337
 
172
338
  if (p.isCancel(installLashNow)) {
173
- p.cancel('Installation cancelled');
339
+ p.cancel("Installation cancelled");
174
340
  process.exit(0);
175
341
  }
176
342
 
177
343
  if (installLashNow) {
178
344
  const lashSpinner = p.spinner();
179
- lashSpinner.start('Installing lash');
345
+ lashSpinner.start("Installing lash");
180
346
 
181
347
  try {
182
- if (commandExists('npm')) {
183
- execSync('npm install -g lash-cli', { stdio: 'pipe' });
184
- lashSpinner.stop('lash installed');
185
- detected.push('lash');
186
- } else if (commandExists('brew')) {
187
- execSync('brew tap lacymorrow/tap && brew install lash', { stdio: 'pipe' });
188
- lashSpinner.stop('lash installed');
189
- detected.push('lash');
348
+ if (commandExists("npm")) {
349
+ execSync("npm install -g lash-cli", { stdio: "pipe" });
350
+ lashSpinner.stop("lash installed");
351
+ detected.push("lash");
352
+ } else if (commandExists("brew")) {
353
+ execSync("brew tap lacymorrow/tap && brew install lash", {
354
+ stdio: "pipe",
355
+ });
356
+ lashSpinner.stop("lash installed");
357
+ detected.push("lash");
190
358
  } else {
191
- lashSpinner.stop('Could not install lash');
192
- p.log.warn('Please install npm or homebrew, then run: npm install -g lash-cli');
359
+ lashSpinner.stop("Could not install lash");
360
+ p.log.warn(
361
+ "Please install npm or homebrew, then run: npm install -g lash-cli",
362
+ );
193
363
  }
194
364
  } catch (e) {
195
- lashSpinner.stop('lash installation failed');
196
- p.log.warn('You can install it manually later: npm install -g lash-cli');
365
+ lashSpinner.stop("lash installation failed");
366
+ p.log.warn(
367
+ "You can install it manually later: npm install -g lash-cli",
368
+ );
197
369
  }
198
370
  }
199
371
  }
200
372
 
201
373
  // Tool selection
202
374
  const selectedTool = await p.select({
203
- message: 'Which AI CLI tool do you want to use?',
204
- options: TOOLS.map(t => ({
375
+ message: "Which AI CLI tool do you want to use?",
376
+ options: TOOLS.map((t) => ({
205
377
  value: t.value,
206
378
  label: t.label,
207
- hint: detected.includes(t.value)
208
- ? pc.green('installed')
209
- : t.hint,
379
+ hint: detected.includes(t.value) ? pc.green("installed") : t.hint,
210
380
  })),
211
- initialValue: detected[0] || 'lash',
381
+ initialValue: detected[0] || "lash",
212
382
  });
213
383
 
214
384
  if (p.isCancel(selectedTool)) {
215
- p.cancel('Installation cancelled');
385
+ p.cancel("Installation cancelled");
216
386
  process.exit(0);
217
387
  }
218
388
 
219
389
  // Prompt for custom command if selected
220
- let customCommand = '';
221
- if (selectedTool === 'custom') {
390
+ let customCommand = "";
391
+ if (selectedTool === "custom") {
222
392
  customCommand = await p.text({
223
- message: 'Enter your custom command (query will be appended as a quoted argument):',
224
- placeholder: 'claude --dangerously-skip-permissions -p',
393
+ message:
394
+ "Enter your custom command (query will be appended as a quoted argument):",
395
+ placeholder: "claude --dangerously-skip-permissions -p",
225
396
  validate(value) {
226
- if (!value || value.trim().length === 0) return 'Command cannot be empty';
397
+ if (!value || value.trim().length === 0)
398
+ return "Command cannot be empty";
227
399
  },
228
400
  });
229
401
 
230
402
  if (p.isCancel(customCommand)) {
231
- p.cancel('Installation cancelled');
403
+ p.cancel("Installation cancelled");
232
404
  process.exit(0);
233
405
  }
234
406
 
@@ -236,96 +408,137 @@ async function install() {
236
408
  }
237
409
 
238
410
  // Offer to install lash if selected but not installed
239
- if (selectedTool === 'lash' && !commandExists('lash')) {
411
+ if (selectedTool === "lash" && !commandExists("lash")) {
240
412
  const installLash = await p.confirm({
241
- message: 'lash is not installed. Would you like to install it now?',
413
+ message: "lash is not installed. Would you like to install it now?",
242
414
  initialValue: true,
243
415
  });
244
416
 
245
417
  if (p.isCancel(installLash)) {
246
- p.cancel('Installation cancelled');
418
+ p.cancel("Installation cancelled");
247
419
  process.exit(0);
248
420
  }
249
421
 
250
422
  if (installLash) {
251
423
  const lashSpinner = p.spinner();
252
- lashSpinner.start('Installing lash');
424
+ lashSpinner.start("Installing lash");
253
425
 
254
426
  try {
255
- if (commandExists('npm')) {
256
- execSync('npm install -g lash-cli', { stdio: 'pipe' });
257
- lashSpinner.stop('lash installed');
258
- } else if (commandExists('brew')) {
259
- execSync('brew tap lacymorrow/tap && brew install lash', { stdio: 'pipe' });
260
- lashSpinner.stop('lash installed');
427
+ if (commandExists("npm")) {
428
+ execSync("npm install -g lash-cli", { stdio: "pipe" });
429
+ lashSpinner.stop("lash installed");
430
+ } else if (commandExists("brew")) {
431
+ execSync("brew tap lacymorrow/tap && brew install lash", {
432
+ stdio: "pipe",
433
+ });
434
+ lashSpinner.stop("lash installed");
261
435
  } else {
262
- lashSpinner.stop('Could not install lash');
263
- p.log.warn('Please install npm or homebrew, then run: npm install -g lash-cli');
436
+ lashSpinner.stop("Could not install lash");
437
+ p.log.warn(
438
+ "Please install npm or homebrew, then run: npm install -g lash-cli",
439
+ );
264
440
  }
265
441
  } catch (e) {
266
- lashSpinner.stop('lash installation failed');
267
- p.log.warn('You can install it manually later: npm install -g lash-cli');
442
+ lashSpinner.stop("lash installation failed");
443
+ p.log.warn(
444
+ "You can install it manually later: npm install -g lash-cli",
445
+ );
268
446
  }
269
447
  }
270
448
  }
271
449
 
272
450
  // Clone/update repository
273
451
  const installSpinner = p.spinner();
274
- installSpinner.start('Installing Lacy');
452
+ installSpinner.start("Installing Lacy");
275
453
 
276
454
  try {
277
455
  if (existsSync(INSTALL_DIR)) {
278
456
  // Update existing
279
457
  try {
280
- execSync('git pull origin main', { cwd: INSTALL_DIR, stdio: 'pipe' });
458
+ execSync("git pull origin main", { cwd: INSTALL_DIR, stdio: "pipe" });
281
459
  } catch {
282
460
  // Ignore pull errors, use existing
283
461
  }
284
- installSpinner.stop('Lacy updated');
462
+ installSpinner.stop("Lacy updated");
285
463
  } else {
286
- execSync(`git clone --depth 1 ${REPO_URL} "${INSTALL_DIR}"`, { stdio: 'pipe' });
287
- installSpinner.stop('Lacy installed');
464
+ execSync(`git clone --depth 1 ${REPO_URL} "${INSTALL_DIR}"`, {
465
+ stdio: "pipe",
466
+ });
467
+ installSpinner.stop("Lacy installed");
288
468
  }
289
469
  } catch (e) {
290
- installSpinner.stop('Installation failed');
470
+ installSpinner.stop("Installation failed");
291
471
  p.log.error(`Could not clone repository: ${e.message}`);
292
- p.outro(pc.red('Installation failed'));
472
+ p.outro(pc.red("Installation failed"));
293
473
  process.exit(1);
294
474
  }
295
475
 
296
- // Configure .zshrc
297
- const zshrcSpinner = p.spinner();
298
- zshrcSpinner.start('Configuring shell');
476
+ // Configure shell RC file
477
+ const shellSpinner = p.spinner();
478
+ shellSpinner.start(`Configuring ${shell}`);
479
+
480
+ const { rcFile, extraRcFile, pluginFile, rcName } = shellConfig;
481
+ const sourceLine = `source ${INSTALL_DIR}/${pluginFile}`;
482
+ const pathLine = `export PATH="${INSTALL_DIR}/bin:$PATH"`;
299
483
 
300
- const sourceLine = `source ${INSTALL_DIR}/lacy.plugin.zsh`;
484
+ // Ensure parent directory exists (for fish: ~/.config/fish/conf.d/)
485
+ const rcDir = rcFile.substring(0, rcFile.lastIndexOf("/"));
486
+ mkdirSync(rcDir, { recursive: true });
301
487
 
302
- if (existsSync(ZSHRC)) {
303
- const zshrcContent = readFileSync(ZSHRC, 'utf-8');
488
+ if (existsSync(rcFile)) {
489
+ const rcContent = readFileSync(rcFile, "utf-8");
304
490
 
305
- if (zshrcContent.includes('lacy.plugin.zsh')) {
306
- zshrcSpinner.stop('Already configured');
491
+ if (rcContent.includes("lacy.plugin")) {
492
+ shellSpinner.stop("Already configured");
493
+
494
+ // Add PATH if missing (upgrade from older install)
495
+ if (!rcContent.includes(".lacy/bin")) {
496
+ appendFileSync(rcFile, `${pathLine}\n`);
497
+ }
307
498
  } else {
308
- appendFileSync(ZSHRC, `\n# Lacy Shell\n${sourceLine}\n`);
309
- zshrcSpinner.stop('Added to .zshrc');
499
+ appendFileSync(rcFile, `\n# Lacy Shell\n${sourceLine}\n${pathLine}\n`);
500
+ shellSpinner.stop(`Added to ${rcName}`);
310
501
  }
311
502
  } else {
312
- writeFileSync(ZSHRC, `# Lacy Shell\n${sourceLine}\n`);
313
- zshrcSpinner.stop('Created .zshrc');
503
+ writeFileSync(rcFile, `# Lacy Shell\n${sourceLine}\n${pathLine}\n`);
504
+ shellSpinner.stop(`Created ${rcName}`);
314
505
  }
315
506
 
316
- // Create config
317
- const configSpinner = p.spinner();
318
- configSpinner.start('Creating configuration');
507
+ // For Bash on macOS, also add to .bashrc if it exists
508
+ if (
509
+ extraRcFile &&
510
+ existsSync(extraRcFile) &&
511
+ !readFileSync(extraRcFile, "utf-8").includes("lacy.plugin")
512
+ ) {
513
+ appendFileSync(extraRcFile, `\n# Lacy Shell\n${sourceLine}\n${pathLine}\n`);
514
+ }
319
515
 
516
+ // Create or update config
517
+ const configSpinner = p.spinner();
320
518
  mkdirSync(INSTALL_DIR, { recursive: true });
321
519
 
322
- const activeToolValue = selectedTool === 'auto' || selectedTool === 'none' ? '' : selectedTool;
520
+ const activeToolValue =
521
+ selectedTool === "auto" || selectedTool === "none" ? "" : selectedTool;
323
522
 
324
- const customCommandLine = selectedTool === 'custom' && customCommand
325
- ? ` custom_command: "${customCommand}"`
326
- : ` # custom_command: "your-command -flags"`;
523
+ if (existsSync(CONFIG_FILE)) {
524
+ // Preserve existing config, only update tool selection
525
+ configSpinner.start("Updating configuration");
526
+ if (activeToolValue) {
527
+ writeConfigValue("active", activeToolValue);
528
+ if (selectedTool === "custom" && customCommand) {
529
+ writeConfigValue("custom_command", `"${customCommand}"`);
530
+ }
531
+ }
532
+ configSpinner.stop("Configuration preserved");
533
+ } else {
534
+ configSpinner.start("Creating configuration");
535
+
536
+ const customCommandLine =
537
+ selectedTool === "custom" && customCommand
538
+ ? ` custom_command: "${customCommand}"`
539
+ : ` # custom_command: "your-command -flags"`;
327
540
 
328
- const configContent = `# Lacy Shell Configuration
541
+ const configContent = `# Lacy Shell Configuration
329
542
  # https://github.com/lacymorrow/lacy
330
543
 
331
544
  # AI CLI tool selection
@@ -349,31 +562,36 @@ auto_detection:
349
562
  confidence_threshold: 0.7
350
563
  `;
351
564
 
352
- writeFileSync(CONFIG_FILE, configContent);
353
- configSpinner.stop('Configuration created');
565
+ writeFileSync(CONFIG_FILE, configContent);
566
+ configSpinner.stop("Configuration created");
567
+ }
354
568
 
355
569
  // Success message
356
- p.log.success(pc.green('Installation complete!'));
570
+ p.log.success(pc.green("Installation complete!"));
357
571
 
358
572
  p.note(
359
- `${pc.cyan('what files are here')} ${pc.dim('→ AI answers')}
360
- ${pc.cyan('ls -la')} ${pc.dim('→ runs in shell')}
573
+ `${pc.cyan("what files are here")} ${pc.dim("→ AI answers")}
574
+ ${pc.cyan("ls -la")} ${pc.dim("→ runs in shell")}
361
575
 
362
576
  Commands:
363
- ${pc.cyan('mode')} ${pc.dim('Show/change mode')}
364
- ${pc.cyan('tool')} ${pc.dim('Show/change AI tool')}
365
- ${pc.cyan('ask "q"')} ${pc.dim('Direct query to AI')}`,
366
- 'Try it'
577
+ ${pc.cyan("mode")} ${pc.dim("Show/change mode")}
578
+ ${pc.cyan("tool")} ${pc.dim("Show/change AI tool")}
579
+ ${pc.cyan('ask "q"')} ${pc.dim("Direct query to AI")}
580
+ ${pc.cyan("lacy setup")} ${pc.dim("Interactive settings")}`,
581
+ "Try it",
367
582
  );
368
583
 
369
- if (selectedTool === 'none' || (selectedTool === 'auto' && detected.length === 0)) {
370
- p.log.warn('Remember to install an AI CLI tool:');
371
- console.log(` ${pc.cyan('npm install -g lash-cli')}`);
584
+ if (
585
+ selectedTool === "none" ||
586
+ (selectedTool === "auto" && detected.length === 0)
587
+ ) {
588
+ p.log.warn("Remember to install an AI CLI tool:");
589
+ console.log(` ${pc.cyan("npm install -g lash-cli")}`);
372
590
  }
373
591
 
374
592
  await restartShell();
375
593
 
376
- p.outro(pc.dim('Learn more: https://github.com/lacymorrow/lacy'));
594
+ p.outro(pc.dim("Learn more: https://github.com/lacymorrow/lacy"));
377
595
  }
378
596
 
379
597
  // ============================================================================
@@ -383,139 +601,299 @@ Commands:
383
601
  async function main() {
384
602
  const args = process.argv.slice(2);
385
603
 
386
- // Handle flags
387
- if (args.includes('--uninstall') || args.includes('-u')) {
604
+ // Handle info subcommand
605
+ if (args[0] === "info") {
606
+ const infoPath = join(INSTALL_DIR, "lib/commands/info.sh");
607
+ if (existsSync(infoPath)) {
608
+ const content = readFileSync(infoPath, "utf-8");
609
+ console.log(content);
610
+ } else {
611
+ console.log(`\n${pc.magenta(pc.bold("🔧 Lacy Shell"))} v${require("./package.json").version || "1.6.5"}\n`);
612
+ console.log("Lacy Shell detects natural language and routes it to AI coding agents.\n");
613
+ console.log("Quick tips:");
614
+ console.log(" • Type normally for shell commands");
615
+ console.log(" • Type natural language for AI assistance");
616
+ console.log(" • Press Ctrl+Space to toggle modes\n");
617
+ console.log(`Run '${pc.cyan("lacy setup")}' to configure your AI tool and settings.`);
618
+ console.log(`Run '${pc.cyan("lacy mode")}' to see current mode and legend.`);
619
+ }
620
+ return;
621
+ }
622
+
623
+ // Handle uninstall subcommand/flag
624
+ if (args[0] === "uninstall") {
388
625
  await uninstall();
389
626
  return;
390
627
  }
391
628
 
392
- if (args.includes('--help') || args.includes('-h')) {
629
+ if (args.includes("--uninstall") || args.includes("-u")) {
630
+ await uninstall();
631
+ return;
632
+ }
633
+
634
+ if (args.includes("--help") || args.includes("-h")) {
393
635
  console.log(`
394
- ${pc.magenta(pc.bold('Lacy Shell'))} - Talk directly to your shell
636
+ ${pc.magenta(pc.bold("Lacy Shell"))} - Talk directly to your shell
395
637
 
396
- ${pc.bold('Usage:')}
638
+ ${pc.bold("Usage:")}
397
639
  npx lacy Install Lacy Shell
398
640
  npx lacy --uninstall Uninstall Lacy Shell
641
+ npx lacy setup Interactive settings
399
642
 
400
- ${pc.bold('Options:')}
643
+ ${pc.bold("Options:")}
401
644
  -h, --help Show this help message
402
645
  -u, --uninstall Uninstall Lacy Shell
403
646
 
404
- ${pc.bold('Other install methods:')}
647
+ ${pc.bold("Commands:")}
648
+ setup Interactive settings (tool, mode, config)
649
+ info Show basic information and help
650
+
651
+ ${pc.bold("Other install methods:")}
405
652
  curl -fsSL https://lacy.sh/install | bash
406
653
  brew install lacymorrow/tap/lacy
407
654
 
408
- ${pc.dim('https://github.com/lacymorrow/lacy')}
655
+ ${pc.dim("https://github.com/lacymorrow/lacy")}
409
656
  `);
410
657
  return;
411
658
  }
412
659
 
413
- // If already installed, offer choices
660
+ // If already installed, show dashboard + menu
414
661
  if (isInstalled()) {
415
662
  console.clear();
416
663
  p.intro(pc.magenta(pc.bold(` Lacy Shell `)));
417
664
 
418
- const action = await p.select({
419
- message: 'Lacy Shell is already installed. What would you like to do?',
420
- options: [
421
- { value: 'update', label: 'Update', hint: 'pull latest changes' },
422
- { value: 'reinstall', label: 'Reinstall', hint: 'fresh installation' },
423
- { value: 'uninstall', label: 'Uninstall', hint: 'remove Lacy Shell' },
424
- { value: 'cancel', label: 'Cancel', hint: 'do nothing' },
425
- ],
426
- });
427
-
428
- if (p.isCancel(action) || action === 'cancel') {
429
- p.cancel('Cancelled');
430
- process.exit(0);
665
+ // Show current status
666
+ const active = readConfigValue("active");
667
+ const mode = readConfigValue("default");
668
+ const detected = [];
669
+ for (const tool of ["lash", "claude", "opencode", "gemini", "codex"]) {
670
+ if (commandExists(tool)) detected.push(tool);
431
671
  }
432
672
 
433
- if (action === 'uninstall') {
434
- // Skip the intro since we already showed it
435
- const confirm = await p.confirm({
436
- message: 'Are you sure you want to uninstall Lacy Shell?',
437
- initialValue: false,
673
+ const toolDisplay = active || "auto-detect";
674
+ const modeDisplay = mode || "auto";
675
+ const toolsDisplay =
676
+ detected.length > 0
677
+ ? detected.map((t) => pc.green(t)).join(", ")
678
+ : pc.yellow("none");
679
+
680
+ p.note(
681
+ ` Tool: ${pc.cyan(toolDisplay)}
682
+ Mode: ${pc.cyan(modeDisplay)}
683
+ Installed: ${toolsDisplay}`,
684
+ "Current config",
685
+ );
686
+
687
+ let loop = true;
688
+ while (loop) {
689
+ const action = await p.select({
690
+ message: "What would you like to do?",
691
+ options: [
692
+ {
693
+ value: "tool",
694
+ label: "Change AI tool",
695
+ hint: `current: ${active || "auto-detect"}`,
696
+ },
697
+ {
698
+ value: "mode",
699
+ label: "Change mode",
700
+ hint: `current: ${modeDisplay}`,
701
+ },
702
+ { value: "config", label: "Edit config", hint: "open in $EDITOR" },
703
+ {
704
+ value: "status",
705
+ label: "Status",
706
+ hint: "show full installation info",
707
+ },
708
+ { value: "update", label: "Update", hint: "pull latest changes" },
709
+ {
710
+ value: "reinstall",
711
+ label: "Reinstall",
712
+ hint: "fresh installation",
713
+ },
714
+ {
715
+ value: "uninstall",
716
+ label: "Uninstall",
717
+ hint: "remove Lacy Shell",
718
+ },
719
+ { value: "done", label: "Done" },
720
+ ],
438
721
  });
439
722
 
440
- if (p.isCancel(confirm) || !confirm) {
441
- p.cancel('Uninstall cancelled');
442
- process.exit(0);
443
- }
444
-
445
- // Remove from .zshrc
446
- const zshrcSpinner = p.spinner();
447
- zshrcSpinner.start('Removing from .zshrc');
448
-
449
- if (existsSync(ZSHRC)) {
450
- let content = readFileSync(ZSHRC, 'utf-8');
451
- content = content
452
- .split('\n')
453
- .filter(line => !line.includes('lacy.plugin.zsh') && line.trim() !== '# Lacy Shell')
454
- .join('\n');
455
- writeFileSync(ZSHRC, content);
456
- zshrcSpinner.stop('Removed from .zshrc');
457
- } else {
458
- zshrcSpinner.stop('No .zshrc found');
723
+ if (p.isCancel(action) || action === "done") {
724
+ loop = false;
725
+ break;
459
726
  }
460
727
 
461
- // Remove installation
462
- const removeSpinner = p.spinner();
463
- removeSpinner.start('Removing installation');
728
+ if (action === "tool") {
729
+ const selectedTool = await p.select({
730
+ message: "Which AI CLI tool do you want to use?",
731
+ options: TOOLS.filter((t) => t.value !== "none").map((t) => ({
732
+ value: t.value,
733
+ label: t.label,
734
+ hint: detected.includes(t.value) ? pc.green("installed") : t.hint,
735
+ })),
736
+ initialValue: active || detected[0] || "auto",
737
+ });
738
+
739
+ if (p.isCancel(selectedTool)) continue;
740
+
741
+ if (selectedTool === "custom") {
742
+ const customCmd = await p.text({
743
+ message:
744
+ "Enter your custom command (query will be appended as a quoted argument):",
745
+ placeholder: "claude --dangerously-skip-permissions -p",
746
+ validate(value) {
747
+ if (!value || value.trim().length === 0)
748
+ return "Command cannot be empty";
749
+ },
750
+ });
751
+ if (p.isCancel(customCmd)) continue;
752
+ writeConfigValue("active", "custom");
753
+ writeConfigValue("custom_command", `"${customCmd}"`);
754
+ p.log.success(`Tool set to: ${pc.cyan("custom")} (${customCmd})`);
755
+ } else if (selectedTool === "auto") {
756
+ writeConfigValue("active", "");
757
+ p.log.success(`Tool set to: ${pc.cyan("auto-detect")}`);
758
+ } else {
759
+ writeConfigValue("active", selectedTool);
760
+ p.log.success(`Tool set to: ${pc.cyan(selectedTool)}`);
761
+ }
464
762
 
465
- if (existsSync(INSTALL_DIR)) {
466
- rmSync(INSTALL_DIR, { recursive: true, force: true });
467
- }
468
- if (existsSync(INSTALL_DIR_OLD)) {
469
- rmSync(INSTALL_DIR_OLD, { recursive: true, force: true });
763
+ await restartShell("Restart shell now to apply changes?");
764
+ loop = false;
470
765
  }
471
766
 
472
- removeSpinner.stop('Installation removed');
473
-
474
- p.log.success('Lacy Shell uninstalled');
475
-
476
- await restartShell('Restart shell now?');
477
-
478
- p.outro(`Run ${pc.cyan('source ~/.zshrc')} or restart your terminal.`);
479
- return;
480
- }
481
-
482
- if (action === 'update') {
483
- const updateSpinner = p.spinner();
484
- updateSpinner.start('Updating Lacy');
485
-
486
- // Determine which directory actually exists
487
- const updateDir = existsSync(INSTALL_DIR) ? INSTALL_DIR : INSTALL_DIR_OLD;
767
+ if (action === "mode") {
768
+ const selectedMode = await p.select({
769
+ message: "Which default mode?",
770
+ options: MODES.map((m) => ({
771
+ value: m.value,
772
+ label: m.label,
773
+ hint: m.hint,
774
+ })),
775
+ initialValue: mode || "auto",
776
+ });
777
+
778
+ if (p.isCancel(selectedMode)) continue;
779
+
780
+ writeConfigValue(
781
+ "default",
782
+ `${selectedMode} # Options: shell, agent, auto`,
783
+ );
784
+ p.log.success(`Mode set to: ${pc.cyan(selectedMode)}`);
785
+
786
+ await restartShell("Restart shell now to apply changes?");
787
+ loop = false;
788
+ }
488
789
 
489
- try {
490
- execSync('git pull origin main', { cwd: updateDir, stdio: 'pipe' });
491
- updateSpinner.stop('Lacy updated');
492
- p.log.success('Update complete!');
790
+ if (action === "config") {
791
+ const editor = process.env.EDITOR || process.env.VISUAL || "vi";
792
+ p.log.info(`Opening ${pc.cyan(CONFIG_FILE)} in ${editor}...`);
793
+ try {
794
+ execSync(`${editor} "${CONFIG_FILE}"`, { stdio: "inherit" });
795
+ } catch {
796
+ p.log.warn("Editor closed");
797
+ }
493
798
 
494
- await restartShell();
799
+ await restartShell("Restart shell now to apply changes?");
800
+ loop = false;
801
+ }
495
802
 
496
- p.outro(`Run ${pc.cyan('source ~/.zshrc')} or restart your terminal.`);
497
- } catch {
498
- updateSpinner.stop('Update failed');
499
- p.log.error('Could not update. Try reinstalling instead.');
500
- p.outro('');
803
+ if (action === "status") {
804
+ const dir = existsSync(INSTALL_DIR) ? INSTALL_DIR : INSTALL_DIR_OLD;
805
+ let sha = "";
806
+ try {
807
+ sha = execSync("git rev-parse --short HEAD", {
808
+ cwd: dir,
809
+ stdio: "pipe",
810
+ })
811
+ .toString()
812
+ .trim();
813
+ } catch {}
814
+
815
+ const shell = detectShell();
816
+ const rc = getShellConfig(shell).rcFile;
817
+ const rcConfigured =
818
+ existsSync(rc) && readFileSync(rc, "utf-8").includes("lacy.plugin");
819
+ const hasConfig = existsSync(CONFIG_FILE);
820
+
821
+ const lines = [
822
+ ` Installed: ${pc.green(dir)}`,
823
+ sha ? ` Version: git ${pc.dim(sha)}` : null,
824
+ ` Shell: ${pc.cyan(shell)} ${rcConfigured ? pc.green("configured") : pc.yellow("not configured")}`,
825
+ ` Config: ${hasConfig ? pc.green("exists") : pc.yellow("missing")}`,
826
+ ` Tool: ${pc.cyan(active || "auto-detect")}`,
827
+ ` Mode: ${pc.cyan(modeDisplay)}`,
828
+ ``,
829
+ ` ${pc.bold("AI CLI tools:")}`,
830
+ ...["lash", "claude", "opencode", "gemini", "codex"].map((t) =>
831
+ commandExists(t)
832
+ ? ` ${pc.green("✓")} ${t}`
833
+ : ` ${pc.dim("○")} ${pc.dim(t)}`,
834
+ ),
835
+ ].filter(Boolean);
836
+
837
+ p.note(lines.join("\n"), "Status");
838
+ // Don't break, let user pick another action
501
839
  }
502
- return;
503
- }
504
840
 
505
- if (action === 'reinstall') {
506
- // Remove existing and continue to install
507
- const removeSpinner = p.spinner();
508
- removeSpinner.start('Removing existing installation');
841
+ if (action === "uninstall") {
842
+ await doUninstall();
843
+ return;
844
+ }
509
845
 
510
- if (existsSync(INSTALL_DIR)) {
511
- rmSync(INSTALL_DIR, { recursive: true, force: true });
846
+ if (action === "update") {
847
+ const updateSpinner = p.spinner();
848
+ updateSpinner.start("Updating Lacy");
849
+ const updateDir = existsSync(INSTALL_DIR)
850
+ ? INSTALL_DIR
851
+ : INSTALL_DIR_OLD;
852
+ try {
853
+ execSync("git pull origin main", { cwd: updateDir, stdio: "pipe" });
854
+ updateSpinner.stop("Lacy updated");
855
+ p.log.success("Update complete!");
856
+ await restartShell();
857
+ p.outro("Restart your terminal to apply changes.");
858
+ } catch {
859
+ updateSpinner.stop("Update failed");
860
+ p.log.error("Could not update. Try reinstalling instead.");
861
+ }
862
+ return;
512
863
  }
513
- if (existsSync(INSTALL_DIR_OLD)) {
514
- rmSync(INSTALL_DIR_OLD, { recursive: true, force: true });
864
+
865
+ if (action === "reinstall") {
866
+ const removeSpinner = p.spinner();
867
+ removeSpinner.start("Removing existing installation");
868
+ // Backup config before removing
869
+ let configBackup = null;
870
+ if (existsSync(CONFIG_FILE)) {
871
+ configBackup = readFileSync(CONFIG_FILE, "utf-8");
872
+ }
873
+ if (existsSync(INSTALL_DIR)) {
874
+ rmSync(INSTALL_DIR, { recursive: true, force: true });
875
+ }
876
+ if (existsSync(INSTALL_DIR_OLD)) {
877
+ rmSync(INSTALL_DIR_OLD, { recursive: true, force: true });
878
+ }
879
+ // Restore config so install() sees it and preserves it
880
+ if (configBackup) {
881
+ mkdirSync(INSTALL_DIR, { recursive: true });
882
+ writeFileSync(CONFIG_FILE, configBackup);
883
+ }
884
+ removeSpinner.stop("Removed");
885
+ loop = false;
886
+ // Falls through to install()
515
887
  }
888
+ }
516
889
 
517
- removeSpinner.stop('Removed');
890
+ // Only reach here if reinstall was selected (or loop ended without return)
891
+ if (!isInstalled()) {
892
+ await install();
893
+ } else {
894
+ p.outro(pc.dim("https://github.com/lacymorrow/lacy"));
518
895
  }
896
+ return;
519
897
  }
520
898
 
521
899
  await install();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lacy",
3
- "version": "1.6.5",
3
+ "version": "1.7.0-beta.1",
4
4
  "description": "Install lacy — talk to your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,9 @@
10
10
  "index.mjs"
11
11
  ],
12
12
  "scripts": {
13
- "start": "node index.mjs"
13
+ "start": "node index.mjs",
14
+ "release": "npm publish",
15
+ "release:beta": "npm version prerelease --preid=beta && npm publish --tag beta"
14
16
  },
15
17
  "keywords": [
16
18
  "lacy",