@visorcraft/idlehands 0.9.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.
- package/LICENSE +21 -0
- package/README.md +30 -0
- package/dist/agent.js +2604 -0
- package/dist/agent.js.map +1 -0
- package/dist/anton/controller.js +341 -0
- package/dist/anton/controller.js.map +1 -0
- package/dist/anton/lock.js +110 -0
- package/dist/anton/lock.js.map +1 -0
- package/dist/anton/parser.js +303 -0
- package/dist/anton/parser.js.map +1 -0
- package/dist/anton/prompt.js +203 -0
- package/dist/anton/prompt.js.map +1 -0
- package/dist/anton/reporter.js +119 -0
- package/dist/anton/reporter.js.map +1 -0
- package/dist/anton/session.js +51 -0
- package/dist/anton/session.js.map +1 -0
- package/dist/anton/types.js +7 -0
- package/dist/anton/types.js.map +1 -0
- package/dist/anton/verifier.js +263 -0
- package/dist/anton/verifier.js.map +1 -0
- package/dist/bench/compare.js +239 -0
- package/dist/bench/compare.js.map +1 -0
- package/dist/bench/debug_hooks.js +17 -0
- package/dist/bench/debug_hooks.js.map +1 -0
- package/dist/bench/json_extract.js +22 -0
- package/dist/bench/json_extract.js.map +1 -0
- package/dist/bench/openclaw.js +86 -0
- package/dist/bench/openclaw.js.map +1 -0
- package/dist/bench/report.js +116 -0
- package/dist/bench/report.js.map +1 -0
- package/dist/bench/runner.js +312 -0
- package/dist/bench/runner.js.map +1 -0
- package/dist/bench/types.js +2 -0
- package/dist/bench/types.js.map +1 -0
- package/dist/bot/commands.js +444 -0
- package/dist/bot/commands.js.map +1 -0
- package/dist/bot/confirm-discord.js +133 -0
- package/dist/bot/confirm-discord.js.map +1 -0
- package/dist/bot/confirm-telegram.js +290 -0
- package/dist/bot/confirm-telegram.js.map +1 -0
- package/dist/bot/discord.js +826 -0
- package/dist/bot/discord.js.map +1 -0
- package/dist/bot/format.js +210 -0
- package/dist/bot/format.js.map +1 -0
- package/dist/bot/session-manager.js +270 -0
- package/dist/bot/session-manager.js.map +1 -0
- package/dist/bot/telegram.js +678 -0
- package/dist/bot/telegram.js.map +1 -0
- package/dist/cli/agent-turn.js +45 -0
- package/dist/cli/agent-turn.js.map +1 -0
- package/dist/cli/args.js +236 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/bot.js +252 -0
- package/dist/cli/bot.js.map +1 -0
- package/dist/cli/build-repl-context.js +365 -0
- package/dist/cli/build-repl-context.js.map +1 -0
- package/dist/cli/command-registry.js +20 -0
- package/dist/cli/command-registry.js.map +1 -0
- package/dist/cli/commands/anton.js +271 -0
- package/dist/cli/commands/anton.js.map +1 -0
- package/dist/cli/commands/editing.js +328 -0
- package/dist/cli/commands/editing.js.map +1 -0
- package/dist/cli/commands/model.js +274 -0
- package/dist/cli/commands/model.js.map +1 -0
- package/dist/cli/commands/project.js +255 -0
- package/dist/cli/commands/project.js.map +1 -0
- package/dist/cli/commands/runtime.js +63 -0
- package/dist/cli/commands/runtime.js.map +1 -0
- package/dist/cli/commands/session.js +281 -0
- package/dist/cli/commands/session.js.map +1 -0
- package/dist/cli/commands/tools.js +126 -0
- package/dist/cli/commands/tools.js.map +1 -0
- package/dist/cli/commands/trifecta.js +221 -0
- package/dist/cli/commands/trifecta.js.map +1 -0
- package/dist/cli/commands/tui.js +17 -0
- package/dist/cli/commands/tui.js.map +1 -0
- package/dist/cli/init.js +222 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/input.js +360 -0
- package/dist/cli/input.js.map +1 -0
- package/dist/cli/oneshot.js +254 -0
- package/dist/cli/oneshot.js.map +1 -0
- package/dist/cli/repl-context.js +2 -0
- package/dist/cli/repl-context.js.map +1 -0
- package/dist/cli/runtime-cmds.js +811 -0
- package/dist/cli/runtime-cmds.js.map +1 -0
- package/dist/cli/service.js +145 -0
- package/dist/cli/service.js.map +1 -0
- package/dist/cli/session-state.js +130 -0
- package/dist/cli/session-state.js.map +1 -0
- package/dist/cli/setup.js +815 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/shell.js +79 -0
- package/dist/cli/shell.js.map +1 -0
- package/dist/cli/status.js +392 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/watch.js +33 -0
- package/dist/cli/watch.js.map +1 -0
- package/dist/client.js +676 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.js +194 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.js +507 -0
- package/dist/config.js.map +1 -0
- package/dist/confirm/auto.js +13 -0
- package/dist/confirm/auto.js.map +1 -0
- package/dist/confirm/headless.js +41 -0
- package/dist/confirm/headless.js.map +1 -0
- package/dist/confirm/terminal.js +90 -0
- package/dist/confirm/terminal.js.map +1 -0
- package/dist/context.js +49 -0
- package/dist/context.js.map +1 -0
- package/dist/git.js +136 -0
- package/dist/git.js.map +1 -0
- package/dist/harnesses.js +171 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/history.js +139 -0
- package/dist/history.js.map +1 -0
- package/dist/index.js +700 -0
- package/dist/index.js.map +1 -0
- package/dist/indexer.js +374 -0
- package/dist/indexer.js.map +1 -0
- package/dist/jsonrpc.js +76 -0
- package/dist/jsonrpc.js.map +1 -0
- package/dist/lens.js +525 -0
- package/dist/lens.js.map +1 -0
- package/dist/lsp.js +605 -0
- package/dist/lsp.js.map +1 -0
- package/dist/markdown.js +275 -0
- package/dist/markdown.js.map +1 -0
- package/dist/mcp.js +554 -0
- package/dist/mcp.js.map +1 -0
- package/dist/recovery.js +178 -0
- package/dist/recovery.js.map +1 -0
- package/dist/replay.js +132 -0
- package/dist/replay.js.map +1 -0
- package/dist/replay_cli.js +24 -0
- package/dist/replay_cli.js.map +1 -0
- package/dist/runtime/executor.js +418 -0
- package/dist/runtime/executor.js.map +1 -0
- package/dist/runtime/planner.js +197 -0
- package/dist/runtime/planner.js.map +1 -0
- package/dist/runtime/store.js +289 -0
- package/dist/runtime/store.js.map +1 -0
- package/dist/runtime/types.js +2 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/safety.js +446 -0
- package/dist/safety.js.map +1 -0
- package/dist/spinner.js +224 -0
- package/dist/spinner.js.map +1 -0
- package/dist/sys/context.js +124 -0
- package/dist/sys/context.js.map +1 -0
- package/dist/sys/snapshot.sh +97 -0
- package/dist/term.js +61 -0
- package/dist/term.js.map +1 -0
- package/dist/themes.js +135 -0
- package/dist/themes.js.map +1 -0
- package/dist/tools.js +1114 -0
- package/dist/tools.js.map +1 -0
- package/dist/tui/branch-picker.js +65 -0
- package/dist/tui/branch-picker.js.map +1 -0
- package/dist/tui/command-handler.js +108 -0
- package/dist/tui/command-handler.js.map +1 -0
- package/dist/tui/confirm.js +90 -0
- package/dist/tui/confirm.js.map +1 -0
- package/dist/tui/controller.js +463 -0
- package/dist/tui/controller.js.map +1 -0
- package/dist/tui/event-bridge.js +44 -0
- package/dist/tui/event-bridge.js.map +1 -0
- package/dist/tui/events.js +2 -0
- package/dist/tui/events.js.map +1 -0
- package/dist/tui/keymap.js +144 -0
- package/dist/tui/keymap.js.map +1 -0
- package/dist/tui/layout.js +11 -0
- package/dist/tui/layout.js.map +1 -0
- package/dist/tui/render.js +186 -0
- package/dist/tui/render.js.map +1 -0
- package/dist/tui/screen.js +48 -0
- package/dist/tui/screen.js.map +1 -0
- package/dist/tui/state.js +167 -0
- package/dist/tui/state.js.map +1 -0
- package/dist/tui/theme.js +70 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/types.js +2 -0
- package/dist/tui/types.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/upgrade.js +412 -0
- package/dist/upgrade.js.map +1 -0
- package/dist/utils.js +87 -0
- package/dist/utils.js.map +1 -0
- package/dist/vault.js +520 -0
- package/dist/vault.js.map +1 -0
- package/dist/vim.js +160 -0
- package/dist/vim.js.map +1 -0
- package/package.json +67 -0
- package/src/sys/snapshot.sh +97 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive first-run setup wizard (full-screen TUI).
|
|
3
|
+
*
|
|
4
|
+
* `idlehands setup` walks the user through configuring their endpoint,
|
|
5
|
+
* model, working directory, and approval mode — then writes config.json.
|
|
6
|
+
*
|
|
7
|
+
* Returns 'run' if the user chose to launch Idle Hands, 'exit' otherwise.
|
|
8
|
+
*/
|
|
9
|
+
import readline from 'node:readline/promises';
|
|
10
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { defaultConfigPath, ensureConfigDir } from '../config.js';
|
|
14
|
+
import { parseUserIds, validateBotConfig, maskToken, serviceState, hasSystemd, migrateOldServices, installBotService, checkLingerEnabled, } from './bot.js';
|
|
15
|
+
import { HIDE_CURSOR, SHOW_CURSOR, ERASE_LINE, enterFullScreen as enterFullScreenBase, leaveFullScreen as leaveFullScreenBase, clearScreen, } from '../tui/screen.js';
|
|
16
|
+
// ── ANSI codes ───────────────────────────────────────────────────────
|
|
17
|
+
const BOLD = '\x1b[1m';
|
|
18
|
+
const DIM = '\x1b[2m';
|
|
19
|
+
const GREEN = '\x1b[32m';
|
|
20
|
+
const YELLOW = '\x1b[33m';
|
|
21
|
+
const RED = '\x1b[31m';
|
|
22
|
+
const CYAN = '\x1b[36m';
|
|
23
|
+
const MAGENTA = '\x1b[35m';
|
|
24
|
+
const RESET = '\x1b[0m';
|
|
25
|
+
// ── ASCII art ────────────────────────────────────────────────────────
|
|
26
|
+
// ANSI Shadow logo (74 chars wide — fits 80-col terminals with indent)
|
|
27
|
+
const LOGO_WIDE = [
|
|
28
|
+
`${CYAN} ██╗██████╗ ██╗ ███████╗${RESET} ${BOLD}██╗ ██╗ █████╗ ███╗ ██╗██████╗ ███████╗${RESET}`,
|
|
29
|
+
`${CYAN} ██║██╔══██╗██║ ██╔════╝${RESET} ${BOLD}██║ ██║██╔══██╗████╗ ██║██╔══██╗██╔════╝${RESET}`,
|
|
30
|
+
`${CYAN} ██║██║ ██║██║ █████╗${RESET} ${BOLD}███████║███████║██╔██╗ ██║██║ ██║███████╗${RESET}`,
|
|
31
|
+
`${CYAN} ██║██║ ██║██║ ██╔══╝${RESET} ${BOLD}██╔══██║██╔══██║██║╚██╗██║██║ ██║╚════██║${RESET}`,
|
|
32
|
+
`${CYAN} ██║██████╔╝███████╗███████╗${RESET} ${BOLD}██║ ██║██║ ██║██║ ╚████║██████╔╝███████║${RESET}`,
|
|
33
|
+
`${CYAN} ╚═╝╚═════╝ ╚══════╝╚══════╝${RESET} ${BOLD}╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═════╝ ╚══════╝${RESET}`,
|
|
34
|
+
];
|
|
35
|
+
// Compact fallback for narrow terminals
|
|
36
|
+
const LOGO_NARROW = [
|
|
37
|
+
` ${CYAN}${BOLD}I D L E${RESET} ${BOLD}H A N D S${RESET}`,
|
|
38
|
+
];
|
|
39
|
+
// ── Screen helpers ───────────────────────────────────────────────────
|
|
40
|
+
let inAltScreen = false;
|
|
41
|
+
function enterFullScreen() {
|
|
42
|
+
enterFullScreenBase();
|
|
43
|
+
inAltScreen = true;
|
|
44
|
+
}
|
|
45
|
+
function leaveFullScreen() {
|
|
46
|
+
leaveFullScreenBase();
|
|
47
|
+
inAltScreen = false;
|
|
48
|
+
}
|
|
49
|
+
function drawHeader(stepLabel) {
|
|
50
|
+
clearScreen();
|
|
51
|
+
const cols = process.stdout.columns ?? 80;
|
|
52
|
+
const logo = cols >= 78 ? LOGO_WIDE : LOGO_NARROW;
|
|
53
|
+
console.log();
|
|
54
|
+
for (const line of logo) {
|
|
55
|
+
console.log(line);
|
|
56
|
+
}
|
|
57
|
+
console.log(` ${DIM}Local-first coding agent${RESET}`);
|
|
58
|
+
if (stepLabel) {
|
|
59
|
+
console.log();
|
|
60
|
+
const bar = '─'.repeat(Math.min(cols - 4, 60));
|
|
61
|
+
console.log(` ${DIM}${bar}${RESET}`);
|
|
62
|
+
console.log(` ${BOLD}${stepLabel}${RESET}`);
|
|
63
|
+
}
|
|
64
|
+
console.log();
|
|
65
|
+
}
|
|
66
|
+
// ── Input helpers ────────────────────────────────────────────────────
|
|
67
|
+
function info(text) {
|
|
68
|
+
console.log(` ${DIM}${text}${RESET}`);
|
|
69
|
+
}
|
|
70
|
+
function success(text) {
|
|
71
|
+
console.log(` ${GREEN}✓${RESET} ${text}`);
|
|
72
|
+
}
|
|
73
|
+
function warn(text) {
|
|
74
|
+
console.log(` ${YELLOW}⚠${RESET} ${text}`);
|
|
75
|
+
}
|
|
76
|
+
async function ask(rl, prompt, fallback = '') {
|
|
77
|
+
process.stdout.write(SHOW_CURSOR);
|
|
78
|
+
const hint = fallback ? ` ${DIM}[${fallback}]${RESET}` : '';
|
|
79
|
+
const ans = (await rl.question(` ${prompt}${hint}: `)).trim();
|
|
80
|
+
return ans || fallback;
|
|
81
|
+
}
|
|
82
|
+
async function pause() {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const wasRaw = process.stdin.isRaw;
|
|
85
|
+
process.stdin.setRawMode(true);
|
|
86
|
+
process.stdin.resume();
|
|
87
|
+
process.stdout.write(` ${DIM}Press any key to continue...${RESET}`);
|
|
88
|
+
const onData = (buf) => {
|
|
89
|
+
const key = buf.toString();
|
|
90
|
+
if (key === '\x03') {
|
|
91
|
+
process.stdin.removeListener('data', onData);
|
|
92
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
93
|
+
leaveFullScreen();
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
process.stdin.removeListener('data', onData);
|
|
97
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
98
|
+
resolve();
|
|
99
|
+
};
|
|
100
|
+
process.stdin.on('data', onData);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function askYN(rl, prompt, defaultYes = true) {
|
|
104
|
+
process.stdout.write(SHOW_CURSOR);
|
|
105
|
+
const hint = defaultYes ? 'Y/n' : 'y/N';
|
|
106
|
+
const ans = (await rl.question(` ${prompt} ${DIM}[${hint}]${RESET}: `)).trim().toLowerCase();
|
|
107
|
+
if (!ans)
|
|
108
|
+
return defaultYes;
|
|
109
|
+
return ans.startsWith('y');
|
|
110
|
+
}
|
|
111
|
+
// ── Arrow-key selector ───────────────────────────────────────────────
|
|
112
|
+
function MOVE_UP(n) {
|
|
113
|
+
return n > 0 ? `\x1b[${n}A` : '';
|
|
114
|
+
}
|
|
115
|
+
async function selectChoice(choices, defaultValue) {
|
|
116
|
+
const defaultIdx = Math.max(0, choices.findIndex((c) => c.value === defaultValue));
|
|
117
|
+
let selected = defaultIdx;
|
|
118
|
+
function render(firstDraw) {
|
|
119
|
+
// +2 for the blank line + hint line after choices
|
|
120
|
+
if (!firstDraw) {
|
|
121
|
+
process.stdout.write(MOVE_UP(choices.length + 2));
|
|
122
|
+
}
|
|
123
|
+
const maxLen = Math.max(...choices.map((c) => c.value.length));
|
|
124
|
+
for (let i = 0; i < choices.length; i++) {
|
|
125
|
+
const c = choices[i];
|
|
126
|
+
const arrow = i === selected ? `${GREEN}❯${RESET}` : ' ';
|
|
127
|
+
const padded = c.value.padEnd(maxLen);
|
|
128
|
+
const label = i === selected ? `${BOLD}${padded}${RESET}` : `${DIM}${padded}${RESET}`;
|
|
129
|
+
const desc = c.desc ? ` ${DIM}${c.desc}${RESET}` : '';
|
|
130
|
+
process.stdout.write(`${ERASE_LINE} ${arrow} ${label}${desc}\n`);
|
|
131
|
+
}
|
|
132
|
+
process.stdout.write(`${ERASE_LINE}\n`);
|
|
133
|
+
process.stdout.write(`${ERASE_LINE} ${DIM}↑/↓ to move, Enter to select${RESET}\n`);
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write('\n');
|
|
136
|
+
process.stdout.write(HIDE_CURSOR);
|
|
137
|
+
render(true);
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
const wasRaw = process.stdin.isRaw;
|
|
140
|
+
process.stdin.setRawMode(true);
|
|
141
|
+
process.stdin.resume();
|
|
142
|
+
const onData = (buf) => {
|
|
143
|
+
const key = buf.toString();
|
|
144
|
+
if (key === '\x1b[A' || key === 'k') {
|
|
145
|
+
selected = (selected - 1 + choices.length) % choices.length;
|
|
146
|
+
render(false);
|
|
147
|
+
}
|
|
148
|
+
else if (key === '\x1b[B' || key === 'j') {
|
|
149
|
+
selected = (selected + 1) % choices.length;
|
|
150
|
+
render(false);
|
|
151
|
+
}
|
|
152
|
+
else if (key === '\r' || key === '\n') {
|
|
153
|
+
cleanup();
|
|
154
|
+
resolve(choices[selected].value);
|
|
155
|
+
}
|
|
156
|
+
else if (key === '\x03') {
|
|
157
|
+
process.stdin.removeListener('data', onData);
|
|
158
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
159
|
+
leaveFullScreen();
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
function cleanup() {
|
|
164
|
+
process.stdin.removeListener('data', onData);
|
|
165
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
166
|
+
process.stdout.write(MOVE_UP(1));
|
|
167
|
+
process.stdout.write(`${ERASE_LINE} ${GREEN}✓${RESET} ${choices[selected].value}\n`);
|
|
168
|
+
}
|
|
169
|
+
process.stdin.on('data', onData);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// ── Runtime detection ────────────────────────────────────────────────
|
|
173
|
+
async function getActiveRuntimeEndpoint() {
|
|
174
|
+
try {
|
|
175
|
+
const { loadActiveRuntime } = await import('../runtime/executor.js');
|
|
176
|
+
const active = await loadActiveRuntime();
|
|
177
|
+
return active?.endpoint ?? null;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ── Fullscreen TUI runtime add forms ─────────────────────────────────
|
|
184
|
+
async function addHostTUI(rl, existing) {
|
|
185
|
+
const editing = !!existing;
|
|
186
|
+
drawHeader(editing ? `Runtime — Edit Host: ${existing.id}` : 'Runtime — Add Host');
|
|
187
|
+
info('A host is a machine that runs inference.');
|
|
188
|
+
if (!editing)
|
|
189
|
+
info('Where is this host?');
|
|
190
|
+
const currentTransport = existing?.transport === 'ssh' ? 'remote' : 'local';
|
|
191
|
+
const transportLabel = await selectChoice([
|
|
192
|
+
{ value: 'local', desc: 'This machine' },
|
|
193
|
+
{ value: 'remote', desc: 'A remote server on your network' },
|
|
194
|
+
], currentTransport);
|
|
195
|
+
const transport = (transportLabel === 'remote' ? 'ssh' : 'local');
|
|
196
|
+
console.log();
|
|
197
|
+
const id = await ask(rl, 'Host id (e.g. my-gpu-box)', existing?.id ?? '');
|
|
198
|
+
if (!id)
|
|
199
|
+
return null;
|
|
200
|
+
const displayName = await ask(rl, 'Display name', existing?.display_name ?? id);
|
|
201
|
+
let connection = {};
|
|
202
|
+
if (transport === 'ssh') {
|
|
203
|
+
console.log();
|
|
204
|
+
connection.host = await ask(rl, 'SSH hostname or IP', existing?.connection?.host ?? '');
|
|
205
|
+
const portStr = await ask(rl, 'SSH port', String(existing?.connection?.port ?? 22));
|
|
206
|
+
connection.port = Number(portStr) || 22;
|
|
207
|
+
const user = await ask(rl, 'SSH user', existing?.connection?.user ?? '');
|
|
208
|
+
if (user)
|
|
209
|
+
connection.user = user;
|
|
210
|
+
const keyPath = await ask(rl, 'SSH key path', existing?.connection?.key_path ?? '');
|
|
211
|
+
if (keyPath)
|
|
212
|
+
connection.key_path = keyPath;
|
|
213
|
+
}
|
|
214
|
+
console.log();
|
|
215
|
+
const gpuRaw = await ask(rl, 'GPU tags, comma-separated (e.g. RTX 4090)', existing?.capabilities?.gpu?.join(', ') ?? '');
|
|
216
|
+
const gpu = gpuRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
217
|
+
const backendsRaw = await ask(rl, 'Supported backends, comma-separated (e.g. cuda,rocm,vulkan)', existing?.capabilities?.backends?.join(', ') ?? '');
|
|
218
|
+
const backends = backendsRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
219
|
+
console.log();
|
|
220
|
+
const stopCmd = await ask(rl, 'Stop model command', existing?.model_control?.stop_cmd ?? 'pkill -f llama-server || true');
|
|
221
|
+
const healthCmd = await ask(rl, 'Health check command', existing?.health?.check_cmd ?? 'true');
|
|
222
|
+
return {
|
|
223
|
+
id,
|
|
224
|
+
display_name: displayName,
|
|
225
|
+
enabled: existing?.enabled ?? true,
|
|
226
|
+
transport,
|
|
227
|
+
connection,
|
|
228
|
+
capabilities: { gpu, backends },
|
|
229
|
+
health: { check_cmd: healthCmd, timeout_sec: existing?.health?.timeout_sec ?? 5 },
|
|
230
|
+
model_control: { stop_cmd: stopCmd, cleanup_cmd: existing?.model_control?.cleanup_cmd ?? null },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
async function addBackendTUI(rl, hosts, existing) {
|
|
234
|
+
const editing = !!existing;
|
|
235
|
+
drawHeader(editing ? `Runtime — Edit Backend: ${existing.id}` : 'Runtime — Add Backend');
|
|
236
|
+
info('A backend is a GPU compute layer for running inference.');
|
|
237
|
+
console.log();
|
|
238
|
+
const id = await ask(rl, 'Backend id (e.g. vulkan-radv)', existing?.id ?? '');
|
|
239
|
+
if (!id)
|
|
240
|
+
return null;
|
|
241
|
+
const displayName = await ask(rl, 'Display name', existing?.display_name ?? id);
|
|
242
|
+
const type = await selectChoice([
|
|
243
|
+
{ value: 'vulkan', desc: 'Vulkan (RADV, etc.)' },
|
|
244
|
+
{ value: 'rocm', desc: 'AMD ROCm' },
|
|
245
|
+
{ value: 'cuda', desc: 'NVIDIA CUDA' },
|
|
246
|
+
{ value: 'metal', desc: 'Apple Metal' },
|
|
247
|
+
{ value: 'cpu', desc: 'CPU only' },
|
|
248
|
+
{ value: 'custom', desc: 'Custom backend' },
|
|
249
|
+
], existing?.type ?? 'vulkan');
|
|
250
|
+
let hostFilters = existing?.host_filters ?? 'any';
|
|
251
|
+
if (hosts.length > 1) {
|
|
252
|
+
console.log();
|
|
253
|
+
const currentFilters = Array.isArray(hostFilters) ? hostFilters.join(', ') : 'any';
|
|
254
|
+
const filtersRaw = await ask(rl, `Host filter (any, or: ${hosts.join(', ')})`, currentFilters);
|
|
255
|
+
hostFilters = filtersRaw === 'any' ? 'any' : filtersRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
256
|
+
}
|
|
257
|
+
console.log();
|
|
258
|
+
const verifyCmd = await ask(rl, 'Verify command (optional, exit 0 = OK)', existing?.verify_cmd ?? '');
|
|
259
|
+
console.log();
|
|
260
|
+
info('Environment variables passed to all models on this backend.');
|
|
261
|
+
info('Example: VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/radeon_icd.x86_64.json');
|
|
262
|
+
const existingEnvStr = existing?.env
|
|
263
|
+
? Object.entries(existing.env).map(([k, v]) => `${k}=${v}`).join(' ')
|
|
264
|
+
: '';
|
|
265
|
+
const envRaw = await ask(rl, 'Environment (KEY=VALUE, space-separated)', existingEnvStr);
|
|
266
|
+
const env = {};
|
|
267
|
+
if (envRaw) {
|
|
268
|
+
for (const pair of envRaw.split(/\s+/)) {
|
|
269
|
+
const eqIdx = pair.indexOf('=');
|
|
270
|
+
if (eqIdx > 0)
|
|
271
|
+
env[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
info('Extra CLI args passed to all models on this backend.');
|
|
275
|
+
info('Example: -fa 1 -mmp 0 -ub 2048 -ctk q4_0 -ctv q4_0 -ngl 0');
|
|
276
|
+
const existingArgsStr = existing?.args?.join(' ') ?? '';
|
|
277
|
+
const argsRaw = await ask(rl, 'Extra args (space-separated)', existingArgsStr);
|
|
278
|
+
const args = argsRaw ? argsRaw.split(/\s+/).filter(Boolean) : [];
|
|
279
|
+
return {
|
|
280
|
+
id,
|
|
281
|
+
display_name: displayName,
|
|
282
|
+
enabled: existing?.enabled ?? true,
|
|
283
|
+
type,
|
|
284
|
+
host_filters: hostFilters,
|
|
285
|
+
apply_cmd: existing?.apply_cmd ?? null,
|
|
286
|
+
verify_cmd: verifyCmd || null,
|
|
287
|
+
rollback_cmd: existing?.rollback_cmd ?? null,
|
|
288
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
289
|
+
...(args.length > 0 ? { args } : {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
async function addModelTUI(rl, hosts, backends, existing) {
|
|
293
|
+
const editing = !!existing;
|
|
294
|
+
drawHeader(editing ? `Runtime — Edit Model: ${existing.id}` : 'Runtime — Add Model');
|
|
295
|
+
info('A model is a GGUF (or other format) with launch and probe commands.');
|
|
296
|
+
info('Template variables: {backend_env}, {source}, {port}, {backend_args}, {host}');
|
|
297
|
+
console.log();
|
|
298
|
+
const id = await ask(rl, 'Model id (e.g. qwen3-coder-q4)', existing?.id ?? '');
|
|
299
|
+
if (!id)
|
|
300
|
+
return null;
|
|
301
|
+
const displayName = await ask(rl, 'Display name', existing?.display_name ?? id);
|
|
302
|
+
const source = await ask(rl, 'Model source (file path or URL)', existing?.source ?? '');
|
|
303
|
+
console.log();
|
|
304
|
+
const defaultStart = 'nohup {backend_env} llama-server -m {source} --port {port} --ctx-size 131072 {backend_args} --host 0.0.0.0 > /tmp/llama-server.log 2>&1 &';
|
|
305
|
+
const startCmd = await ask(rl, 'Start command (runs on host)', existing?.launch?.start_cmd ?? defaultStart);
|
|
306
|
+
const probeCmd = await ask(rl, 'Probe command (runs on host)', existing?.launch?.probe_cmd ?? 'curl -fsS http://127.0.0.1:{port}/health');
|
|
307
|
+
const probeTimeoutStr = await ask(rl, 'Probe timeout (seconds)', String(existing?.launch?.probe_timeout_sec ?? 60));
|
|
308
|
+
const probeTimeout = Math.max(5, Number(probeTimeoutStr) || 60);
|
|
309
|
+
const portStr = await ask(rl, 'Default port', String(existing?.runtime_defaults?.port ?? 8080));
|
|
310
|
+
const port = Number(portStr) || 8080;
|
|
311
|
+
let hostPolicy = existing?.host_policy ?? 'any';
|
|
312
|
+
let backendPolicy = existing?.backend_policy ?? 'any';
|
|
313
|
+
if (hosts.length > 1) {
|
|
314
|
+
console.log();
|
|
315
|
+
const currentHp = Array.isArray(hostPolicy) ? hostPolicy.join(', ') : 'any';
|
|
316
|
+
const hp = await ask(rl, `Host policy (any, or: ${hosts.join(', ')})`, currentHp);
|
|
317
|
+
hostPolicy = hp === 'any' ? 'any' : hp.split(',').map((s) => s.trim()).filter(Boolean);
|
|
318
|
+
}
|
|
319
|
+
if (backends.length > 1) {
|
|
320
|
+
const currentBp = Array.isArray(backendPolicy) ? backendPolicy.join(', ') : 'any';
|
|
321
|
+
const bp = await ask(rl, `Backend policy (any, or: ${backends.join(', ')})`, currentBp);
|
|
322
|
+
backendPolicy = bp === 'any' ? 'any' : bp.split(',').map((s) => s.trim()).filter(Boolean);
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
id,
|
|
326
|
+
display_name: displayName,
|
|
327
|
+
enabled: existing?.enabled ?? true,
|
|
328
|
+
source,
|
|
329
|
+
host_policy: hostPolicy,
|
|
330
|
+
backend_policy: backendPolicy,
|
|
331
|
+
launch: {
|
|
332
|
+
start_cmd: startCmd,
|
|
333
|
+
probe_cmd: probeCmd,
|
|
334
|
+
probe_timeout_sec: probeTimeout,
|
|
335
|
+
probe_interval_ms: existing?.launch?.probe_interval_ms ?? 1000,
|
|
336
|
+
},
|
|
337
|
+
runtime_defaults: { port },
|
|
338
|
+
split_policy: existing?.split_policy ?? null,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
// ── Main wizard ──────────────────────────────────────────────────────
|
|
342
|
+
// ── Bot setup helper (used by Step 5) ────────────────────────────────
|
|
343
|
+
async function setupBot(rl, target, defaultDir, existing) {
|
|
344
|
+
const isTg = target === 'Telegram';
|
|
345
|
+
drawHeader(`Step 5 of 5 — Bot Setup: ${target}`);
|
|
346
|
+
info(isTg ? 'You need a bot token from @BotFather on Telegram.' : 'You need a bot token from the Discord Developer Portal.');
|
|
347
|
+
info(`Allowed user IDs restrict who can ${isTg ? 'talk to' : 'use'} the bot.`);
|
|
348
|
+
console.log();
|
|
349
|
+
const token = await ask(rl, 'Bot token', existing?.token ?? '');
|
|
350
|
+
if (!token.trim()) {
|
|
351
|
+
warn(`No token provided. Skipping ${target}.`);
|
|
352
|
+
await pause();
|
|
353
|
+
return existing;
|
|
354
|
+
}
|
|
355
|
+
const usersStr = await ask(rl, 'Allowed user IDs (comma-separated)', existing?.allowed_users?.join(', ') ?? '');
|
|
356
|
+
const users = parseUserIds(usersStr);
|
|
357
|
+
if (users.length === 0) {
|
|
358
|
+
warn(`No valid user IDs. Skipping ${target}.`);
|
|
359
|
+
await pause();
|
|
360
|
+
return existing;
|
|
361
|
+
}
|
|
362
|
+
let guildId;
|
|
363
|
+
if (!isTg) {
|
|
364
|
+
const gid = await ask(rl, 'Guild/server ID (blank for DM-only)', existing?.guild_id ?? '');
|
|
365
|
+
guildId = gid.trim() || undefined;
|
|
366
|
+
}
|
|
367
|
+
const dir = await ask(rl, 'Default working directory', existing?.default_dir ?? defaultDir);
|
|
368
|
+
const cfg = {
|
|
369
|
+
token: token.trim(), allowed_users: users, default_dir: dir.trim() || defaultDir,
|
|
370
|
+
...(guildId ? { guild_id: guildId, allow_guilds: true } : {}),
|
|
371
|
+
};
|
|
372
|
+
const err = validateBotConfig(cfg);
|
|
373
|
+
if (err) {
|
|
374
|
+
warn(err);
|
|
375
|
+
await pause();
|
|
376
|
+
return existing;
|
|
377
|
+
}
|
|
378
|
+
return cfg;
|
|
379
|
+
}
|
|
380
|
+
export async function runSetup(existingConfigPath) {
|
|
381
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
382
|
+
console.error('Setup requires an interactive terminal.');
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
const configPath = existingConfigPath ?? defaultConfigPath();
|
|
386
|
+
let existingConfig = null;
|
|
387
|
+
try {
|
|
388
|
+
const raw = await fs.readFile(configPath, 'utf8');
|
|
389
|
+
existingConfig = JSON.parse(raw);
|
|
390
|
+
}
|
|
391
|
+
catch { /* no config yet */ }
|
|
392
|
+
const { loadRuntimes, saveRuntimes } = await import('../runtime/store.js');
|
|
393
|
+
enterFullScreen();
|
|
394
|
+
const exitHandler = () => { if (inAltScreen)
|
|
395
|
+
leaveFullScreen(); };
|
|
396
|
+
process.on('exit', exitHandler);
|
|
397
|
+
const rl = readline.createInterface({ input, output });
|
|
398
|
+
let result = 'exit';
|
|
399
|
+
let runtimeReady = false;
|
|
400
|
+
try {
|
|
401
|
+
// ── 1. Runtime (hosts → backends → models → select) ───────────
|
|
402
|
+
let runtimes;
|
|
403
|
+
try {
|
|
404
|
+
runtimes = await loadRuntimes();
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
runtimes = { schema_version: 1, hosts: [], backends: [], models: [] };
|
|
408
|
+
}
|
|
409
|
+
// ── 1a. Hosts ──────────────────────────────────────────────────
|
|
410
|
+
// eslint-disable-next-line no-constant-condition
|
|
411
|
+
while (true) {
|
|
412
|
+
runtimes = await loadRuntimes().catch(() => runtimes);
|
|
413
|
+
drawHeader('Step 1 of 6 — Runtime: Hosts');
|
|
414
|
+
if (runtimes.hosts.length === 0) {
|
|
415
|
+
info('No hosts configured yet.');
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
for (const h of runtimes.hosts) {
|
|
419
|
+
const loc = h.transport === 'ssh'
|
|
420
|
+
? `${h.connection?.user ? h.connection.user + '@' : ''}${h.connection?.host ?? '?'}`
|
|
421
|
+
: 'local';
|
|
422
|
+
success(`${h.id} ${DIM}(${loc})${RESET}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
console.log();
|
|
426
|
+
const hostChoices = [
|
|
427
|
+
...runtimes.hosts.map((h) => ({ value: `edit:${h.id}`, desc: `Edit ${h.display_name}` })),
|
|
428
|
+
{ value: 'add', desc: 'Add a new host' },
|
|
429
|
+
{ value: 'continue', desc: 'Continue →' },
|
|
430
|
+
];
|
|
431
|
+
const hostAction = await selectChoice(hostChoices, runtimes.hosts.length > 0 ? 'continue' : 'add');
|
|
432
|
+
if (hostAction === 'continue')
|
|
433
|
+
break;
|
|
434
|
+
if (hostAction === 'add') {
|
|
435
|
+
const host = await addHostTUI(rl);
|
|
436
|
+
if (host) {
|
|
437
|
+
runtimes.hosts.push(host);
|
|
438
|
+
await saveRuntimes(runtimes);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
else if (hostAction.startsWith('edit:')) {
|
|
442
|
+
const idx = runtimes.hosts.findIndex((h) => h.id === hostAction.slice(5));
|
|
443
|
+
if (idx >= 0) {
|
|
444
|
+
const updated = await addHostTUI(rl, runtimes.hosts[idx]);
|
|
445
|
+
if (updated) {
|
|
446
|
+
runtimes.hosts[idx] = updated;
|
|
447
|
+
await saveRuntimes(runtimes);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// ── 1b. Backends ───────────────────────────────────────────────
|
|
453
|
+
// eslint-disable-next-line no-constant-condition
|
|
454
|
+
while (true) {
|
|
455
|
+
runtimes = await loadRuntimes().catch(() => runtimes);
|
|
456
|
+
const hostIds = runtimes.hosts.map((h) => h.id);
|
|
457
|
+
drawHeader('Step 1 of 6 — Runtime: Backends');
|
|
458
|
+
if (runtimes.backends.length === 0) {
|
|
459
|
+
info('No backends configured yet.');
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
for (const b of runtimes.backends) {
|
|
463
|
+
const envCount = b.env ? Object.keys(b.env).length : 0;
|
|
464
|
+
const extra = [b.type, envCount > 0 ? `${envCount} env` : '', b.args?.length ? `${b.args.length} args` : '']
|
|
465
|
+
.filter(Boolean).join(', ');
|
|
466
|
+
success(`${b.id} ${DIM}(${extra})${RESET}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
console.log();
|
|
470
|
+
const backendChoices = [
|
|
471
|
+
...runtimes.backends.map((b) => ({ value: `edit:${b.id}`, desc: `Edit ${b.display_name}` })),
|
|
472
|
+
{ value: 'add', desc: 'Add a new backend' },
|
|
473
|
+
{ value: 'continue', desc: 'Continue →' },
|
|
474
|
+
];
|
|
475
|
+
const backendAction = await selectChoice(backendChoices, runtimes.backends.length > 0 ? 'continue' : 'add');
|
|
476
|
+
if (backendAction === 'continue')
|
|
477
|
+
break;
|
|
478
|
+
if (backendAction === 'add') {
|
|
479
|
+
const backend = await addBackendTUI(rl, hostIds);
|
|
480
|
+
if (backend) {
|
|
481
|
+
runtimes.backends.push(backend);
|
|
482
|
+
await saveRuntimes(runtimes);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
else if (backendAction.startsWith('edit:')) {
|
|
486
|
+
const idx = runtimes.backends.findIndex((b) => b.id === backendAction.slice(5));
|
|
487
|
+
if (idx >= 0) {
|
|
488
|
+
const updated = await addBackendTUI(rl, hostIds, runtimes.backends[idx]);
|
|
489
|
+
if (updated) {
|
|
490
|
+
runtimes.backends[idx] = updated;
|
|
491
|
+
await saveRuntimes(runtimes);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// ── 1c. Models ─────────────────────────────────────────────────
|
|
497
|
+
// eslint-disable-next-line no-constant-condition
|
|
498
|
+
while (true) {
|
|
499
|
+
runtimes = await loadRuntimes().catch(() => runtimes);
|
|
500
|
+
const hostIds = runtimes.hosts.map((h) => h.id);
|
|
501
|
+
const backendIds = runtimes.backends.map((b) => b.id);
|
|
502
|
+
drawHeader('Step 1 of 6 — Runtime: Models');
|
|
503
|
+
if (runtimes.models.length === 0) {
|
|
504
|
+
info('No models configured yet.');
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
for (const m of runtimes.models) {
|
|
508
|
+
const port = m.runtime_defaults?.port ?? 8080;
|
|
509
|
+
success(`${m.id} ${DIM}(port ${port})${RESET}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
console.log();
|
|
513
|
+
const modelChoices = [
|
|
514
|
+
...runtimes.models.map((m) => ({ value: `edit:${m.id}`, desc: `Edit ${m.display_name}` })),
|
|
515
|
+
{ value: 'add', desc: 'Add a new model' },
|
|
516
|
+
{ value: 'continue', desc: 'Continue →' },
|
|
517
|
+
];
|
|
518
|
+
const modelAction = await selectChoice(modelChoices, runtimes.models.length > 0 ? 'continue' : 'add');
|
|
519
|
+
if (modelAction === 'continue')
|
|
520
|
+
break;
|
|
521
|
+
if (modelAction === 'add') {
|
|
522
|
+
const model = await addModelTUI(rl, hostIds, backendIds);
|
|
523
|
+
if (model) {
|
|
524
|
+
runtimes.models.push(model);
|
|
525
|
+
await saveRuntimes(runtimes);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
else if (modelAction.startsWith('edit:')) {
|
|
529
|
+
const idx = runtimes.models.findIndex((m) => m.id === modelAction.slice(5));
|
|
530
|
+
if (idx >= 0) {
|
|
531
|
+
const updated = await addModelTUI(rl, hostIds, backendIds, runtimes.models[idx]);
|
|
532
|
+
if (updated) {
|
|
533
|
+
runtimes.models[idx] = updated;
|
|
534
|
+
await saveRuntimes(runtimes);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// ── 1d. Select a model ─────────────────────────────────────────
|
|
540
|
+
runtimes = await loadRuntimes();
|
|
541
|
+
const enabledModels = runtimes.models.filter((m) => m.enabled);
|
|
542
|
+
if (enabledModels.length > 0) {
|
|
543
|
+
drawHeader('Runtime — Start Model');
|
|
544
|
+
let modelId;
|
|
545
|
+
if (enabledModels.length === 1) {
|
|
546
|
+
modelId = enabledModels[0].id;
|
|
547
|
+
info(`Starting model: ${BOLD}${modelId}${RESET}`);
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
info('Which model should we start?');
|
|
551
|
+
console.log();
|
|
552
|
+
modelId = await selectChoice(enabledModels.map((m) => ({ value: m.id, desc: m.display_name })), enabledModels[0].id);
|
|
553
|
+
}
|
|
554
|
+
console.log();
|
|
555
|
+
info('Starting inference server...');
|
|
556
|
+
console.log();
|
|
557
|
+
try {
|
|
558
|
+
const { plan } = await import('../runtime/planner.js');
|
|
559
|
+
const { execute, loadActiveRuntime } = await import('../runtime/executor.js');
|
|
560
|
+
const active = await loadActiveRuntime();
|
|
561
|
+
const planResult = plan({ modelId, mode: 'live' }, runtimes, active);
|
|
562
|
+
if (!planResult.ok) {
|
|
563
|
+
warn(`Plan failed: ${planResult.reason}`);
|
|
564
|
+
}
|
|
565
|
+
else if (planResult.reuse) {
|
|
566
|
+
success('Runtime already active and healthy.');
|
|
567
|
+
runtimeReady = true;
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
const execResult = await execute(planResult, {
|
|
571
|
+
onStep: (step, status, detail) => {
|
|
572
|
+
if (status === 'start')
|
|
573
|
+
process.stdout.write(` ${DIM}${step.description}...${RESET}`);
|
|
574
|
+
else if (status === 'done')
|
|
575
|
+
process.stdout.write(` ${GREEN}✓${RESET}\n`);
|
|
576
|
+
else if (status === 'error') {
|
|
577
|
+
process.stdout.write(` ${RED}✗${RESET}\n`);
|
|
578
|
+
if (detail) {
|
|
579
|
+
for (const line of detail.split('\n').slice(0, 8)) {
|
|
580
|
+
console.log(` ${RED}${line}${RESET}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
force: true,
|
|
586
|
+
});
|
|
587
|
+
if (execResult.ok) {
|
|
588
|
+
console.log();
|
|
589
|
+
success('Inference server started!');
|
|
590
|
+
runtimeReady = true;
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
console.log();
|
|
594
|
+
warn(`Failed to start: ${execResult.error || 'unknown error'}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
catch (e) {
|
|
599
|
+
warn(`Error: ${e?.message ?? String(e)}`);
|
|
600
|
+
}
|
|
601
|
+
console.log();
|
|
602
|
+
await pause();
|
|
603
|
+
}
|
|
604
|
+
// ── 2. Working directory ───────────────────────────────────────
|
|
605
|
+
drawHeader('Step 2 of 6 — Working Directory');
|
|
606
|
+
info('Where Idle Hands reads/writes files by default.');
|
|
607
|
+
info('You can always override with --dir or /dir in a session.');
|
|
608
|
+
console.log();
|
|
609
|
+
const currentDir = existingConfig?.dir || process.cwd();
|
|
610
|
+
const dir = await ask(rl, 'Working directory', currentDir);
|
|
611
|
+
const resolvedDir = path.resolve(dir.replace(/^~/, process.env.HOME ?? '~'));
|
|
612
|
+
// ── 3. Approval mode ──────────────────────────────────────────
|
|
613
|
+
drawHeader('Step 3 of 6 — Approval Mode');
|
|
614
|
+
info('Controls how much the agent can do without asking you first.');
|
|
615
|
+
const currentApproval = existingConfig?.approval_mode || 'auto-edit';
|
|
616
|
+
const approvalMode = await selectChoice([
|
|
617
|
+
{ value: 'plan', desc: 'Read-only. Edits and commands recorded as plans.' },
|
|
618
|
+
{ value: 'default', desc: 'Confirms both file edits and shell commands.' },
|
|
619
|
+
{ value: 'auto-edit', desc: 'File edits automatic. Shell commands need confirmation.' },
|
|
620
|
+
{ value: 'yolo', desc: 'Everything automatic. Trusted codebases only.' },
|
|
621
|
+
], currentApproval);
|
|
622
|
+
// ── 4. Response Timeout ─────────────────────────────────────
|
|
623
|
+
drawHeader('Step 4 of 6 — Limits');
|
|
624
|
+
info('How long to wait for model responses and how many tool rounds per prompt.');
|
|
625
|
+
console.log();
|
|
626
|
+
const currentResponseTimeout = existingConfig?.response_timeout ?? 600;
|
|
627
|
+
const responseTimeoutStr = await ask(rl, 'Response timeout in seconds (how long to wait for a reply)', String(currentResponseTimeout));
|
|
628
|
+
const responseTimeout = Math.max(10, parseInt(responseTimeoutStr, 10) || 300);
|
|
629
|
+
const currentMaxIter = existingConfig?.max_iterations ?? 100;
|
|
630
|
+
const maxIterStr = await ask(rl, 'Max tool rounds per prompt (higher = more complex tasks)', String(currentMaxIter));
|
|
631
|
+
const maxIterations = Math.max(1, parseInt(maxIterStr, 10) || 100);
|
|
632
|
+
// ── 5. Theme ──────────────────────────────────────────────────
|
|
633
|
+
drawHeader('Step 5 of 6 — Theme');
|
|
634
|
+
const currentTheme = existingConfig?.theme || 'default';
|
|
635
|
+
const theme = await selectChoice([
|
|
636
|
+
{ value: 'default', desc: 'Standard colors' },
|
|
637
|
+
{ value: 'dark', desc: 'High contrast for dark terminals' },
|
|
638
|
+
{ value: 'light', desc: 'For light terminal backgrounds' },
|
|
639
|
+
{ value: 'minimal', desc: 'Stripped-down, less color' },
|
|
640
|
+
{ value: 'hacker', desc: 'Green on black' },
|
|
641
|
+
], currentTheme);
|
|
642
|
+
// ── 5. Bot Setup ──────────────────────────────────────────────
|
|
643
|
+
const existingTg = existingConfig?.bot?.telegram;
|
|
644
|
+
const existingDc = existingConfig?.bot?.discord;
|
|
645
|
+
let botTelegram = (existingTg?.token && existingTg?.allowed_users?.length)
|
|
646
|
+
? { token: existingTg.token, allowed_users: existingTg.allowed_users, default_dir: existingTg.default_dir ?? '' }
|
|
647
|
+
: null;
|
|
648
|
+
let botDiscord = (existingDc?.token && existingDc?.allowed_users?.length)
|
|
649
|
+
? { token: existingDc.token, allowed_users: existingDc.allowed_users, default_dir: existingDc.default_dir ?? '', guild_id: existingDc.guild_id, allow_guilds: existingDc.allow_guilds }
|
|
650
|
+
: null;
|
|
651
|
+
// eslint-disable-next-line no-constant-condition
|
|
652
|
+
while (true) {
|
|
653
|
+
drawHeader('Step 6 of 6 — Bot Setup');
|
|
654
|
+
info('Configure chat bot frontends (Telegram, Discord).');
|
|
655
|
+
console.log();
|
|
656
|
+
const botChoices = [];
|
|
657
|
+
if (botTelegram) {
|
|
658
|
+
botChoices.push({ value: 'edit-tg', desc: `\x1b[32m✓\x1b[0m Telegram (${maskToken(botTelegram.token)}, ${botTelegram.allowed_users.length} user${botTelegram.allowed_users.length === 1 ? '' : 's'}) — Edit` });
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
botChoices.push({ value: 'add-tg', desc: 'Set up Telegram bot' });
|
|
662
|
+
}
|
|
663
|
+
if (botDiscord) {
|
|
664
|
+
botChoices.push({ value: 'edit-dc', desc: `\x1b[32m✓\x1b[0m Discord (${maskToken(botDiscord.token)}, ${botDiscord.allowed_users.length} user${botDiscord.allowed_users.length === 1 ? '' : 's'}) — Edit` });
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
botChoices.push({ value: 'add-dc', desc: 'Set up Discord bot' });
|
|
668
|
+
}
|
|
669
|
+
botChoices.push({ value: 'continue', desc: (botTelegram || botDiscord) ? 'Continue →' : 'Skip — no bots' });
|
|
670
|
+
const botAction = await selectChoice(botChoices, 'continue');
|
|
671
|
+
if (botAction === 'continue')
|
|
672
|
+
break;
|
|
673
|
+
if (botAction === 'add-tg' || botAction === 'edit-tg') {
|
|
674
|
+
botTelegram = await setupBot(rl, 'Telegram', resolvedDir, botTelegram);
|
|
675
|
+
}
|
|
676
|
+
if (botAction === 'add-dc' || botAction === 'edit-dc') {
|
|
677
|
+
botDiscord = await setupBot(rl, 'Discord', resolvedDir, botDiscord);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// ── Summary + Write ───────────────────────────────────────────
|
|
681
|
+
drawHeader('Setup Complete');
|
|
682
|
+
const activeEndpoint = await getActiveRuntimeEndpoint();
|
|
683
|
+
const endpoint = activeEndpoint || existingConfig?.endpoint || 'http://127.0.0.1:8080/v1';
|
|
684
|
+
if (activeEndpoint) {
|
|
685
|
+
console.log(` Endpoint: ${CYAN}${activeEndpoint}${RESET} ${DIM}(from runtime)${RESET}`);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
console.log(` Endpoint: ${DIM}(will be set when a model is started)${RESET}`);
|
|
689
|
+
}
|
|
690
|
+
console.log(` Directory: ${CYAN}${resolvedDir}${RESET}`);
|
|
691
|
+
console.log(` Approval mode: ${CYAN}${approvalMode}${RESET}`);
|
|
692
|
+
console.log(` Theme: ${CYAN}${theme}${RESET}`);
|
|
693
|
+
if (botTelegram) {
|
|
694
|
+
console.log(` Telegram bot: ${GREEN}✓${RESET} ${DIM}(token: ${maskToken(botTelegram.token)}, ${botTelegram.allowed_users.length} user${botTelegram.allowed_users.length === 1 ? '' : 's'})${RESET}`);
|
|
695
|
+
}
|
|
696
|
+
if (botDiscord) {
|
|
697
|
+
console.log(` Discord bot: ${GREEN}✓${RESET} ${DIM}(token: ${maskToken(botDiscord.token)}, ${botDiscord.allowed_users.length} user${botDiscord.allowed_users.length === 1 ? '' : 's'}${botDiscord.guild_id ? `, guild: ${botDiscord.guild_id}` : ''})${RESET}`);
|
|
698
|
+
}
|
|
699
|
+
console.log(` Config file: ${DIM}${configPath}${RESET}`);
|
|
700
|
+
console.log();
|
|
701
|
+
const confirmed = await askYN(rl, 'Write this config?', true);
|
|
702
|
+
if (!confirmed) {
|
|
703
|
+
console.log(`\n ${DIM}Cancelled. No changes written.${RESET}\n`);
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
const finalConfig = existingConfig ? { ...existingConfig } : {};
|
|
707
|
+
finalConfig.endpoint = endpoint;
|
|
708
|
+
finalConfig.model = '';
|
|
709
|
+
finalConfig.dir = resolvedDir;
|
|
710
|
+
finalConfig.approval_mode = approvalMode;
|
|
711
|
+
finalConfig.no_confirm = approvalMode === 'yolo';
|
|
712
|
+
finalConfig.response_timeout = responseTimeout;
|
|
713
|
+
finalConfig.max_iterations = maxIterations;
|
|
714
|
+
finalConfig.theme = theme;
|
|
715
|
+
if (finalConfig.max_tokens === undefined)
|
|
716
|
+
finalConfig.max_tokens = 16384;
|
|
717
|
+
if (finalConfig.temperature === undefined)
|
|
718
|
+
finalConfig.temperature = 0.2;
|
|
719
|
+
if (finalConfig.timeout === undefined)
|
|
720
|
+
finalConfig.timeout = 600;
|
|
721
|
+
if (finalConfig.max_iterations === undefined)
|
|
722
|
+
finalConfig.max_iterations = 100;
|
|
723
|
+
if (finalConfig.mode === undefined)
|
|
724
|
+
finalConfig.mode = 'code';
|
|
725
|
+
// Bot config
|
|
726
|
+
if (botTelegram) {
|
|
727
|
+
finalConfig.bot = finalConfig.bot ?? {};
|
|
728
|
+
finalConfig.bot.telegram = {
|
|
729
|
+
...(finalConfig.bot.telegram ?? {}),
|
|
730
|
+
token: botTelegram.token,
|
|
731
|
+
allowed_users: botTelegram.allowed_users,
|
|
732
|
+
default_dir: botTelegram.default_dir,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (botDiscord) {
|
|
736
|
+
finalConfig.bot = finalConfig.bot ?? {};
|
|
737
|
+
finalConfig.bot.discord = {
|
|
738
|
+
...(finalConfig.bot.discord ?? {}),
|
|
739
|
+
token: botDiscord.token,
|
|
740
|
+
allowed_users: botDiscord.allowed_users,
|
|
741
|
+
default_dir: botDiscord.default_dir,
|
|
742
|
+
guild_id: botDiscord.guild_id,
|
|
743
|
+
allow_guilds: botDiscord.allow_guilds,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
await ensureConfigDir(configPath);
|
|
747
|
+
await fs.writeFile(configPath, JSON.stringify(finalConfig, null, 2) + '\n', 'utf8');
|
|
748
|
+
success('Config saved!');
|
|
749
|
+
}
|
|
750
|
+
// ── Service installation ─────────────────────────────────────
|
|
751
|
+
if ((botTelegram || botDiscord) && confirmed && hasSystemd()) {
|
|
752
|
+
drawHeader('Background Service');
|
|
753
|
+
const oldMigrated = await migrateOldServices();
|
|
754
|
+
if (oldMigrated.length > 0)
|
|
755
|
+
info(`Migrated old service(s): ${oldMigrated.join(', ')}`);
|
|
756
|
+
const st = serviceState();
|
|
757
|
+
if (st.active) {
|
|
758
|
+
info('Bot service is already running.');
|
|
759
|
+
if (await askYN(rl, 'Restart to apply new config?', true)) {
|
|
760
|
+
(await import('node:child_process')).spawnSync('systemctl', ['--user', 'restart', st.name], { stdio: 'pipe' });
|
|
761
|
+
success('Service restarted.');
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
info('Install a background service so your bot(s) run automatically.');
|
|
766
|
+
info('Uses systemd user service — no sudo required.');
|
|
767
|
+
console.log();
|
|
768
|
+
if (await askYN(rl, 'Install and start service?', true)) {
|
|
769
|
+
await installBotService();
|
|
770
|
+
success('Service installed and started!');
|
|
771
|
+
info('View logs: journalctl --user -u idlehands-bot.service -f');
|
|
772
|
+
if (!checkLingerEnabled())
|
|
773
|
+
warn('Linger not enabled. Run: loginctl enable-linger');
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
console.log();
|
|
777
|
+
await pause();
|
|
778
|
+
}
|
|
779
|
+
// ── Final action ──────────────────────────────────────────────
|
|
780
|
+
const defaultAction = (confirmed && runtimeReady) ? 'run' : 'exit';
|
|
781
|
+
const action = await selectChoice([
|
|
782
|
+
{ value: 'run', desc: 'Start Idle Hands' },
|
|
783
|
+
{ value: 'exit', desc: 'Return to terminal' },
|
|
784
|
+
], defaultAction);
|
|
785
|
+
result = action;
|
|
786
|
+
}
|
|
787
|
+
finally {
|
|
788
|
+
try {
|
|
789
|
+
rl.close();
|
|
790
|
+
}
|
|
791
|
+
catch { }
|
|
792
|
+
if (inAltScreen)
|
|
793
|
+
leaveFullScreen();
|
|
794
|
+
process.removeListener('exit', exitHandler);
|
|
795
|
+
}
|
|
796
|
+
return result;
|
|
797
|
+
}
|
|
798
|
+
// ── Guided runtime onboarding (CLI fallback) ─────────────────────────
|
|
799
|
+
/** Called from index.ts when createSession fails — suggests setup. */
|
|
800
|
+
export async function guidedRuntimeOnboarding() {
|
|
801
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
802
|
+
return false;
|
|
803
|
+
const { loadRuntimes } = await import('../runtime/store.js');
|
|
804
|
+
try {
|
|
805
|
+
const rt = await loadRuntimes();
|
|
806
|
+
if (rt.hosts.length > 0 && rt.backends.length > 0 && rt.models.length > 0)
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
console.log(`\n ${YELLOW}⚠${RESET} No models found. Run ${BOLD}idlehands setup${RESET} for guided configuration.\n`);
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
//# sourceMappingURL=setup.js.map
|