lacy 1.8.22 → 1.8.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -20
- package/commands/info.sh +27 -0
- package/index.mjs +609 -974
- package/package.json +6 -6
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
|
-
|
|
13
|
+
mkdtempSync,
|
|
10
14
|
readFileSync,
|
|
11
|
-
|
|
15
|
+
renameSync,
|
|
12
16
|
rmSync,
|
|
17
|
+
writeFileSync,
|
|
13
18
|
} from "fs";
|
|
14
19
|
import { homedir, tmpdir } from "os";
|
|
15
|
-
import {
|
|
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
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
157
|
+
case "fish":
|
|
106
158
|
return {
|
|
107
|
-
|
|
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
|
|
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")
|
|
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(
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
237
|
+
function pathExists(path) {
|
|
238
|
+
try {
|
|
239
|
+
lstatSync(path);
|
|
240
|
+
return true;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
204
244
|
}
|
|
205
245
|
|
|
206
|
-
function
|
|
207
|
-
return existsSync(
|
|
246
|
+
function isLacyTree(dir) {
|
|
247
|
+
return existsSync(join(dir, "lacy.plugin.zsh")) && existsSync(join(dir, "lib", "core", "constants.sh"));
|
|
208
248
|
}
|
|
209
249
|
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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(`^[\\
|
|
262
|
+
const match = content.match(new RegExp(`^[ \\t]*${key}:[ \\t]*(.*)$`, "m"));
|
|
222
263
|
if (!match) return "";
|
|
223
|
-
return match[1].replace(/[
|
|
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
|
-
|
|
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, `$
|
|
273
|
+
writeFileSync(CONFIG_FILE, content.replace(regex, (_, lead) => (value ? `${lead} ${value}` : lead)));
|
|
232
274
|
}
|
|
233
275
|
}
|
|
234
276
|
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
});
|
|
293
|
+
# preheat:
|
|
294
|
+
# eager: false
|
|
295
|
+
# server_port: 4096
|
|
280
296
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
297
|
+
# logging:
|
|
298
|
+
# queries: false # true writes ~/.lacy/logs/queries.log (owner-only)
|
|
299
|
+
`;
|
|
300
|
+
}
|
|
285
301
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
//
|
|
321
|
+
// Download
|
|
294
322
|
// ============================================================================
|
|
295
323
|
|
|
296
|
-
//
|
|
297
|
-
function
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
433
|
+
async function restartShell(message = "Restart shell now to apply changes?", shellCmd = null) {
|
|
434
|
+
if (!isInteractive()) return;
|
|
358
435
|
|
|
359
|
-
|
|
436
|
+
const restart = await p.confirm({ message, initialValue: true });
|
|
437
|
+
if (p.isCancel(restart) || !restart) return;
|
|
360
438
|
|
|
361
|
-
|
|
439
|
+
const cmd = shellCmd || getShellConfig(detectShell()).shellCmd;
|
|
440
|
+
p.log.info(`Restarting ${cmd}...`);
|
|
441
|
+
restoreTerminalState();
|
|
362
442
|
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
460
|
+
// ============================================================================
|
|
461
|
+
// Uninstall: the work is done by uninstall.sh
|
|
462
|
+
// ============================================================================
|
|
371
463
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
527
|
+
s.stop("Could not install lash: npm or Homebrew is needed");
|
|
528
|
+
return false;
|
|
430
529
|
}
|
|
431
|
-
}
|
|
432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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 >
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
499
|
-
const
|
|
500
|
-
message: "
|
|
501
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
541
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
lashSpinner.start("Installing lash");
|
|
613
|
+
const shell = detectShell();
|
|
614
|
+
const shellConfig = getShellConfig(shell);
|
|
562
615
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}
|
|
644
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
645
|
+
writeFileSync(CONFIG_FILE, defaultConfig(selectedTool || ""));
|
|
646
|
+
}
|
|
633
647
|
|
|
634
|
-
|
|
635
|
-
p.log.info("Installing copilot via gh extension...");
|
|
648
|
+
await trackEvent("install");
|
|
636
649
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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(
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
"
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
process.exit(0);
|
|
734
|
+
await restartShell();
|
|
735
|
+
break;
|
|
734
736
|
}
|
|
735
737
|
|
|
736
|
-
if (
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
763
|
-
|
|
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
|
-
|
|
749
|
+
await restartShell();
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
767
752
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
811
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
847
|
-
|
|
793
|
+
if (action === "uninstall") {
|
|
794
|
+
await uninstall();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
848
797
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
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
|
-
|
|
933
|
-
|
|
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
|
|
971
|
-
npx lacy
|
|
972
|
-
npx lacy
|
|
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
|
|
976
|
-
-u, --uninstall
|
|
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
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
}
|
|
837
|
+
async function main() {
|
|
838
|
+
const args = process.argv.slice(2);
|
|
1133
839
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1228
|
-
|
|
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()
|
|
1240
|
-
process.exit(0)
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
});
|
|
874
|
+
main()
|
|
875
|
+
.then(() => process.exit(0))
|
|
876
|
+
.catch((e) => {
|
|
877
|
+
restoreTerminalState();
|
|
878
|
+
p.log.error(e.message);
|
|
879
|
+
process.exit(1);
|
|
880
|
+
});
|