@moxxy/cli 0.0.12 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +278 -112
  2. package/bin/moxxy +10 -0
  3. package/package.json +36 -53
  4. package/src/api-client.js +286 -0
  5. package/src/cli.js +349 -0
  6. package/src/commands/agent.js +413 -0
  7. package/src/commands/auth.js +326 -0
  8. package/src/commands/channel.js +285 -0
  9. package/src/commands/doctor.js +261 -0
  10. package/src/commands/events.js +80 -0
  11. package/src/commands/gateway.js +428 -0
  12. package/src/commands/heartbeat.js +145 -0
  13. package/src/commands/init.js +954 -0
  14. package/src/commands/mcp.js +278 -0
  15. package/src/commands/plugin.js +583 -0
  16. package/src/commands/provider.js +1934 -0
  17. package/src/commands/settings.js +224 -0
  18. package/src/commands/skill.js +125 -0
  19. package/src/commands/template.js +237 -0
  20. package/src/commands/uninstall.js +196 -0
  21. package/src/commands/update.js +406 -0
  22. package/src/commands/vault.js +219 -0
  23. package/src/help.js +392 -0
  24. package/src/lib/plugin-registry.js +98 -0
  25. package/src/platform.js +40 -0
  26. package/src/sse-client.js +79 -0
  27. package/src/tui/action-wizards.js +130 -0
  28. package/src/tui/app.jsx +859 -0
  29. package/src/tui/components/action-picker.jsx +86 -0
  30. package/src/tui/components/chat-panel.jsx +120 -0
  31. package/src/tui/components/footer.jsx +13 -0
  32. package/src/tui/components/header.jsx +45 -0
  33. package/src/tui/components/input-area.jsx +384 -0
  34. package/src/tui/components/messages/ask-message.jsx +13 -0
  35. package/src/tui/components/messages/assistant-message.jsx +165 -0
  36. package/src/tui/components/messages/channel-message.jsx +18 -0
  37. package/src/tui/components/messages/event-message.jsx +22 -0
  38. package/src/tui/components/messages/hive-status.jsx +34 -0
  39. package/src/tui/components/messages/skill-message.jsx +31 -0
  40. package/src/tui/components/messages/system-message.jsx +12 -0
  41. package/src/tui/components/messages/thinking.jsx +25 -0
  42. package/src/tui/components/messages/tool-group.jsx +62 -0
  43. package/src/tui/components/messages/tool-message.jsx +66 -0
  44. package/src/tui/components/messages/user-message.jsx +12 -0
  45. package/src/tui/components/model-picker.jsx +138 -0
  46. package/src/tui/components/multiline-input.jsx +72 -0
  47. package/src/tui/events-handler.js +730 -0
  48. package/src/tui/helpers.js +59 -0
  49. package/src/tui/hooks/use-command-handler.js +451 -0
  50. package/src/tui/index.jsx +55 -0
  51. package/src/tui/input-utils.js +26 -0
  52. package/src/tui/markdown-renderer.js +66 -0
  53. package/src/tui/mcp-wizard.js +136 -0
  54. package/src/tui/model-picker.js +174 -0
  55. package/src/tui/slash-commands.js +26 -0
  56. package/src/tui/store.js +12 -0
  57. package/src/tui/theme.js +17 -0
  58. package/src/ui.js +109 -0
  59. package/bin/moxxy.js +0 -2
  60. package/dist/chunk-23LZYKQ6.mjs +0 -1131
  61. package/dist/chunk-2FZEA3NG.mjs +0 -457
  62. package/dist/chunk-3KDPLS22.mjs +0 -1131
  63. package/dist/chunk-3QRJTRBT.mjs +0 -1102
  64. package/dist/chunk-6DZX6EAA.mjs +0 -37
  65. package/dist/chunk-A4WRDUNY.mjs +0 -1242
  66. package/dist/chunk-C46NSEKG.mjs +0 -211
  67. package/dist/chunk-CAUXONEF.mjs +0 -1131
  68. package/dist/chunk-CPL5V56X.mjs +0 -1131
  69. package/dist/chunk-CTBVTTBG.mjs +0 -440
  70. package/dist/chunk-FHHLXTEZ.mjs +0 -1121
  71. package/dist/chunk-FXY3GPVA.mjs +0 -1126
  72. package/dist/chunk-GSNMMI3H.mjs +0 -530
  73. package/dist/chunk-HHOAOGUS.mjs +0 -1242
  74. package/dist/chunk-ITBO7BKI.mjs +0 -1243
  75. package/dist/chunk-J33O35WX.mjs +0 -532
  76. package/dist/chunk-N5JTPB6U.mjs +0 -820
  77. package/dist/chunk-NGVL4Q5C.mjs +0 -1102
  78. package/dist/chunk-Q2OCMNYI.mjs +0 -1131
  79. package/dist/chunk-QDVRLN6D.mjs +0 -1121
  80. package/dist/chunk-QO2JONHP.mjs +0 -1131
  81. package/dist/chunk-RVAPILHA.mjs +0 -1242
  82. package/dist/chunk-S7YBOV7E.mjs +0 -1131
  83. package/dist/chunk-SHIG6Y5L.mjs +0 -1074
  84. package/dist/chunk-SOFST2PV.mjs +0 -1242
  85. package/dist/chunk-SUNUYS6G.mjs +0 -1243
  86. package/dist/chunk-TMZWETMH.mjs +0 -1242
  87. package/dist/chunk-TYD7NMMI.mjs +0 -581
  88. package/dist/chunk-TYQ3YS42.mjs +0 -1068
  89. package/dist/chunk-UALWCJ7F.mjs +0 -1131
  90. package/dist/chunk-UQZKODNW.mjs +0 -1124
  91. package/dist/chunk-USC6R2ON.mjs +0 -1242
  92. package/dist/chunk-W32EQCVC.mjs +0 -823
  93. package/dist/chunk-WMB5ENMC.mjs +0 -1242
  94. package/dist/chunk-WNHA5JAP.mjs +0 -1242
  95. package/dist/cli-2AIWTL6F.mjs +0 -8
  96. package/dist/cli-2QKJ5UUL.mjs +0 -8
  97. package/dist/cli-4RIS6DQX.mjs +0 -8
  98. package/dist/cli-5RH4VBBL.mjs +0 -7
  99. package/dist/cli-7MK4YGOP.mjs +0 -7
  100. package/dist/cli-B4KH6MZI.mjs +0 -8
  101. package/dist/cli-CGO2LZ6Z.mjs +0 -8
  102. package/dist/cli-CVP26EL2.mjs +0 -8
  103. package/dist/cli-DDRVVNAV.mjs +0 -8
  104. package/dist/cli-E7U56QVQ.mjs +0 -8
  105. package/dist/cli-EQNRMLL3.mjs +0 -8
  106. package/dist/cli-F5RUHHH4.mjs +0 -8
  107. package/dist/cli-LX6FFSEF.mjs +0 -8
  108. package/dist/cli-LY74GWKR.mjs +0 -6
  109. package/dist/cli-MAT3ZJHI.mjs +0 -8
  110. package/dist/cli-NJXXTQYF.mjs +0 -8
  111. package/dist/cli-O4ZGFAZG.mjs +0 -8
  112. package/dist/cli-ORVLI3UQ.mjs +0 -8
  113. package/dist/cli-PV43ZVKA.mjs +0 -8
  114. package/dist/cli-REVD6ISM.mjs +0 -8
  115. package/dist/cli-TBX76KQX.mjs +0 -8
  116. package/dist/cli-THCGF7SQ.mjs +0 -8
  117. package/dist/cli-TLX5ENVM.mjs +0 -8
  118. package/dist/cli-TMNI5ZYE.mjs +0 -8
  119. package/dist/cli-TNJHCBQA.mjs +0 -6
  120. package/dist/cli-TUX22CZP.mjs +0 -8
  121. package/dist/cli-XJVH7EEP.mjs +0 -8
  122. package/dist/cli-XXOW4VXJ.mjs +0 -8
  123. package/dist/cli-XZ5RESNB.mjs +0 -6
  124. package/dist/cli-YCBYZ76Q.mjs +0 -8
  125. package/dist/cli-ZLMQCU7X.mjs +0 -8
  126. package/dist/dist-2VGKJRBH.mjs +0 -6820
  127. package/dist/dist-37BNX4QG.mjs +0 -7081
  128. package/dist/dist-7LTHRYKA.mjs +0 -11569
  129. package/dist/dist-7XJPQW5C.mjs +0 -6950
  130. package/dist/dist-AYMVOW7T.mjs +0 -7123
  131. package/dist/dist-BHUWCDRS.mjs +0 -7132
  132. package/dist/dist-FAXRJMEN.mjs +0 -6812
  133. package/dist/dist-HQGANM3P.mjs +0 -6976
  134. package/dist/dist-KATLOZQV.mjs +0 -7054
  135. package/dist/dist-KLSB6YHV.mjs +0 -6964
  136. package/dist/dist-LKIOZQ42.mjs +0 -17
  137. package/dist/dist-UYA4RJUH.mjs +0 -2792
  138. package/dist/dist-ZYHCBILM.mjs +0 -6993
  139. package/dist/index.d.mts +0 -23
  140. package/dist/index.d.ts +0 -23
  141. package/dist/index.js +0 -25531
  142. package/dist/index.mjs +0 -18
  143. package/dist/src-APP5P3UD.mjs +0 -1386
  144. package/dist/src-D5HMDDVE.mjs +0 -1324
  145. package/dist/src-EK3WD4AU.mjs +0 -1327
  146. package/dist/src-LSZFLMFN.mjs +0 -1400
  147. package/dist/src-T77DFTFP.mjs +0 -1407
  148. package/dist/src-WIOCZRAC.mjs +0 -1397
  149. package/dist/src-YK6CHCMW.mjs +0 -1400
@@ -0,0 +1,954 @@
1
+ import { p, handleCancel, withSpinner, showResult } from '../ui.js';
2
+ import { VALID_SCOPES } from './auth.js';
3
+ import { BUILTIN_PROVIDERS, ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, loginAnthropic, loginOpenAiCodex, checkProviderCredentials, resolveBuiltinProviderModels } from './provider.js';
4
+ import { shellExportInstruction, shellProfileName } from '../platform.js';
5
+ import { mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, copyFileSync, createWriteStream } from 'node:fs';
6
+ import { join, resolve } from 'node:path';
7
+ import { homedir, platform, arch } from 'node:os';
8
+ import { execSync } from 'node:child_process';
9
+ import { pipeline } from 'node:stream/promises';
10
+
11
+ export function getMoxxyHome() {
12
+ return process.env.MOXXY_HOME || join(homedir(), '.moxxy');
13
+ }
14
+
15
+ /**
16
+ * Read the auth_mode from ~/.moxxy/config/gateway.yaml.
17
+ * Returns 'token' | 'loopback'.
18
+ * Env var MOXXY_LOOPBACK=true overrides the config file.
19
+ */
20
+ export function readAuthMode() {
21
+ if (process.env.MOXXY_LOOPBACK === 'true' || process.env.MOXXY_LOOPBACK === '1') {
22
+ return 'loopback';
23
+ }
24
+ const configPath = join(getMoxxyHome(), 'config', 'gateway.yaml');
25
+ try {
26
+ const raw = readFileSync(configPath, 'utf-8');
27
+ const match = raw.match(/^auth_mode:\s*(.+)$/m);
28
+ if (match && match[1].trim() === 'loopback') return 'loopback';
29
+ } catch {
30
+ // config missing or unparseable = default to token
31
+ }
32
+ return 'token';
33
+ }
34
+
35
+ /**
36
+ * Reset all tokens by clearing the api_tokens table via sqlite3 CLI.
37
+ * This re-enables the bootstrap path (first token without auth).
38
+ * Returns true if the reset succeeded.
39
+ */
40
+ export function resetTokens() {
41
+ const dbPath = join(getMoxxyHome(), 'moxxy.db');
42
+ if (!existsSync(dbPath)) return false;
43
+ try {
44
+ execSync(`sqlite3 "${dbPath}" "DELETE FROM api_tokens;"`, { stdio: 'pipe' });
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function detectGatewayPlatform() {
52
+ const osMap = { darwin: 'darwin', linux: 'linux' };
53
+ const archMap = { arm64: 'arm64', x64: 'x86_64' };
54
+ const os = osMap[platform()] || platform();
55
+ const cpuArch = archMap[arch()] || arch();
56
+ const binaryName = `moxxy-gateway-${os}-${cpuArch}`;
57
+ return { os, arch: cpuArch, binaryName };
58
+ }
59
+
60
+ const GITHUB_REPO = process.env.MOXXY_GITHUB_REPO || 'moxxy-ai/moxxy';
61
+ const GITHUB_API = 'https://api.github.com';
62
+
63
+ async function fetchLatestReleaseAssetUrl(binaryName) {
64
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
65
+ const headers = { 'Accept': 'application/vnd.github+json', 'User-Agent': 'moxxy-cli' };
66
+ if (token) headers['Authorization'] = `Bearer ${token}`;
67
+
68
+ const url = `${GITHUB_API}/repos/${GITHUB_REPO}/releases/latest`;
69
+ const resp = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
70
+
71
+ if (resp.status === 403) {
72
+ const remaining = resp.headers.get('x-ratelimit-remaining');
73
+ if (remaining === '0') {
74
+ throw new Error('GitHub API rate limit exceeded. Set GITHUB_TOKEN env var to increase the limit.');
75
+ }
76
+ throw new Error(`GitHub API returned 403: ${resp.statusText}`);
77
+ }
78
+ if (resp.status === 404) {
79
+ throw new Error('No releases found.');
80
+ }
81
+ if (!resp.ok) {
82
+ throw new Error(`GitHub API error: ${resp.status} ${resp.statusText}`);
83
+ }
84
+
85
+ const release = await resp.json();
86
+ const asset = release.assets.find(a => a.name === binaryName);
87
+ if (!asset) {
88
+ const available = release.assets.map(a => a.name).join(', ');
89
+ throw new Error(`No binary for this platform (${binaryName}). Available: ${available}`);
90
+ }
91
+ return { url: asset.browser_download_url, version: release.tag_name };
92
+ }
93
+
94
+ async function installGatewayBinary(moxxyHome) {
95
+ const { binaryName } = detectGatewayPlatform();
96
+ const binDir = join(moxxyHome, 'bin');
97
+ const binName = platform() === 'win32' ? 'moxxy-gateway.exe' : 'moxxy-gateway';
98
+ const binPath = join(binDir, binName);
99
+
100
+ // MOXXY_GATEWAY_URL overrides GitHub releases (for local dev / custom builds)
101
+ const overrideUrl = process.env.MOXXY_GATEWAY_URL;
102
+
103
+ if (existsSync(binPath) && !overrideUrl) {
104
+ p.log.success(`Gateway binary already installed: ${binPath}`);
105
+ return true;
106
+ }
107
+
108
+ mkdirSync(binDir, { recursive: true });
109
+ const tmpPath = binPath + '.download';
110
+
111
+ try {
112
+ let downloadUrl;
113
+ let version;
114
+
115
+ const isLocalPath = overrideUrl && !overrideUrl.startsWith('http://') && !overrideUrl.startsWith('https://');
116
+
117
+ if (isLocalPath) {
118
+ const srcPath = resolve(overrideUrl);
119
+ if (!existsSync(srcPath)) {
120
+ throw new Error(`Local binary not found: ${srcPath}`);
121
+ }
122
+ p.log.info(`Copying local gateway binary: ${srcPath}`);
123
+ copyFileSync(srcPath, binPath);
124
+ chmodSync(binPath, 0o755);
125
+ p.log.success(`Gateway installed: ${binPath}`);
126
+ } else {
127
+ if (overrideUrl) {
128
+ downloadUrl = overrideUrl;
129
+ p.log.info(`Using custom gateway URL: ${overrideUrl}`);
130
+ } else {
131
+ const release = await withSpinner('Fetching latest release...', () =>
132
+ fetchLatestReleaseAssetUrl(binaryName), 'Release found.');
133
+ downloadUrl = release.url;
134
+ version = release.version;
135
+ }
136
+
137
+ const headers = { 'User-Agent': 'moxxy-cli', 'Accept': 'application/octet-stream' };
138
+ if (!overrideUrl) {
139
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
140
+ if (token) headers['Authorization'] = `Bearer ${token}`;
141
+ }
142
+
143
+ await withSpinner(`Downloading gateway${version ? ` ${version}` : ''} (${binaryName})...`, async () => {
144
+ const resp = await fetch(downloadUrl, { headers, signal: AbortSignal.timeout(120000) });
145
+ if (!resp.ok) {
146
+ throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
147
+ }
148
+ const fileStream = createWriteStream(tmpPath);
149
+ await pipeline(resp.body, fileStream);
150
+ }, 'Gateway downloaded.');
151
+
152
+ const { renameSync } = await import('node:fs');
153
+ renameSync(tmpPath, binPath);
154
+ chmodSync(binPath, 0o755);
155
+ p.log.success(`Gateway installed: ${binPath}`);
156
+ }
157
+ return true;
158
+ } catch (err) {
159
+ // Clean up partial download
160
+ try { const { unlinkSync } = await import('node:fs'); unlinkSync(tmpPath); } catch { /* ignore */ }
161
+ p.log.error(`Failed to download gateway: ${err.message}`);
162
+ return false;
163
+ }
164
+ }
165
+
166
+ export async function runInit(client, args) {
167
+ p.intro('Welcome to Moxxy');
168
+
169
+ // Step 0: Create ~/.moxxy directory structure
170
+ p.note(
171
+ 'The ~/.moxxy directory stores agent configs, secrets, and metadata.\n' +
172
+ 'It will be created automatically if it doesn\'t exist.',
173
+ 'Home Directory'
174
+ );
175
+ const moxxyHome = getMoxxyHome();
176
+ try {
177
+ mkdirSync(join(moxxyHome, 'agents'), { recursive: true });
178
+ mkdirSync(join(moxxyHome, 'config'), { recursive: true });
179
+ p.log.success(`Moxxy home: ${moxxyHome}`);
180
+ } catch (err) {
181
+ p.log.warn(`Could not create ${moxxyHome}: ${err.message}`);
182
+ }
183
+
184
+ // Step 1: Install gateway binary
185
+ p.note(
186
+ 'The gateway is the backend service that manages agents, routing,\n' +
187
+ 'and tool execution. It will be downloaded and installed automatically.',
188
+ 'Gateway Installation'
189
+ );
190
+ const gatewayInstalled = await installGatewayBinary(moxxyHome);
191
+
192
+ // Step 1.5: Check/configure API URL
193
+ p.note(
194
+ 'The gateway listens on a local port.\n' +
195
+ 'The default is http://localhost:3000.',
196
+ 'Gateway Connection'
197
+ );
198
+ const useDefault = await p.confirm({
199
+ message: `Use gateway at ${client.baseUrl}?`,
200
+ initialValue: true,
201
+ });
202
+ handleCancel(useDefault);
203
+
204
+ if (!useDefault) {
205
+ const apiUrl = await p.text({
206
+ message: 'Enter gateway URL',
207
+ placeholder: 'http://localhost:3000',
208
+ validate: (val) => {
209
+ try { new URL(val); } catch { return 'Must be a valid URL'; }
210
+ },
211
+ });
212
+ handleCancel(apiUrl);
213
+ client.baseUrl = apiUrl;
214
+ }
215
+
216
+ // Step 2: Start gateway and check connectivity
217
+ let gatewayReachable = false;
218
+ if (gatewayInstalled) {
219
+ const startIt = await p.confirm({
220
+ message: 'Start the gateway now?',
221
+ initialValue: true,
222
+ });
223
+ handleCancel(startIt);
224
+
225
+ if (startIt) {
226
+ try {
227
+ const { startGateway } = await import('./gateway.js');
228
+ await startGateway();
229
+ gatewayReachable = true;
230
+ } catch (err) {
231
+ p.log.warn(`Could not start gateway: ${err.message}`);
232
+ p.log.info('You can start it later with: moxxy gateway start');
233
+ }
234
+ }
235
+ }
236
+
237
+ if (!gatewayReachable) {
238
+ try {
239
+ await withSpinner('Checking gateway connection...', async () => {
240
+ const resp = await fetch(`${client.baseUrl}/v1/providers`);
241
+ if (resp) gatewayReachable = true;
242
+ }, 'Gateway is reachable.');
243
+ } catch {
244
+ p.log.warn('Gateway is not reachable. Start it with: moxxy gateway start');
245
+ p.log.info('You can continue setup and connect later.');
246
+ }
247
+ }
248
+
249
+ // Step 2.5: Auth mode selection
250
+ p.note(
251
+ 'Token mode requires an API token for every request (more secure).\n' +
252
+ 'Loopback mode skips auth for localhost requests (easier for local dev).',
253
+ 'Authentication'
254
+ );
255
+ const authMode = await p.select({
256
+ message: 'Authorization mode?',
257
+ options: [
258
+ { value: 'token', label: 'Token (default)', hint: 'API tokens required for all requests' },
259
+ { value: 'loopback', label: 'Loopback', hint: 'no auth needed from localhost' },
260
+ ],
261
+ });
262
+ handleCancel(authMode);
263
+
264
+ // Persist auth mode to config
265
+ const configPath = join(moxxyHome, 'config', 'gateway.yaml');
266
+ try {
267
+ writeFileSync(configPath, `auth_mode: ${authMode}\n`);
268
+ p.log.success(`Auth mode set to: ${authMode}`);
269
+ } catch (err) {
270
+ p.log.warn(`Could not write ${configPath}: ${err.message}`);
271
+ }
272
+
273
+ if (authMode === 'loopback') {
274
+ p.note(
275
+ 'The gateway will accept all requests from localhost without a token.\n' +
276
+ 'Non-localhost requests will still require authentication.',
277
+ 'Loopback mode'
278
+ );
279
+ }
280
+
281
+ // Step 3: Token bootstrap (skip if loopback mode)
282
+ if (authMode !== 'loopback') {
283
+ p.note(
284
+ 'API tokens authenticate CLI requests to the gateway.\n' +
285
+ 'A wildcard (*) token grants full access to all endpoints.',
286
+ 'API Token'
287
+ );
288
+ const createToken = await p.confirm({
289
+ message: 'Create an API token?',
290
+ initialValue: true,
291
+ });
292
+ handleCancel(createToken);
293
+
294
+ if (createToken) {
295
+ const scopes = ['*'];
296
+ const ttl = undefined;
297
+
298
+ const payload = { scopes };
299
+ if (ttl) payload.ttl_seconds = ttl;
300
+
301
+ // Try bootstrap (no auth) first, then with existing token
302
+ const savedToken = client.token;
303
+ let result;
304
+ let created = false;
305
+
306
+ // Attempt 1: bootstrap (no auth = works when DB has no tokens)
307
+ client.token = '';
308
+ try {
309
+ result = await withSpinner('Creating token...', () =>
310
+ client.request('/v1/auth/tokens', 'POST', payload), 'Token created.');
311
+ created = true;
312
+ } catch (err) {
313
+ if (err.status !== 401) {
314
+ p.log.error(`Failed to create token: ${err.message}`);
315
+ }
316
+ }
317
+
318
+ // Attempt 2: use existing MOXXY_TOKEN if bootstrap failed
319
+ if (!created && savedToken) {
320
+ client.token = savedToken;
321
+ try {
322
+ result = await withSpinner('Retrying with existing token...', () =>
323
+ client.request('/v1/auth/tokens', 'POST', payload), 'Token created.');
324
+ created = true;
325
+ } catch (err) {
326
+ if (err.status !== 401) {
327
+ p.log.error(`Failed to create token: ${err.message}`);
328
+ }
329
+ }
330
+ }
331
+
332
+ // Attempt 3: recovery menu = paste token or reset
333
+ if (!created) {
334
+ p.log.warn('Tokens already exist and your current token is missing or invalid.');
335
+ const recovery = await p.select({
336
+ message: 'How would you like to proceed?',
337
+ options: [
338
+ { value: 'reset', label: 'Reset tokens', hint: 'clear all existing tokens and create a new one' },
339
+ { value: 'paste', label: 'Paste a token', hint: 'use an existing valid token' },
340
+ { value: 'skip', label: 'Skip', hint: 'continue without a token' },
341
+ ],
342
+ });
343
+ handleCancel(recovery);
344
+
345
+ if (recovery === 'reset') {
346
+ const confirm = await p.confirm({
347
+ message: 'This will revoke ALL existing tokens. Continue?',
348
+ initialValue: false,
349
+ });
350
+ handleCancel(confirm);
351
+
352
+ if (confirm && resetTokens()) {
353
+ p.log.success('All tokens cleared.');
354
+ client.token = '';
355
+ try {
356
+ result = await withSpinner('Creating token...', () =>
357
+ client.request('/v1/auth/tokens', 'POST', payload), 'Token created.');
358
+ created = true;
359
+ } catch (err) {
360
+ p.log.error(`Failed to create token: ${err.message}`);
361
+ }
362
+ } else if (confirm) {
363
+ p.log.error('Could not reset tokens. Is sqlite3 installed?');
364
+ }
365
+ } else if (recovery === 'paste') {
366
+ const pastedToken = await p.text({
367
+ message: 'Paste a valid API token',
368
+ placeholder: 'mox_...',
369
+ });
370
+ handleCancel(pastedToken);
371
+ if (pastedToken) {
372
+ client.token = pastedToken;
373
+ try {
374
+ result = await withSpinner('Creating token...', () =>
375
+ client.request('/v1/auth/tokens', 'POST', payload), 'Token created.');
376
+ created = true;
377
+ } catch (err) {
378
+ p.log.error(`Failed to create token: ${err.message}`);
379
+ }
380
+ }
381
+ } else {
382
+ p.log.info('Skipped. Create a token later with: moxxy auth token create');
383
+ }
384
+ }
385
+
386
+ if (created) {
387
+ // Use the new token for the rest of the init flow
388
+ client.token = result.token;
389
+ showResult('Your API Token', {
390
+ ID: result.id,
391
+ Token: result.token,
392
+ Scopes: scopes.join(', '),
393
+ });
394
+
395
+ p.note(
396
+ `# Add to ${shellProfileName()}:\n${shellExportInstruction('MOXXY_TOKEN', result.token)}`,
397
+ 'Save your token'
398
+ );
399
+ p.log.warn('This token will not be shown again. Save it now.');
400
+ }
401
+ }
402
+ } // end authMode !== 'loopback'
403
+
404
+ // Step 4: Provider installation (optional)
405
+ let installedProviderId = null;
406
+
407
+ p.note(
408
+ 'Providers connect Moxxy to LLM services like Anthropic, OpenAI, or Google.\n' +
409
+ 'You need at least one provider installed before creating agents.',
410
+ 'Provider Setup'
411
+ );
412
+
413
+ const installProvider = await p.confirm({
414
+ message: 'Install an LLM provider?',
415
+ initialValue: true,
416
+ });
417
+ handleCancel(installProvider);
418
+
419
+ if (installProvider) {
420
+ if (authMode === 'token' && !client.token) {
421
+ p.log.warn('Provider install requires a token in token mode. Skipping.');
422
+ p.log.info('Create a token first, then run: moxxy provider install');
423
+ } else {
424
+ const providerChoice = await p.select({
425
+ message: 'Select a provider to install',
426
+ options: [
427
+ ...BUILTIN_PROVIDERS.map(bp => ({
428
+ value: bp.id,
429
+ label: bp.display_name,
430
+ hint: `${bp.models.length} models`,
431
+ })),
432
+ { value: '__skip__', label: 'Skip', hint: 'install a provider later' },
433
+ ],
434
+ });
435
+ handleCancel(providerChoice);
436
+
437
+ if (providerChoice !== '__skip__') {
438
+ const builtin = BUILTIN_PROVIDERS.find(bp => bp.id === providerChoice);
439
+
440
+ // Providers with dedicated login flows (OAuth / validated API key)
441
+ if (builtin.oauth_login || builtin.api_key_login) {
442
+ try {
443
+ const flags = {};
444
+ let result;
445
+ if (providerChoice === ANTHROPIC_PROVIDER_ID) {
446
+ result = await loginAnthropic(client, flags);
447
+ } else if (providerChoice === OPENAI_CODEX_PROVIDER_ID) {
448
+ result = await loginOpenAiCodex(client, flags);
449
+ }
450
+ if (result?.provider_id) {
451
+ installedProviderId = result.provider_id;
452
+ }
453
+ } catch (err) {
454
+ p.log.error(`Failed to install provider: ${err.message}`);
455
+ }
456
+ } else {
457
+ // Generic provider flow (no special login)
458
+ const availableModels = await resolveBuiltinProviderModels(builtin);
459
+ const CUSTOM_MODEL_VALUE = '__custom_model__';
460
+ const selectedModels = handleCancel(await p.multiselect({
461
+ message: 'Select models to install',
462
+ options: [
463
+ ...availableModels.map(m => ({
464
+ value: m.model_id,
465
+ label: m.display_name,
466
+ hint: m.model_id,
467
+ })),
468
+ { value: CUSTOM_MODEL_VALUE, label: 'Custom model ID', hint: 'enter a model ID manually' },
469
+ ],
470
+ required: true,
471
+ }));
472
+
473
+ const models = availableModels
474
+ .filter(m => selectedModels.includes(m.model_id))
475
+ .map(m => ({
476
+ ...m,
477
+ metadata: m.metadata || (builtin.api_base ? { api_base: builtin.api_base } : {}),
478
+ }));
479
+
480
+ // Handle custom model
481
+ if (selectedModels.includes(CUSTOM_MODEL_VALUE)) {
482
+ const customModelId = handleCancel(await p.text({
483
+ message: 'Custom model ID',
484
+ placeholder: 'e.g. ft:gpt-4o:my-org:custom-suffix',
485
+ validate: (v) => { if (!v.trim()) return 'Required'; },
486
+ }));
487
+
488
+ const customModelName = handleCancel(await p.text({
489
+ message: 'Display name for this model',
490
+ initialValue: customModelId,
491
+ }));
492
+
493
+ models.push({
494
+ model_id: customModelId,
495
+ display_name: customModelName || customModelId,
496
+ metadata: builtin.api_base ? { api_base: builtin.api_base, custom: true } : { custom: true },
497
+ });
498
+ }
499
+
500
+ // Verify credentials (binary check for CLI providers, API key for others)
501
+ const credOk = await checkProviderCredentials(builtin, client);
502
+ if (!credOk) return;
503
+
504
+ // Install provider
505
+ try {
506
+ await withSpinner(`Installing ${builtin.display_name}...`, () =>
507
+ client.installProvider(builtin.id, builtin.display_name, models),
508
+ `${builtin.display_name} installed.`
509
+ );
510
+
511
+ installedProviderId = builtin.id;
512
+
513
+ const resultInfo = {
514
+ ID: builtin.id,
515
+ Name: builtin.display_name,
516
+ Models: models.map(m => m.model_id).join(', '),
517
+ };
518
+ if (builtin.api_key_env) resultInfo['API Key Env'] = builtin.api_key_env;
519
+ if (builtin.cli_binary) resultInfo['CLI Binary'] = builtin.cli_binary;
520
+ showResult('Provider Installed', resultInfo);
521
+ } catch (err) {
522
+ p.log.error(`Failed to install provider: ${err.message}`);
523
+ }
524
+ }
525
+ }
526
+ }
527
+ }
528
+
529
+ // Step 5: Agent creation (optional)
530
+ p.note(
531
+ 'Agents are LLM-powered workers that use tools and skills to complete tasks.\n' +
532
+ 'Each agent is bound to a provider and model.',
533
+ 'Your First Agent'
534
+ );
535
+
536
+ const createAgent = await p.confirm({
537
+ message: 'Create your first agent?',
538
+ initialValue: true,
539
+ });
540
+ handleCancel(createAgent);
541
+
542
+ if (createAgent) {
543
+ if (authMode === 'token' && !client.token) {
544
+ p.log.warn('Agent creation requires a token in token mode. Skipping.');
545
+ p.log.info('Create a token first, then run: moxxy agent create');
546
+ } else {
547
+ // Check for available providers
548
+ let agentProviderId = installedProviderId;
549
+
550
+ if (!agentProviderId) {
551
+ try {
552
+ const providers = await withSpinner('Fetching providers...', () =>
553
+ client.listProviders(), 'Providers loaded.');
554
+ if (!providers || providers.length === 0) {
555
+ p.log.warn('No providers installed. Install one first with: moxxy provider install');
556
+ agentProviderId = null;
557
+ } else {
558
+ agentProviderId = handleCancel(await p.select({
559
+ message: 'Select a provider',
560
+ options: providers.map(pr => ({
561
+ value: pr.id,
562
+ label: pr.display_name || pr.id,
563
+ })),
564
+ }));
565
+ }
566
+ } catch (err) {
567
+ p.log.warn(`Could not list providers: ${err.message}`);
568
+ }
569
+ }
570
+
571
+ if (agentProviderId) {
572
+ // Agent name
573
+ const agentName = handleCancel(await p.text({
574
+ message: 'Agent name',
575
+ placeholder: 'my-agent',
576
+ validate: (v) => {
577
+ if (!v || v.trim().length === 0) return 'Required';
578
+ if (v.length > 64) return 'Max 64 characters';
579
+ if (!/^[a-z][a-z0-9-]*$/.test(v)) return 'Lowercase alphanumeric and hyphens, must start with a letter';
580
+ },
581
+ }));
582
+
583
+ // Model selection (live from API)
584
+ let agentModelId;
585
+ try {
586
+ const models = await withSpinner('Fetching models...', () =>
587
+ client.listModels(agentProviderId), 'Models loaded.');
588
+
589
+ const CUSTOM_MODEL_VALUE = '__custom_model__';
590
+ const modelOptions = [
591
+ ...(models || []).map(m => ({
592
+ value: m.model_id,
593
+ label: m.display_name || m.model_id,
594
+ hint: m.model_id,
595
+ })),
596
+ { value: CUSTOM_MODEL_VALUE, label: 'Custom model ID', hint: 'enter a model ID manually' },
597
+ ];
598
+
599
+ const modelChoice = handleCancel(await p.select({
600
+ message: 'Select a model',
601
+ options: modelOptions,
602
+ }));
603
+
604
+ if (modelChoice === CUSTOM_MODEL_VALUE) {
605
+ agentModelId = handleCancel(await p.text({
606
+ message: 'Custom model ID',
607
+ placeholder: 'e.g. claude-sonnet-4-20250514',
608
+ validate: (v) => { if (!v.trim()) return 'Required'; },
609
+ }));
610
+ } else {
611
+ agentModelId = modelChoice;
612
+ }
613
+ } catch (err) {
614
+ p.log.warn(`Could not fetch models: ${err.message}`);
615
+ agentModelId = handleCancel(await p.text({
616
+ message: 'Model ID',
617
+ placeholder: 'e.g. claude-sonnet-4-20250514',
618
+ validate: (v) => { if (!v.trim()) return 'Required'; },
619
+ }));
620
+ }
621
+
622
+ // Persona (optional)
623
+ const persona = handleCancel(await p.text({
624
+ message: 'Persona (optional)',
625
+ placeholder: 'e.g. A helpful coding assistant',
626
+ }));
627
+
628
+ // Create the agent
629
+ try {
630
+ const opts = {};
631
+ if (persona && persona.trim()) opts.persona = persona.trim();
632
+
633
+ const agentResult = await withSpinner('Creating agent...', () =>
634
+ client.createAgent(agentProviderId, agentModelId, agentName, opts),
635
+ 'Agent created.'
636
+ );
637
+
638
+ showResult('Agent Created', {
639
+ Name: agentResult.name,
640
+ Provider: agentResult.provider_id,
641
+ Model: agentResult.model_id,
642
+ Status: agentResult.status,
643
+ });
644
+ } catch (err) {
645
+ p.log.error(`Failed to create agent: ${err.message}`);
646
+ }
647
+ }
648
+ }
649
+ }
650
+
651
+ // Step 6: Channel setup (optional)
652
+ p.note(
653
+ 'Channels enable agent communication via Telegram or Discord.\n' +
654
+ 'You can set up channels later with: moxxy channel create',
655
+ 'Channels'
656
+ );
657
+ const setupChannel = await p.confirm({
658
+ message: 'Set up a messaging channel (Telegram/Discord)?',
659
+ initialValue: false,
660
+ });
661
+ handleCancel(setupChannel);
662
+
663
+ if (setupChannel) {
664
+ const channelType = await p.select({
665
+ message: 'Channel type',
666
+ options: [
667
+ { value: 'telegram', label: 'Telegram', hint: 'BotFather bot token required' },
668
+ { value: 'discord', label: 'Discord', hint: 'coming soon (scaffold)' },
669
+ ],
670
+ });
671
+ handleCancel(channelType);
672
+
673
+ if (channelType === 'telegram') {
674
+ p.note(
675
+ '1. Open Telegram and talk to @BotFather\n' +
676
+ '2. Send /newbot and follow the prompts\n' +
677
+ '3. Copy the bot token',
678
+ 'Telegram Bot Setup'
679
+ );
680
+
681
+ const botToken = await p.password({
682
+ message: 'Paste your Telegram bot token',
683
+ });
684
+ handleCancel(botToken);
685
+
686
+ const displayName = await p.text({
687
+ message: 'Display name for this channel',
688
+ placeholder: 'My Moxxy Bot',
689
+ });
690
+ handleCancel(displayName);
691
+
692
+ try {
693
+ const result = await withSpinner('Registering Telegram channel...', () =>
694
+ client.request('/v1/channels', 'POST', {
695
+ channel_type: 'telegram',
696
+ display_name: displayName || 'Telegram Bot',
697
+ bot_token: botToken,
698
+ }), 'Channel registered.');
699
+
700
+ showResult('Telegram Channel', { ID: result.id, Status: result.status });
701
+
702
+ // Interactive pairing
703
+ p.note(
704
+ '1. Open your Telegram bot and send /start\n' +
705
+ '2. You will receive a 6-digit pairing code',
706
+ 'Pair your chat'
707
+ );
708
+
709
+ const pairCode = await p.text({
710
+ message: 'Enter the 6-digit pairing code',
711
+ placeholder: '123456',
712
+ validate: (v) => {
713
+ if (!v || v.trim().length === 0) return 'Code is required';
714
+ },
715
+ });
716
+ handleCancel(pairCode);
717
+
718
+ // Pick an agent to bind
719
+ let agentId;
720
+ try {
721
+ const agents = await withSpinner('Fetching agents...', () =>
722
+ client.listAgents(), 'Agents loaded.');
723
+ if (!agents || agents.length === 0) {
724
+ p.log.warn('No agents found. Create one first with: moxxy agent create');
725
+ p.log.info(`Pair later with: moxxy channel pair --code ${pairCode} --agent <agent-id>`);
726
+ } else {
727
+ agentId = await p.select({
728
+ message: 'Select agent to bind',
729
+ options: agents.map(a => ({
730
+ value: a.name,
731
+ label: `${a.name} (${a.provider_id}/${a.model_id})`,
732
+ })),
733
+ });
734
+ handleCancel(agentId);
735
+ }
736
+ } catch (err) {
737
+ p.log.warn(`Could not list agents: ${err.message}`);
738
+ p.log.info(`Pair later with: moxxy channel pair --code ${pairCode} --agent <agent-id>`);
739
+ }
740
+
741
+ if (agentId) {
742
+ try {
743
+ const pairResult = await withSpinner('Pairing...', () =>
744
+ client.request(`/v1/channels/${result.id}/pair`, 'POST', {
745
+ code: pairCode,
746
+ agent_id: agentId,
747
+ }), 'Paired successfully.');
748
+ showResult('Channel Paired', {
749
+ 'Binding ID': pairResult.id,
750
+ Agent: pairResult.agent_id,
751
+ 'External Chat': pairResult.external_chat_id,
752
+ });
753
+ } catch (err) {
754
+ p.log.error(`Failed to pair: ${err.message}`);
755
+ p.log.info(`Try again with: moxxy channel pair --code ${pairCode} --agent ${agentId}`);
756
+ }
757
+ }
758
+ } catch (err) {
759
+ p.log.error(`Failed to register channel: ${err.message}`);
760
+ }
761
+ } else {
762
+ p.log.info('Discord channel support is coming soon.');
763
+ }
764
+ }
765
+
766
+ // Step 7: Browser rendering (optional)
767
+ p.note(
768
+ 'Browser rendering enables agents to load JavaScript-heavy websites\n' +
769
+ 'using a headless Chrome browser. This requires Chrome/Chromium.\n' +
770
+ 'Without it, agents can still fetch pages via HTTP (works for most sites).',
771
+ 'Browser Rendering'
772
+ );
773
+
774
+ const enableBrowser = await p.confirm({
775
+ message: 'Enable browser rendering capabilities?',
776
+ initialValue: false,
777
+ });
778
+ handleCancel(enableBrowser);
779
+
780
+ if (enableBrowser) {
781
+ const chromePath = detectChromeBinary(moxxyHome);
782
+
783
+ if (chromePath) {
784
+ p.log.success(`Chrome found: ${chromePath}`);
785
+ saveBrowserRenderingSetting(moxxyHome, true);
786
+ p.log.success('Browser rendering enabled.');
787
+ } else {
788
+ p.log.warn('Chrome/Chromium not found on this system.');
789
+
790
+ const downloadChrome = await p.confirm({
791
+ message: 'Download Chromium (~150MB) to ~/.moxxy/chromium/?',
792
+ initialValue: true,
793
+ });
794
+ handleCancel(downloadChrome);
795
+
796
+ if (downloadChrome) {
797
+ const installed = await installChromium(moxxyHome);
798
+ if (installed) {
799
+ saveBrowserRenderingSetting(moxxyHome, true);
800
+ p.log.success('Browser rendering enabled.');
801
+ } else {
802
+ p.log.warn('Chromium install failed. You can retry later with: moxxy settings browser-rendering');
803
+ }
804
+ } else {
805
+ p.log.info('Skipped. Install Chrome manually or run: moxxy settings browser-rendering');
806
+ }
807
+ }
808
+ }
809
+
810
+ p.outro('Setup complete. Run moxxy to see available commands.');
811
+ }
812
+
813
+ // ---------------------------------------------------------------------------
814
+ // Browser rendering helpers
815
+ // ---------------------------------------------------------------------------
816
+
817
+ function detectChromeBinary(moxxyHome) {
818
+ const os = platform();
819
+
820
+ // 1. CHROME_PATH env var
821
+ if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) {
822
+ return process.env.CHROME_PATH;
823
+ }
824
+
825
+ // 2. System Chrome
826
+ const systemPaths = os === 'darwin'
827
+ ? [
828
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
829
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
830
+ ]
831
+ : [
832
+ '/usr/bin/google-chrome',
833
+ '/usr/bin/google-chrome-stable',
834
+ '/usr/bin/chromium',
835
+ '/usr/bin/chromium-browser',
836
+ ];
837
+
838
+ for (const p of systemPaths) {
839
+ if (existsSync(p)) return p;
840
+ }
841
+
842
+ // 3. which fallback (Linux)
843
+ if (os === 'linux') {
844
+ for (const name of ['google-chrome', 'chromium-browser', 'chromium']) {
845
+ try {
846
+ const result = execSync(`which ${name}`, { stdio: 'pipe', encoding: 'utf-8' }).trim();
847
+ if (result && existsSync(result)) return result;
848
+ } catch { /* not found */ }
849
+ }
850
+ }
851
+
852
+ // 4. Previously downloaded
853
+ const platDir = chromePlatformDir();
854
+ const binaryName = os === 'darwin'
855
+ ? 'Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
856
+ : 'chrome';
857
+ const downloaded = join(moxxyHome, 'chromium', platDir, binaryName);
858
+ if (existsSync(downloaded)) return downloaded;
859
+
860
+ return null;
861
+ }
862
+
863
+ function chromePlatformDir() {
864
+ const os = platform();
865
+ const cpuArch = arch();
866
+ if (os === 'darwin') {
867
+ return cpuArch === 'arm64' ? 'chrome-mac-arm64' : 'chrome-mac-x64';
868
+ }
869
+ return 'chrome-linux64';
870
+ }
871
+
872
+ async function installChromium(moxxyHome) {
873
+ const CHROME_FOR_TESTING_API = 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json';
874
+
875
+ try {
876
+ // Fetch latest stable version info
877
+ const versionInfo = await withSpinner('Fetching Chromium version info...', async () => {
878
+ const resp = await fetch(CHROME_FOR_TESTING_API, { signal: AbortSignal.timeout(15000) });
879
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
880
+ return resp.json();
881
+ }, 'Version info retrieved.');
882
+
883
+ const platKey = chromePlatformKey();
884
+ const stable = versionInfo.channels.Stable;
885
+ const chromeDownloads = stable.downloads.chrome;
886
+ const entry = chromeDownloads.find(d => d.platform === platKey);
887
+
888
+ if (!entry) {
889
+ p.log.error(`No Chromium build for platform: ${platKey}`);
890
+ return false;
891
+ }
892
+
893
+ const downloadUrl = entry.url;
894
+ const targetDir = join(moxxyHome, 'chromium');
895
+ mkdirSync(targetDir, { recursive: true });
896
+
897
+ const zipPath = join(targetDir, 'chrome.zip');
898
+
899
+ // Download
900
+ await withSpinner(`Downloading Chromium ${stable.version}...`, async () => {
901
+ const resp = await fetch(downloadUrl, { signal: AbortSignal.timeout(300000) });
902
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
903
+ const fileStream = createWriteStream(zipPath);
904
+ await pipeline(resp.body, fileStream);
905
+ }, 'Download complete.');
906
+
907
+ // Extract
908
+ await withSpinner('Extracting Chromium...', async () => {
909
+ execSync(`unzip -o -q "${zipPath}" -d "${targetDir}"`, { stdio: 'pipe' });
910
+ }, 'Extraction complete.');
911
+
912
+ // Cleanup zip
913
+ try { const { unlinkSync } = await import('node:fs'); unlinkSync(zipPath); } catch { /* ignore */ }
914
+
915
+ const chromePath = detectChromeBinary(moxxyHome);
916
+ if (chromePath) {
917
+ // Make executable on Linux
918
+ if (platform() === 'linux') {
919
+ try { chmodSync(chromePath, 0o755); } catch { /* ignore */ }
920
+ }
921
+ p.log.success(`Chromium installed: ${chromePath}`);
922
+ return true;
923
+ }
924
+
925
+ p.log.error('Extraction succeeded but Chrome binary not found');
926
+ return false;
927
+ } catch (err) {
928
+ p.log.error(`Failed to install Chromium: ${err.message}`);
929
+ return false;
930
+ }
931
+ }
932
+
933
+ function chromePlatformKey() {
934
+ const os = platform();
935
+ const cpuArch = arch();
936
+ if (os === 'darwin') {
937
+ return cpuArch === 'arm64' ? 'mac-arm64' : 'mac-x64';
938
+ }
939
+ return 'linux64';
940
+ }
941
+
942
+ function saveBrowserRenderingSetting(moxxyHome, enabled) {
943
+ const settingsFile = join(moxxyHome, 'settings.yaml');
944
+ let lines = [];
945
+ try {
946
+ const raw = readFileSync(settingsFile, 'utf-8');
947
+ lines = raw.split('\n').filter(l => !l.startsWith('browser_rendering:'));
948
+ } catch { /* no existing settings */ }
949
+
950
+ lines.push(`browser_rendering: ${enabled}`);
951
+
952
+ mkdirSync(moxxyHome, { recursive: true });
953
+ writeFileSync(settingsFile, lines.filter(l => l.trim()).join('\n') + '\n');
954
+ }