@lardexx/arenacode 0.0.0-stage → 2.5.2
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 +288 -2
- package/bin/arenacode.js +19 -0
- package/docs/ANALYSIS.md +201 -0
- package/docs/PUBLISHING.md +65 -0
- package/docs/TUI.md +114 -0
- package/docs/tui-approve.html +28 -0
- package/docs/tui-approve.png +0 -0
- package/docs/tui-home.html +28 -0
- package/docs/tui-home.png +0 -0
- package/docs/tui-palette.html +28 -0
- package/docs/tui-palette.png +0 -0
- package/docs/tui-plan.html +28 -0
- package/docs/tui-plan.png +0 -0
- package/docs/tui-preview.html +28 -0
- package/docs/tui-session.png +0 -0
- package/docs/tui-slash.html +28 -0
- package/package.json +32 -4
- package/src/bridge.js +135 -0
- package/src/cli.js +720 -0
- package/src/config.js +141 -0
- package/src/local.js +507 -0
- package/src/meta.js +143 -0
- package/src/prompts/agent-prompt.md +237 -0
- package/src/tui/app.js +1368 -0
- package/src/tui/dialogs.js +136 -0
- package/src/tui/keys.js +134 -0
- package/src/tui/logo.js +54 -0
- package/src/tui/render.js +256 -0
- package/src/tui/term.js +93 -0
- package/src/tui/theme.js +246 -0
- package/src/tui/widgets.js +374 -0
- package/src/turn.js +213 -0
- package/src/ui.js +130 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
VERSION, CLIENT_NAME, configPath, readConfig, writeConfig, patchConfig, deviceId,
|
|
9
|
+
resolveUrl, resolveKey, openFolder,
|
|
10
|
+
} = require('./config');
|
|
11
|
+
const { Bridge } = require('./bridge');
|
|
12
|
+
const { buildMeta, readProjectContext } = require('./meta');
|
|
13
|
+
const { followTask } = require('./turn');
|
|
14
|
+
const { c, write, ask, banner, truncate, indentLines } = require('./ui');
|
|
15
|
+
|
|
16
|
+
const AGENT_PROMPT_FILE = path.join(__dirname, 'prompts', 'agent-prompt.md');
|
|
17
|
+
|
|
18
|
+
const MODE_LINES = {
|
|
19
|
+
agent:
|
|
20
|
+
'[ArenaCode mode: agent]\n' +
|
|
21
|
+
'AGENT MODE (not plan mode). Carry the task out directly: do not make a plan, do not send ui plan, ' +
|
|
22
|
+
'do not write a plan. This line overrides any plan instruction from earlier tasks or from the chat, ' +
|
|
23
|
+
'even if you remember one. Work, verify each change, and reply.',
|
|
24
|
+
plan:
|
|
25
|
+
'[ArenaCode mode: plan]\n' +
|
|
26
|
+
'PLAN MODE. Send the plan first with `ui plan`: the steps, the files involved, the risks and what you ' +
|
|
27
|
+
'will not do. Then stop and wait for the user to approve it before acting.',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// --------------------------------------------------------------------------- //
|
|
31
|
+
// argument parsing
|
|
32
|
+
// --------------------------------------------------------------------------- //
|
|
33
|
+
|
|
34
|
+
const BOOLEAN_FLAGS = new Set(['yes', 'json', 'help', 'quiet', 'version', 'confirm-writes', 'no-pc', 'piped']);
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const flags = {};
|
|
38
|
+
const positional = [];
|
|
39
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
40
|
+
const arg = argv[i];
|
|
41
|
+
if (arg === '--') {
|
|
42
|
+
positional.push(...argv.slice(i + 1));
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
if (arg.startsWith('--')) {
|
|
46
|
+
const [rawName, inlineValue] = arg.slice(2).split('=');
|
|
47
|
+
const name = rawName.replace(/_/g, '-');
|
|
48
|
+
if (inlineValue !== undefined) {
|
|
49
|
+
flags[name] = inlineValue;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
53
|
+
flags[name] = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const next = argv[i + 1];
|
|
57
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
58
|
+
flags[name] = next;
|
|
59
|
+
i += 1;
|
|
60
|
+
} else {
|
|
61
|
+
flags[name] = true;
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (arg.startsWith('-') && arg.length === 2) {
|
|
66
|
+
const map = { y: 'yes', h: 'help', j: 'json' };
|
|
67
|
+
flags[map[arg.slice(1)] || arg.slice(1)] = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
positional.push(arg);
|
|
71
|
+
}
|
|
72
|
+
return { flags, positional };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --------------------------------------------------------------------------- //
|
|
76
|
+
// context helpers
|
|
77
|
+
// --------------------------------------------------------------------------- //
|
|
78
|
+
|
|
79
|
+
function context(flags) {
|
|
80
|
+
const cfg = readConfig();
|
|
81
|
+
const url = resolveUrl(cfg, flags.url);
|
|
82
|
+
const key = resolveKey(cfg, flags.key);
|
|
83
|
+
const folder = openFolder(cfg, flags.folder);
|
|
84
|
+
const device = deviceId(cfg);
|
|
85
|
+
return {
|
|
86
|
+
cfg,
|
|
87
|
+
url,
|
|
88
|
+
key,
|
|
89
|
+
folder,
|
|
90
|
+
device,
|
|
91
|
+
lang: flags.lang || cfg.lang || 'it',
|
|
92
|
+
mode: flags.mode === 'plan' ? 'plan' : flags.mode === 'agent' ? 'agent' : cfg.mode || 'agent',
|
|
93
|
+
pcAccess: flags['no-pc'] ? false : cfg.pc_access !== false,
|
|
94
|
+
yes: Boolean(flags.yes),
|
|
95
|
+
json: Boolean(flags.json),
|
|
96
|
+
confirmWrites: Boolean(flags['confirm-writes']),
|
|
97
|
+
idleTimeout: Number(flags['idle-timeout'] || cfg.idle_timeout || 180),
|
|
98
|
+
bridge: new Bridge(url, key, device),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function requireUrl(ctx) {
|
|
103
|
+
if (ctx.url) return true;
|
|
104
|
+
write('');
|
|
105
|
+
write(c.red(' no bridge URL configured.'));
|
|
106
|
+
write(` ${c.gray('run:')} arenacode init ${c.gray('or')} arenacode config set url https://your-bridge.tunnl.gg`);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function ensureAuth(ctx) {
|
|
111
|
+
const res = await ctx.bridge.authCheck();
|
|
112
|
+
if (res.status === 200) return true;
|
|
113
|
+
const reason = (res.json && (res.json.error || res.json.hint)) || `HTTP ${res.status}`;
|
|
114
|
+
write(c.red(` auth failed: ${reason}`));
|
|
115
|
+
if (res.status === 403) write(c.gray(' this API key is bound to another PC: on the bridge run keys unbind <ID>'));
|
|
116
|
+
if (res.status === 401) write(c.gray(' check the API key: arenacode config set key <API key>'));
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --------------------------------------------------------------------------- //
|
|
121
|
+
// commands
|
|
122
|
+
// --------------------------------------------------------------------------- //
|
|
123
|
+
|
|
124
|
+
async function cmdInit(flags) {
|
|
125
|
+
banner(VERSION);
|
|
126
|
+
const cfg = readConfig();
|
|
127
|
+
write(c.gray(` config file: ${configPath()}`));
|
|
128
|
+
write('');
|
|
129
|
+
const url = await ask(` Bridge URL ${cfg.url ? c.gray(`[${cfg.url}]`) : ''}: `, { default: cfg.url });
|
|
130
|
+
const key = await ask(` API key ${cfg.key ? c.gray('[configured]') : ''}: `, { default: cfg.key });
|
|
131
|
+
const folder = await ask(` Open folder ${c.gray(`[${cfg.folder || process.cwd()}]`)}: `, { default: cfg.folder || process.cwd() });
|
|
132
|
+
|
|
133
|
+
const next = { ...cfg, url: String(url).replace(/\/+$/, ''), key: String(key), folder: path.resolve(String(folder) || process.cwd()) };
|
|
134
|
+
const file = writeConfig(next);
|
|
135
|
+
write(` ${c.green('✓')} saved ${c.gray(file)}`);
|
|
136
|
+
|
|
137
|
+
const bridge = new Bridge(next.url, next.key, deviceId(next));
|
|
138
|
+
const health = await bridge.health();
|
|
139
|
+
if (health.status !== 200) {
|
|
140
|
+
write(c.red(` ✗ bridge unreachable: ${(health.json && health.json.error) || health.status}`));
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
write(` ${c.green('✓')} bridge ${c.bold('v' + (health.json.version || '?'))} ${c.gray(health.json.public_url || '')}`);
|
|
144
|
+
const auth = await bridge.authCheck();
|
|
145
|
+
if (auth.status === 200) write(` ${c.green('✓')} API key accepted`);
|
|
146
|
+
else write(c.red(` ✗ API key rejected: ${(auth.json && auth.json.error) || auth.status}`));
|
|
147
|
+
write('');
|
|
148
|
+
write(` ${c.gray('try:')} arenacode ask "list the files in my folder"`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function cmdConfig(positional, flags) {
|
|
153
|
+
const [action = 'show', key, ...rest] = positional;
|
|
154
|
+
const cfg = readConfig();
|
|
155
|
+
if (action === 'path') {
|
|
156
|
+
write(configPath());
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
if (action === 'show') {
|
|
160
|
+
const shown = { ...cfg, key: cfg.key ? `${cfg.key.slice(0, 8)}…` : '' };
|
|
161
|
+
write(JSON.stringify(shown, null, 2));
|
|
162
|
+
write(c.gray(`# ${configPath()}`));
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
if (action === 'set') {
|
|
166
|
+
const name = key;
|
|
167
|
+
const val = rest.join(' ');
|
|
168
|
+
if (!name || val === '') {
|
|
169
|
+
write('usage: arenacode config set <url|key|folder|lang|mode|pc_access> <value>');
|
|
170
|
+
return 2;
|
|
171
|
+
}
|
|
172
|
+
const patch = {};
|
|
173
|
+
if (name === 'pc_access') patch.pc_access = !['false', '0', 'no', 'off'].includes(String(val).toLowerCase());
|
|
174
|
+
else if (name === 'url') patch.url = String(val).replace(/\/+$/, '');
|
|
175
|
+
else patch[name] = val;
|
|
176
|
+
const file = patchConfig(patch);
|
|
177
|
+
write(` ${c.green('✓')} ${name} saved ${c.gray(file)}`);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
if (action === 'clear') {
|
|
181
|
+
try {
|
|
182
|
+
fs.rmSync(configPath());
|
|
183
|
+
} catch {
|
|
184
|
+
/* ignore */
|
|
185
|
+
}
|
|
186
|
+
write(` ${c.green('✓')} config removed`);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
write('usage: arenacode config [show|path|set <key> <value>|clear]');
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function cmdPing(flags) {
|
|
194
|
+
const ctx = context(flags);
|
|
195
|
+
if (!requireUrl(ctx)) return 1;
|
|
196
|
+
const health = await ctx.bridge.health();
|
|
197
|
+
if (health.status !== 200) {
|
|
198
|
+
write(c.red(` ✗ bridge unreachable: ${(health.json && health.json.error) || health.status}`));
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
const h = health.json;
|
|
202
|
+
if (ctx.json) {
|
|
203
|
+
write(JSON.stringify(h, null, 2));
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
write('');
|
|
207
|
+
write(` ${c.green('●')} bridge ${c.bold('v' + h.version)} ${c.gray(`· executor=${h.executor_mode} · terminal=${h.terminal ? 'on' : 'off'}`)}`);
|
|
208
|
+
write(` ${c.gray('url')} ${h.public_url || c.gray('(no tunnel)')}`);
|
|
209
|
+
write(` ${c.gray('tasks')} ${h.tasks.claimable} claimable · ${h.tasks.pending} pending · ${h.tasks.done} done`);
|
|
210
|
+
const auth = await ctx.bridge.authCheck();
|
|
211
|
+
write(` ${auth.status === 200 ? c.green('✓') : c.red('✗')} auth ${auth.status === 200 ? 'ok' : (auth.json && auth.json.error) || auth.status}`);
|
|
212
|
+
return auth.status === 200 ? 0 : 1;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function cmdDoctor(flags) {
|
|
216
|
+
const ctx = context(flags);
|
|
217
|
+
write('');
|
|
218
|
+
write(` ${c.bold('arenacode doctor')}`);
|
|
219
|
+
const line = (ok, label, detail = '') => write(` ${ok ? c.green('✓') : c.red('✗')} ${label.padEnd(18)} ${c.gray(detail)}`);
|
|
220
|
+
line(Number(process.versions.node.split('.')[0]) >= 18, 'node', process.version);
|
|
221
|
+
line(true, 'platform', `${process.platform} ${os.release()} ${os.arch()}`);
|
|
222
|
+
line(Boolean(ctx.url), 'bridge url', ctx.url || 'not configured');
|
|
223
|
+
line(true, 'config', configPath());
|
|
224
|
+
line(/^[A-Za-z0-9-]{8,128}$/.test(deviceId()), 'device id', deviceId().slice(0, 12) + '…');
|
|
225
|
+
line(fs.existsSync(ctx.folder), 'open folder', ctx.folder);
|
|
226
|
+
line(Boolean(require('./meta').which('git')), 'git', require('./meta').which('git') ? 'available' : 'missing');
|
|
227
|
+
if (ctx.url) {
|
|
228
|
+
const health = await ctx.bridge.health();
|
|
229
|
+
line(health.status === 200, 'bridge', health.status === 200 ? `v${health.json.version} ${health.json.public_url || ''}` : (health.json && health.json.error) || 'unreachable');
|
|
230
|
+
if (health.status === 200) {
|
|
231
|
+
const auth = await ctx.bridge.authCheck();
|
|
232
|
+
line(auth.status === 200, 'api key', auth.status === 200 ? 'accepted' : (auth.json && auth.json.error) || String(auth.status));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
write('');
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function agentPrompt() {
|
|
240
|
+
try {
|
|
241
|
+
return fs.readFileSync(AGENT_PROMPT_FILE, 'utf8');
|
|
242
|
+
} catch {
|
|
243
|
+
return '';
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** POST /task exactly like the desktop client does. */
|
|
248
|
+
async function createTask(ctx, instruction, { extraInstructions = '' } = {}) {
|
|
249
|
+
const meta = buildMeta(ctx.folder, { lang: ctx.lang, pcAccess: ctx.pcAccess, activeFile: '' });
|
|
250
|
+
const projectContext = readProjectContext(ctx.folder);
|
|
251
|
+
const body = {
|
|
252
|
+
instruction,
|
|
253
|
+
wait: 0,
|
|
254
|
+
meta,
|
|
255
|
+
agent_prompt: agentPrompt(),
|
|
256
|
+
};
|
|
257
|
+
if (projectContext) body.project_context = projectContext;
|
|
258
|
+
if (extraInstructions) body.instruction = `${instruction}\n\n${extraInstructions}`;
|
|
259
|
+
|
|
260
|
+
const res = await ctx.bridge.createTask(body);
|
|
261
|
+
if (res.status !== 202 && res.status !== 200) {
|
|
262
|
+
const reason = (res.json && (res.json.error || res.json.hint)) || `HTTP ${res.status}`;
|
|
263
|
+
throw new Error(reason);
|
|
264
|
+
}
|
|
265
|
+
return res.json.task_id;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function runInstruction(ctx, text, { attachments = [] } = {}) {
|
|
269
|
+
let instruction = `${MODE_LINES[ctx.mode] || MODE_LINES.agent}\n${text}`;
|
|
270
|
+
|
|
271
|
+
if (attachments.length) {
|
|
272
|
+
const uploaded = [];
|
|
273
|
+
for (const file of attachments) {
|
|
274
|
+
const abs = path.resolve(file);
|
|
275
|
+
if (!fs.existsSync(abs)) {
|
|
276
|
+
write(c.yellow(` ! missing attachment: ${file}`));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const name = `attachments/${Date.now()}-${path.basename(abs)}`;
|
|
280
|
+
const res = await ctx.bridge.upload(name, fs.readFileSync(abs));
|
|
281
|
+
if (res.status !== 200) {
|
|
282
|
+
write(c.yellow(` ! upload failed: ${file}`));
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
uploaded.push(res.json.path);
|
|
286
|
+
write(` ${c.green('↑')} ${c.gray(`${path.basename(abs)} → ${res.json.path} (${res.json.size} B)`)}`);
|
|
287
|
+
}
|
|
288
|
+
if (uploaded.length) instruction += `\n\n[attachments in the workspace: ${uploaded.join(', ')}]`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let taskId = null;
|
|
292
|
+
const onSigint = async () => {
|
|
293
|
+
if (!taskId) process.exit(130);
|
|
294
|
+
write('');
|
|
295
|
+
write(c.yellow(' ■ stop requested…'));
|
|
296
|
+
await ctx.bridge.cancel(taskId);
|
|
297
|
+
process.exit(130);
|
|
298
|
+
};
|
|
299
|
+
process.once('SIGINT', onSigint);
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
taskId = await createTask(ctx, instruction);
|
|
303
|
+
write(c.gray(` task ${taskId}`));
|
|
304
|
+
const result = await followTask(ctx.bridge, taskId, {
|
|
305
|
+
folder: ctx.folder,
|
|
306
|
+
lang: ctx.lang,
|
|
307
|
+
yes: ctx.yes,
|
|
308
|
+
confirmWrites: ctx.confirmWrites,
|
|
309
|
+
idleTimeout: ctx.idleTimeout,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (!result.streamed && result.output) {
|
|
313
|
+
write('');
|
|
314
|
+
write(indentLines(c.white(result.output.trim()), ' │ '));
|
|
315
|
+
}
|
|
316
|
+
if (result.error) write(` ${c.red('✗')} ${c.red(result.error)}`);
|
|
317
|
+
return result.state === 'error' ? 1 : 0;
|
|
318
|
+
} finally {
|
|
319
|
+
process.removeListener('SIGINT', onSigint);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function cmdAsk(positional, flags) {
|
|
324
|
+
const ctx = context(flags);
|
|
325
|
+
if (!requireUrl(ctx)) return 1;
|
|
326
|
+
const text = positional.join(' ').trim();
|
|
327
|
+
if (!text) {
|
|
328
|
+
write('usage: arenacode ask "your instruction"');
|
|
329
|
+
return 2;
|
|
330
|
+
}
|
|
331
|
+
if (!(await ensureAuth(ctx))) return 1;
|
|
332
|
+
const attachments = flags.attach ? String(flags.attach).split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
333
|
+
write('');
|
|
334
|
+
write(` ${c.bold('›')} ${c.white(truncate(text, 100))} ${c.gray(`· ${ctx.mode} · ${ctx.folder}`)}`);
|
|
335
|
+
return runInstruction(ctx, text, { attachments });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Full-screen TUI (opencode-style): timeline, input box, dialogs, local cards. */
|
|
339
|
+
async function cmdUi(flags) {
|
|
340
|
+
const ctx = context(flags);
|
|
341
|
+
if (!requireUrl(ctx)) return 1;
|
|
342
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
343
|
+
write(c.red(' the TUI needs an interactive terminal.'));
|
|
344
|
+
write(c.gray(' inside a script or a pipe use: arenacode ask "…" or arenacode chat'));
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
const { App } = require('./tui/app');
|
|
348
|
+
const app = new App({
|
|
349
|
+
url: ctx.url,
|
|
350
|
+
key: ctx.key,
|
|
351
|
+
folder: ctx.folder,
|
|
352
|
+
lang: ctx.lang,
|
|
353
|
+
mode: ctx.mode,
|
|
354
|
+
yes: ctx.yes,
|
|
355
|
+
confirmWrites: ctx.confirmWrites,
|
|
356
|
+
});
|
|
357
|
+
await app.start();
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function cmdChat(flags) {
|
|
362
|
+
const ctx = context(flags);
|
|
363
|
+
if (!requireUrl(ctx)) return 1;
|
|
364
|
+
if (!(await ensureAuth(ctx))) return 1;
|
|
365
|
+
banner(VERSION);
|
|
366
|
+
write(` ${c.gray('bridge')} ${ctx.url} ${c.gray('folder')} ${ctx.folder}`);
|
|
367
|
+
write(` ${c.gray('type a request · /help for commands · Ctrl+D to quit')}`);
|
|
368
|
+
write('');
|
|
369
|
+
|
|
370
|
+
const readline = require('node:readline');
|
|
371
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY });
|
|
372
|
+
const prompt = () => {
|
|
373
|
+
rl.setPrompt(c.cyan(' › '));
|
|
374
|
+
rl.prompt();
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
return new Promise((resolve) => {
|
|
378
|
+
prompt();
|
|
379
|
+
rl.on('line', async (line) => {
|
|
380
|
+
const input = line.trim();
|
|
381
|
+
if (!input) {
|
|
382
|
+
prompt();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
rl.pause();
|
|
386
|
+
try {
|
|
387
|
+
if (input === '/quit' || input === '/exit') {
|
|
388
|
+
rl.close();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (input === '/help') {
|
|
392
|
+
write(` ${c.gray('/files [path]')} list the bridge workspace ${c.gray('/cat <path>')} preview a file`);
|
|
393
|
+
write(` ${c.gray('/mode agent|plan')} switch mode ${c.gray('/lang it|en|es')} interface language`);
|
|
394
|
+
write(` ${c.gray('/folder <dir>')} change the open folder ${c.gray('/ping')} bridge status`);
|
|
395
|
+
write(` ${c.gray('/tasks')} queue ${c.gray('/stop <id>')} cancel a turn ${c.gray('/quit')}`);
|
|
396
|
+
write('');
|
|
397
|
+
} else if (input === '/ping') {
|
|
398
|
+
await cmdPing({ ...flags });
|
|
399
|
+
} else if (input === '/tasks') {
|
|
400
|
+
await cmdTasks([], flags);
|
|
401
|
+
} else if (input.startsWith('/files')) {
|
|
402
|
+
await cmdFiles(input.split(/\s+/).slice(1), flags);
|
|
403
|
+
} else if (input.startsWith('/cat ')) {
|
|
404
|
+
await cmdCat([input.slice(5).trim()], flags);
|
|
405
|
+
} else if (input.startsWith('/mode ')) {
|
|
406
|
+
const mode = input.slice(6).trim() === 'plan' ? 'plan' : 'agent';
|
|
407
|
+
patchConfig({ mode });
|
|
408
|
+
ctx.mode = mode;
|
|
409
|
+
write(` ${c.green('✓')} mode ${mode}`);
|
|
410
|
+
} else if (input.startsWith('/lang ')) {
|
|
411
|
+
const lang = input.slice(6).trim().slice(0, 2);
|
|
412
|
+
patchConfig({ lang });
|
|
413
|
+
ctx.lang = lang;
|
|
414
|
+
write(` ${c.green('✓')} lang ${lang}`);
|
|
415
|
+
} else if (input.startsWith('/folder ')) {
|
|
416
|
+
const folder = path.resolve(input.slice(8).trim().replace(/^"|"$/g, ''));
|
|
417
|
+
patchConfig({ folder });
|
|
418
|
+
ctx.folder = folder;
|
|
419
|
+
write(` ${c.green('✓')} folder ${folder}`);
|
|
420
|
+
} else if (input.startsWith('/stop')) {
|
|
421
|
+
const id = input.split(/\s+/)[1];
|
|
422
|
+
if (id) await cmdStop([id], flags);
|
|
423
|
+
else write(c.gray(' usage: /stop <task-id>'));
|
|
424
|
+
} else {
|
|
425
|
+
await runInstruction(ctx, input);
|
|
426
|
+
}
|
|
427
|
+
} catch (e) {
|
|
428
|
+
write(c.red(` ✗ ${e.message}`));
|
|
429
|
+
}
|
|
430
|
+
write('');
|
|
431
|
+
rl.resume();
|
|
432
|
+
prompt();
|
|
433
|
+
});
|
|
434
|
+
rl.on('close', () => {
|
|
435
|
+
write('');
|
|
436
|
+
resolve(0);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function cmdTasks(positional, flags) {
|
|
442
|
+
const ctx = context(flags);
|
|
443
|
+
if (!requireUrl(ctx)) return 1;
|
|
444
|
+
const state = positional[0] || flags.state || 'all';
|
|
445
|
+
const res = await ctx.bridge.listTasks(state);
|
|
446
|
+
if (res.status !== 200) {
|
|
447
|
+
write(c.red(` ✗ ${(res.json && res.json.error) || res.status}`));
|
|
448
|
+
return 1;
|
|
449
|
+
}
|
|
450
|
+
if (ctx.json) {
|
|
451
|
+
write(JSON.stringify(res.json, null, 2));
|
|
452
|
+
return 0;
|
|
453
|
+
}
|
|
454
|
+
const tasks = res.json.tasks || [];
|
|
455
|
+
if (!tasks.length) {
|
|
456
|
+
write(c.gray(' (no tasks)'));
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
for (const task of tasks) {
|
|
460
|
+
const when = new Date(task.created_at * 1000).toLocaleString();
|
|
461
|
+
const stateTone = task.state === 'done' ? 'green' : task.state === 'error' ? 'red' : task.state === 'cancelled' ? 'yellow' : 'cyan';
|
|
462
|
+
write(` ${c[stateTone](task.state.padEnd(9))} ${c.gray(when)} ${c.bold(task.id)} ${c.gray(truncate(String(task.instruction).split('\n').slice(-1)[0], 60))}`);
|
|
463
|
+
}
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function cmdStatus(positional, flags) {
|
|
468
|
+
const ctx = context(flags);
|
|
469
|
+
if (!requireUrl(ctx)) return 1;
|
|
470
|
+
const id = positional[0];
|
|
471
|
+
if (!id) {
|
|
472
|
+
write('usage: arenacode status <task-id>');
|
|
473
|
+
return 2;
|
|
474
|
+
}
|
|
475
|
+
const res = await ctx.bridge.getTask(id);
|
|
476
|
+
if (res.status !== 200) {
|
|
477
|
+
write(c.red(` ✗ ${(res.json && res.json.error) || res.status}`));
|
|
478
|
+
return 1;
|
|
479
|
+
}
|
|
480
|
+
if (ctx.json) {
|
|
481
|
+
write(JSON.stringify(res.json, null, 2));
|
|
482
|
+
return 0;
|
|
483
|
+
}
|
|
484
|
+
const t = res.json;
|
|
485
|
+
write('');
|
|
486
|
+
write(` ${c.bold(t.id)} ${c.cyan(t.state)} ${c.gray(new Date(t.created_at * 1000).toLocaleString())}`);
|
|
487
|
+
if (t.claimant) write(` ${c.gray('claimant')} ${t.claimant}`);
|
|
488
|
+
if (t.error) write(` ${c.red('error')} ${t.error}`);
|
|
489
|
+
if (t.output) {
|
|
490
|
+
write('');
|
|
491
|
+
write(indentLines(String(t.output).trim(), ' │ '));
|
|
492
|
+
}
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function cmdEvents(positional, flags) {
|
|
497
|
+
const ctx = context(flags);
|
|
498
|
+
if (!requireUrl(ctx)) return 1;
|
|
499
|
+
const id = positional[0];
|
|
500
|
+
if (!id) {
|
|
501
|
+
write('usage: arenacode events <task-id>');
|
|
502
|
+
return 2;
|
|
503
|
+
}
|
|
504
|
+
const res = await ctx.bridge.getTask(id);
|
|
505
|
+
if (res.status !== 200) {
|
|
506
|
+
write(c.red(` ✗ ${(res.json && res.json.error) || res.status}`));
|
|
507
|
+
return 1;
|
|
508
|
+
}
|
|
509
|
+
const { Timeline } = require('./turn');
|
|
510
|
+
const timeline = new Timeline({ folder: ctx.folder, lang: ctx.lang });
|
|
511
|
+
for (const ev of res.json.events || []) timeline.render(ev);
|
|
512
|
+
timeline.newlineIfText();
|
|
513
|
+
write(c.gray(` state: ${res.json.state}`));
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function cmdStop(positional, flags) {
|
|
518
|
+
const ctx = context(flags);
|
|
519
|
+
if (!requireUrl(ctx)) return 1;
|
|
520
|
+
let id = positional[0];
|
|
521
|
+
if (!id) {
|
|
522
|
+
const res = await ctx.bridge.listTasks('all');
|
|
523
|
+
const live = (res.json.tasks || []).filter((t) => !['done', 'error', 'cancelled'].includes(t.state)).pop();
|
|
524
|
+
id = live && live.id;
|
|
525
|
+
}
|
|
526
|
+
if (!id) {
|
|
527
|
+
write(c.gray(' nothing to stop'));
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
const res = await ctx.bridge.cancel(id);
|
|
531
|
+
if (res.status !== 200) {
|
|
532
|
+
write(c.red(` ✗ ${(res.json && res.json.error) || res.status}`));
|
|
533
|
+
return 1;
|
|
534
|
+
}
|
|
535
|
+
write(` ${c.yellow('■')} stopped ${id}`);
|
|
536
|
+
return 0;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function cmdFiles(positional, flags) {
|
|
540
|
+
const ctx = context(flags);
|
|
541
|
+
if (!requireUrl(ctx)) return 1;
|
|
542
|
+
const rel = positional[0] || '';
|
|
543
|
+
const res = await ctx.bridge.fs(rel);
|
|
544
|
+
if (res.status !== 200) {
|
|
545
|
+
write(c.red(` ✗ ${(res.json && res.json.error) || res.status}`));
|
|
546
|
+
return 1;
|
|
547
|
+
}
|
|
548
|
+
if (ctx.json) {
|
|
549
|
+
write(JSON.stringify(res.json, null, 2));
|
|
550
|
+
return 0;
|
|
551
|
+
}
|
|
552
|
+
write(c.gray(` ${res.json.root}/${res.json.path}`));
|
|
553
|
+
for (const entry of res.json.entries) {
|
|
554
|
+
const size = entry.type === 'dir' ? '<DIR>' : String(entry.size);
|
|
555
|
+
write(` ${c.gray(size.padStart(9))} ${entry.type === 'dir' ? c.cyan(entry.name + '/') : entry.name}`);
|
|
556
|
+
}
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function cmdCat(positional, flags) {
|
|
561
|
+
const ctx = context(flags);
|
|
562
|
+
if (!requireUrl(ctx)) return 1;
|
|
563
|
+
const rel = positional[0];
|
|
564
|
+
if (!rel) {
|
|
565
|
+
write('usage: arenacode cat <path>');
|
|
566
|
+
return 2;
|
|
567
|
+
}
|
|
568
|
+
const res = await ctx.bridge.file(rel);
|
|
569
|
+
if (res.status !== 200) {
|
|
570
|
+
write(c.red(` ✗ ${(res.json && res.json.error) || res.status}`));
|
|
571
|
+
return 1;
|
|
572
|
+
}
|
|
573
|
+
if (res.json.binary) {
|
|
574
|
+
write(c.gray(` binary file (${res.json.size} B)`));
|
|
575
|
+
return 0;
|
|
576
|
+
}
|
|
577
|
+
write(res.json.text);
|
|
578
|
+
return 0;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function cmdAttach(positional, flags) {
|
|
582
|
+
const ctx = context(flags);
|
|
583
|
+
if (!requireUrl(ctx)) return 1;
|
|
584
|
+
if (!positional.length) {
|
|
585
|
+
write('usage: arenacode attach <file> [more files]');
|
|
586
|
+
return 2;
|
|
587
|
+
}
|
|
588
|
+
for (const file of positional) {
|
|
589
|
+
const abs = path.resolve(file);
|
|
590
|
+
if (!fs.existsSync(abs)) {
|
|
591
|
+
write(c.yellow(` ! missing: ${file}`));
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const name = `attachments/${Date.now()}-${path.basename(abs)}`;
|
|
595
|
+
const res = await ctx.bridge.upload(name, fs.readFileSync(abs));
|
|
596
|
+
if (res.status !== 200) {
|
|
597
|
+
write(c.red(` ✗ ${(res.json && res.json.error) || res.status}`));
|
|
598
|
+
return 1;
|
|
599
|
+
}
|
|
600
|
+
write(` ${c.green('↑')} ${res.json.path} ${c.gray(`(${res.json.size} B)`)}`);
|
|
601
|
+
}
|
|
602
|
+
return 0;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Handshake task the desktop client sends to check the bridge and vision. */
|
|
606
|
+
async function cmdHandshake(flags) {
|
|
607
|
+
const ctx = context(flags);
|
|
608
|
+
if (!requireUrl(ctx)) return 1;
|
|
609
|
+
if (!(await ensureAuth(ctx))) return 1;
|
|
610
|
+
const instruction =
|
|
611
|
+
'[ArenaCode handshake] Vision check. The bridge creates the image, not you: do not write, copy or edit any PNG ' +
|
|
612
|
+
'or other image. Run `python3 agentctl.py vision-png <task id>`, open the PNG it prints and look at it, then run ' +
|
|
613
|
+
'`python3 agentctl.py --has-vision-enabled:true <task id>` if you can see and describe it, or ' +
|
|
614
|
+
'`--has-vision-enabled:false` if you cannot. Reply with one short sentence describing what you see, then go back ' +
|
|
615
|
+
'to listening.';
|
|
616
|
+
write('');
|
|
617
|
+
write(` ${c.bold('handshake')} ${c.gray('· vision check')}`);
|
|
618
|
+
return runInstruction(ctx, instruction);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function help() {
|
|
622
|
+
banner(VERSION);
|
|
623
|
+
write(` ${c.bold('usage')} arenacode <command> [options]`);
|
|
624
|
+
write('');
|
|
625
|
+
write(` ${c.cyan('setup')}`);
|
|
626
|
+
write(` init configure URL + API key, test the connection`);
|
|
627
|
+
write(` config [show|set|path] read or change the saved configuration`);
|
|
628
|
+
write(` ping bridge status and API key check`);
|
|
629
|
+
write(` doctor local checks (node, git, folder, bridge)`);
|
|
630
|
+
write('');
|
|
631
|
+
write(` ${c.cyan('chat')}`);
|
|
632
|
+
write(` ask "instruction" run one task and stream the timeline`);
|
|
633
|
+
write(` ui full-screen TUI (default on a terminal)`);
|
|
634
|
+
write(` chat plain line-by-line session`);
|
|
635
|
+
write(` stop [task-id] cancel the running turn (Stop)`);
|
|
636
|
+
write(` handshake vision/handshake check task`);
|
|
637
|
+
write('');
|
|
638
|
+
write(` ${c.cyan('inspect')}`);
|
|
639
|
+
write(` tasks [state] task queue on the bridge`);
|
|
640
|
+
write(` status <task-id> state and result of a task`);
|
|
641
|
+
write(` events <task-id> timeline of a task`);
|
|
642
|
+
write(` files [path] bridge workspace listing`);
|
|
643
|
+
write(` cat <path> preview a file in the bridge workspace`);
|
|
644
|
+
write(` attach <file…> upload files to the bridge workspace`);
|
|
645
|
+
write('');
|
|
646
|
+
write(` ${c.cyan('options')}`);
|
|
647
|
+
write(` --folder <dir> open folder (the project the agent works on)`);
|
|
648
|
+
write(` --url <url> --key <key> override the saved bridge settings`);
|
|
649
|
+
write(` --mode agent|plan task mode (default: ${c.gray('agent')})`);
|
|
650
|
+
write(` --lang it|en|es language for instructions and cards`);
|
|
651
|
+
write(` --no-pc tell the agent the PC terminal is off`);
|
|
652
|
+
write(` --yes run local commands without asking`);
|
|
653
|
+
write(` --confirm-writes ask before write/replace/rm/mv on your folder`);
|
|
654
|
+
write(` --attach a,b upload files with the task`);
|
|
655
|
+
write(` --json raw JSON output where available`);
|
|
656
|
+
write('');
|
|
657
|
+
write(c.gray(' docs: https://github.com/ · bridge: arena-bridge 2.5.2'));
|
|
658
|
+
write('');
|
|
659
|
+
return 0;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// --------------------------------------------------------------------------- //
|
|
663
|
+
|
|
664
|
+
async function main(argv) {
|
|
665
|
+
const { flags, positional } = parseArgs(argv);
|
|
666
|
+
const [command = 'chat', ...rest] = positional;
|
|
667
|
+
|
|
668
|
+
if (flags.version || command === 'version') {
|
|
669
|
+
write(VERSION);
|
|
670
|
+
return 0;
|
|
671
|
+
}
|
|
672
|
+
if (flags.help || command === 'help') return help();
|
|
673
|
+
|
|
674
|
+
switch (command) {
|
|
675
|
+
case 'init':
|
|
676
|
+
return cmdInit(flags);
|
|
677
|
+
case 'config':
|
|
678
|
+
return cmdConfig(rest, flags);
|
|
679
|
+
case 'ping':
|
|
680
|
+
return cmdPing(flags);
|
|
681
|
+
case 'doctor':
|
|
682
|
+
return cmdDoctor(flags);
|
|
683
|
+
case 'ask':
|
|
684
|
+
return cmdAsk(rest, flags);
|
|
685
|
+
case 'ui':
|
|
686
|
+
case 'tui':
|
|
687
|
+
return cmdUi(flags);
|
|
688
|
+
case 'chat':
|
|
689
|
+
return cmdChat(flags);
|
|
690
|
+
case 'tasks':
|
|
691
|
+
return cmdTasks(rest, flags);
|
|
692
|
+
case 'status':
|
|
693
|
+
return cmdStatus(rest, flags);
|
|
694
|
+
case 'events':
|
|
695
|
+
return cmdEvents(rest, flags);
|
|
696
|
+
case 'stop':
|
|
697
|
+
return cmdStop(rest, flags);
|
|
698
|
+
case 'files':
|
|
699
|
+
return cmdFiles(rest, flags);
|
|
700
|
+
case 'cat':
|
|
701
|
+
return cmdCat(rest, flags);
|
|
702
|
+
case 'attach':
|
|
703
|
+
return cmdAttach(rest, flags);
|
|
704
|
+
case 'handshake':
|
|
705
|
+
return cmdHandshake(flags);
|
|
706
|
+
default:
|
|
707
|
+
if (command.startsWith('-')) {
|
|
708
|
+
// `arenacode "instruction"` convenience form
|
|
709
|
+
return cmdAsk(positional, flags);
|
|
710
|
+
}
|
|
711
|
+
if ((command === 'chat' || command === undefined) && process.stdout.isTTY && process.stdin.isTTY) {
|
|
712
|
+
return cmdUi(flags);
|
|
713
|
+
}
|
|
714
|
+
write(c.red(` unknown command: ${command}`));
|
|
715
|
+
help();
|
|
716
|
+
return 2;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
module.exports = { main, parseArgs, MODE_LINES, createTask };
|