opencode-agora 0.2.2 → 0.4.0

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 (175) hide show
  1. package/README.md +217 -52
  2. package/dist/cli/app.d.ts +3 -0
  3. package/dist/cli/app.d.ts.map +1 -1
  4. package/dist/cli/app.js +1202 -227
  5. package/dist/cli/app.js.map +1 -1
  6. package/dist/cli/chat-renderer.d.ts +31 -0
  7. package/dist/cli/chat-renderer.d.ts.map +1 -0
  8. package/dist/cli/chat-renderer.js +275 -0
  9. package/dist/cli/chat-renderer.js.map +1 -0
  10. package/dist/cli/commands-meta.d.ts +21 -0
  11. package/dist/cli/commands-meta.d.ts.map +1 -0
  12. package/dist/cli/commands-meta.js +600 -0
  13. package/dist/cli/commands-meta.js.map +1 -0
  14. package/dist/cli/completions.d.ts +18 -0
  15. package/dist/cli/completions.d.ts.map +1 -0
  16. package/dist/cli/completions.js +190 -0
  17. package/dist/cli/completions.js.map +1 -0
  18. package/dist/cli/mcp-server.d.ts +4 -0
  19. package/dist/cli/mcp-server.d.ts.map +1 -0
  20. package/dist/cli/mcp-server.js +277 -0
  21. package/dist/cli/mcp-server.js.map +1 -0
  22. package/dist/cli/menu.d.ts +7 -0
  23. package/dist/cli/menu.d.ts.map +1 -0
  24. package/dist/cli/menu.js +164 -0
  25. package/dist/cli/menu.js.map +1 -0
  26. package/dist/cli/pages/community.d.ts +3 -0
  27. package/dist/cli/pages/community.d.ts.map +1 -0
  28. package/dist/cli/pages/community.js +276 -0
  29. package/dist/cli/pages/community.js.map +1 -0
  30. package/dist/cli/pages/helpers.d.ts +32 -0
  31. package/dist/cli/pages/helpers.d.ts.map +1 -0
  32. package/dist/cli/pages/helpers.js +67 -0
  33. package/dist/cli/pages/helpers.js.map +1 -0
  34. package/dist/cli/pages/home.d.ts +3 -0
  35. package/dist/cli/pages/home.d.ts.map +1 -0
  36. package/dist/cli/pages/home.js +148 -0
  37. package/dist/cli/pages/home.js.map +1 -0
  38. package/dist/cli/pages/marketplace.d.ts +3 -0
  39. package/dist/cli/pages/marketplace.d.ts.map +1 -0
  40. package/dist/cli/pages/marketplace.js +179 -0
  41. package/dist/cli/pages/marketplace.js.map +1 -0
  42. package/dist/cli/pages/news.d.ts +3 -0
  43. package/dist/cli/pages/news.d.ts.map +1 -0
  44. package/dist/cli/pages/news.js +561 -0
  45. package/dist/cli/pages/news.js.map +1 -0
  46. package/dist/cli/pages/settings.d.ts +3 -0
  47. package/dist/cli/pages/settings.d.ts.map +1 -0
  48. package/dist/cli/pages/settings.js +166 -0
  49. package/dist/cli/pages/settings.js.map +1 -0
  50. package/dist/cli/pages/types.d.ts +67 -0
  51. package/dist/cli/pages/types.d.ts.map +1 -0
  52. package/dist/cli/pages/types.js +2 -0
  53. package/dist/cli/pages/types.js.map +1 -0
  54. package/dist/cli/prompter.d.ts +135 -0
  55. package/dist/cli/prompter.d.ts.map +1 -0
  56. package/dist/cli/prompter.js +675 -0
  57. package/dist/cli/prompter.js.map +1 -0
  58. package/dist/cli/shell.d.ts +23 -0
  59. package/dist/cli/shell.d.ts.map +1 -0
  60. package/dist/cli/shell.js +819 -0
  61. package/dist/cli/shell.js.map +1 -0
  62. package/dist/cli/tui.d.ts +7 -0
  63. package/dist/cli/tui.d.ts.map +1 -0
  64. package/dist/cli/tui.js +373 -0
  65. package/dist/cli/tui.js.map +1 -0
  66. package/dist/cli.js +1 -1
  67. package/dist/cli.js.map +1 -1
  68. package/dist/commands.d.ts +14 -0
  69. package/dist/commands.d.ts.map +1 -0
  70. package/dist/commands.js +28 -0
  71. package/dist/commands.js.map +1 -0
  72. package/dist/community/client.d.ts +47 -0
  73. package/dist/community/client.d.ts.map +1 -0
  74. package/dist/community/client.js +245 -0
  75. package/dist/community/client.js.map +1 -0
  76. package/dist/community/types.d.ts +50 -0
  77. package/dist/community/types.d.ts.map +1 -0
  78. package/dist/community/types.js +11 -0
  79. package/dist/community/types.js.map +1 -0
  80. package/dist/config-files.d.ts.map +1 -1
  81. package/dist/config-files.js +11 -8
  82. package/dist/config-files.js.map +1 -1
  83. package/dist/config.d.ts +8 -8
  84. package/dist/config.d.ts.map +1 -1
  85. package/dist/config.js +16 -27
  86. package/dist/config.js.map +1 -1
  87. package/dist/data.d.ts +1 -1
  88. package/dist/data.d.ts.map +1 -1
  89. package/dist/data.js +1013 -545
  90. package/dist/data.js.map +1 -1
  91. package/dist/format.d.ts +5 -39
  92. package/dist/format.d.ts.map +1 -1
  93. package/dist/format.js +5 -118
  94. package/dist/format.js.map +1 -1
  95. package/dist/history.d.ts +13 -0
  96. package/dist/history.d.ts.map +1 -0
  97. package/dist/history.js +37 -0
  98. package/dist/history.js.map +1 -0
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +151 -236
  101. package/dist/index.js.map +1 -1
  102. package/dist/init.d.ts +4 -1
  103. package/dist/init.d.ts.map +1 -1
  104. package/dist/init.js +103 -51
  105. package/dist/init.js.map +1 -1
  106. package/dist/live.d.ts +4 -0
  107. package/dist/live.d.ts.map +1 -1
  108. package/dist/live.js +87 -19
  109. package/dist/live.js.map +1 -1
  110. package/dist/marketplace.d.ts +9 -0
  111. package/dist/marketplace.d.ts.map +1 -1
  112. package/dist/marketplace.js +128 -33
  113. package/dist/marketplace.js.map +1 -1
  114. package/dist/news/cache.d.ts +13 -0
  115. package/dist/news/cache.d.ts.map +1 -0
  116. package/dist/news/cache.js +65 -0
  117. package/dist/news/cache.js.map +1 -0
  118. package/dist/news/score.d.ts +4 -0
  119. package/dist/news/score.d.ts.map +1 -0
  120. package/dist/news/score.js +43 -0
  121. package/dist/news/score.js.map +1 -0
  122. package/dist/news/sources/arxiv.d.ts +9 -0
  123. package/dist/news/sources/arxiv.d.ts.map +1 -0
  124. package/dist/news/sources/arxiv.js +103 -0
  125. package/dist/news/sources/arxiv.js.map +1 -0
  126. package/dist/news/sources/github-trending.d.ts +9 -0
  127. package/dist/news/sources/github-trending.d.ts.map +1 -0
  128. package/dist/news/sources/github-trending.js +93 -0
  129. package/dist/news/sources/github-trending.js.map +1 -0
  130. package/dist/news/sources/hn.d.ts +9 -0
  131. package/dist/news/sources/hn.d.ts.map +1 -0
  132. package/dist/news/sources/hn.js +53 -0
  133. package/dist/news/sources/hn.js.map +1 -0
  134. package/dist/news/sources/reddit.d.ts +9 -0
  135. package/dist/news/sources/reddit.d.ts.map +1 -0
  136. package/dist/news/sources/reddit.js +68 -0
  137. package/dist/news/sources/reddit.js.map +1 -0
  138. package/dist/news/sources/rss.d.ts +14 -0
  139. package/dist/news/sources/rss.d.ts.map +1 -0
  140. package/dist/news/sources/rss.js +102 -0
  141. package/dist/news/sources/rss.js.map +1 -0
  142. package/dist/news/types.d.ts +39 -0
  143. package/dist/news/types.d.ts.map +1 -0
  144. package/dist/news/types.js +47 -0
  145. package/dist/news/types.js.map +1 -0
  146. package/dist/preferences.d.ts +14 -0
  147. package/dist/preferences.d.ts.map +1 -0
  148. package/dist/preferences.js +31 -0
  149. package/dist/preferences.js.map +1 -0
  150. package/dist/settings.d.ts +26 -0
  151. package/dist/settings.d.ts.map +1 -0
  152. package/dist/settings.js +257 -0
  153. package/dist/settings.js.map +1 -0
  154. package/dist/state.d.ts.map +1 -1
  155. package/dist/state.js +7 -6
  156. package/dist/state.js.map +1 -1
  157. package/dist/transcript.d.ts +28 -0
  158. package/dist/transcript.d.ts.map +1 -0
  159. package/dist/transcript.js +79 -0
  160. package/dist/transcript.js.map +1 -0
  161. package/dist/types.d.ts +6 -1
  162. package/dist/types.d.ts.map +1 -1
  163. package/dist/ui.d.ts +157 -0
  164. package/dist/ui.d.ts.map +1 -0
  165. package/dist/ui.js +296 -0
  166. package/dist/ui.js.map +1 -0
  167. package/package.json +21 -10
  168. package/dist/api.d.ts +0 -69
  169. package/dist/api.d.ts.map +0 -1
  170. package/dist/api.js +0 -109
  171. package/dist/api.js.map +0 -1
  172. package/dist/logger.d.ts +0 -20
  173. package/dist/logger.d.ts.map +0 -1
  174. package/dist/logger.js +0 -59
  175. package/dist/logger.js.map +0 -1
@@ -0,0 +1,819 @@
1
+ import { execSync, spawn } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, readdirSync, statSync, writeFileSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { join, resolve, sep } from 'node:path';
5
+ import { COMMANDS } from './commands-meta.js';
6
+ import { runInteractiveMenu } from './menu.js';
7
+ import { runTui } from './tui.js';
8
+ import { AGORA_VERSION, FREE_MODELS } from './app.js';
9
+ import { detectAgoraDataDir, loadAgoraState, resolveSavedItems } from '../state.js';
10
+ import { appendTranscript, loadSessionMeta, readTranscript, recentBashContext, writeSessionMeta } from '../transcript.js';
11
+ import { gradientText, renderBanner, supportsTrueColor } from '../ui.js';
12
+ import { createChatRenderer } from './chat-renderer.js';
13
+ import { readLine } from './prompter.js';
14
+ import { completeShellLine, ghostFromHistory } from './completions.js';
15
+ import { getMarketplaceItems } from '../marketplace.js';
16
+ const SHELL_BUILTINS = new Set(['cd', 'export', 'alias', 'source', 'unset', 'umask', 'exec']);
17
+ const MAX_BASH_BUFFER = 16 * 1024;
18
+ /** Map slash aliases (with leading `/`) to TUI page ids. */
19
+ const TUI_SLASH_ALIASES = {
20
+ '/tui': 'default',
21
+ '/home': 'home',
22
+ '/market': 'marketplace',
23
+ '/marketplace': 'marketplace',
24
+ '/comm': 'community',
25
+ '/community': 'community',
26
+ '/news': 'news',
27
+ '/settings': 'settings'
28
+ };
29
+ const QUESTION_STARTERS = new Set([
30
+ 'what',
31
+ 'why',
32
+ 'how',
33
+ 'which',
34
+ 'when',
35
+ 'where',
36
+ 'who',
37
+ 'whose',
38
+ 'should',
39
+ 'shall',
40
+ 'can',
41
+ 'could',
42
+ 'would',
43
+ 'will',
44
+ 'do',
45
+ 'does',
46
+ 'did',
47
+ 'is',
48
+ 'are',
49
+ 'am',
50
+ 'was',
51
+ 'were',
52
+ 'tell',
53
+ 'explain',
54
+ 'describe',
55
+ 'help',
56
+ 'hi',
57
+ 'hello',
58
+ 'hey',
59
+ 'thanks'
60
+ ]);
61
+ export function looksLikeQuestion(line) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed)
64
+ return false;
65
+ if (trimmed.endsWith('?'))
66
+ return true;
67
+ const words = trimmed.split(/\s+/);
68
+ const firstWord = words[0].toLowerCase();
69
+ if (QUESTION_STARTERS.has(firstWord))
70
+ return true;
71
+ if (trimmed[0] === trimmed[0].toUpperCase() &&
72
+ trimmed[0] !== trimmed[0].toLowerCase() &&
73
+ words.length >= 3) {
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ export function classifyInput(line, isExecutable) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed)
81
+ return { kind: 'noop' };
82
+ if (trimmed === '/help')
83
+ return { kind: 'meta', sub: 'help' };
84
+ if (trimmed === '/quit')
85
+ return { kind: 'meta', sub: 'quit' };
86
+ if (trimmed === '/exit')
87
+ return { kind: 'meta', sub: 'exit' };
88
+ if (trimmed === '/clear')
89
+ return { kind: 'meta', sub: 'clear' };
90
+ if (trimmed === '/transcript')
91
+ return { kind: 'meta', sub: 'transcript' };
92
+ if (trimmed === '/menu')
93
+ return { kind: 'meta', sub: 'menu' };
94
+ if (trimmed === '/terminal')
95
+ return { kind: 'meta', sub: 'terminal' };
96
+ if (trimmed === '/verbose')
97
+ return { kind: 'meta', sub: 'verbose' };
98
+ if (trimmed === '/quiet')
99
+ return { kind: 'meta', sub: 'quiet' };
100
+ if (trimmed === '/medium')
101
+ return { kind: 'meta', sub: 'medium' };
102
+ if (trimmed === '/last')
103
+ return { kind: 'meta', sub: 'last' };
104
+ if (trimmed === '/again')
105
+ return { kind: 'meta', sub: 'again' };
106
+ if (trimmed.startsWith('/? '))
107
+ return { kind: 'meta', sub: 'dry-run', args: trimmed.slice(3).trim() };
108
+ if (trimmed.startsWith('!'))
109
+ return { kind: 'bash', cmd: trimmed.slice(1).trim() };
110
+ if (trimmed.startsWith('?'))
111
+ return { kind: 'chat', msg: trimmed.slice(1).trim() };
112
+ // TUI shortcuts: `/tui` opens Home, `/home` `/market` `/comm` `/news`
113
+ // `/settings` open the TUI on that page. Recognised only as exact bare
114
+ // commands so `/tui foo` falls through to the generic CLI forwarding below.
115
+ if (TUI_SLASH_ALIASES[trimmed] !== undefined) {
116
+ const target = TUI_SLASH_ALIASES[trimmed];
117
+ return target === 'default' ? { kind: 'tui' } : { kind: 'tui', page: target };
118
+ }
119
+ // Slash-prefixed inputs that weren't an exact meta or TUI match are
120
+ // forwarded to the `agora` CLI: `/agora help`, `/agora search foo`,
121
+ // `/help tutorials`, `/foo` all become `agora <args>`. Never let
122
+ // `/anything` fall through to bash — PATH-joining an absolute name like
123
+ // `/agora` historically matched the real binary on disk and bash then
124
+ // tried to exec `/agora` literally.
125
+ if (trimmed.startsWith('/')) {
126
+ let rest = trimmed.slice(1).trim();
127
+ if (rest === 'agora' || rest.startsWith('agora ')) {
128
+ rest = rest === 'agora' ? '' : rest.slice('agora '.length).trim();
129
+ }
130
+ return { kind: 'bash', cmd: rest ? `agora ${rest}` : 'agora help' };
131
+ }
132
+ if (looksLikeQuestion(trimmed))
133
+ return { kind: 'chat', msg: trimmed };
134
+ const firstToken = trimmed.split(/\s+/)[0];
135
+ if (SHELL_BUILTINS.has(firstToken) || isExecutable(firstToken)) {
136
+ return { kind: 'bash', cmd: trimmed };
137
+ }
138
+ return { kind: 'chat', msg: trimmed };
139
+ }
140
+ // ── Executable check ────────────────────────────────────────────────────────
141
+ function makeExecutableChecker(pathEnv) {
142
+ const cache = new Map();
143
+ const dirs = (pathEnv ?? '').split(':').filter(Boolean);
144
+ return function isExecutable(name) {
145
+ if (cache.has(name))
146
+ return cache.get(name);
147
+ // PATH lookup is for bare command names only. An absolute or relative path
148
+ // (`/agora`, `./run`, `bin/agora`) is the caller's literal request — Node's
149
+ // `path.join('/usr/bin', '/agora')` discards the leading slash and would
150
+ // falsely match the real `agora` on PATH otherwise.
151
+ if (name.includes('/')) {
152
+ cache.set(name, false);
153
+ return false;
154
+ }
155
+ for (const dir of dirs) {
156
+ const full = join(dir, name);
157
+ try {
158
+ if (existsSync(full)) {
159
+ const st = statSync(full);
160
+ if (st.isFile() && (st.mode & 0o111) !== 0) {
161
+ cache.set(name, true);
162
+ return true;
163
+ }
164
+ }
165
+ }
166
+ catch {
167
+ // skip
168
+ }
169
+ }
170
+ cache.set(name, false);
171
+ return false;
172
+ };
173
+ }
174
+ // ── Helpers ─────────────────────────────────────────────────────────────────
175
+ function expandHome(p) {
176
+ if (p === '~')
177
+ return homedir();
178
+ if (p.startsWith('~/'))
179
+ return join(homedir(), p.slice(2));
180
+ return p;
181
+ }
182
+ function tailBuffer(buf, maxBytes) {
183
+ if (buf.length <= maxBytes)
184
+ return buf;
185
+ return buf.slice(buf.length - maxBytes);
186
+ }
187
+ function formatTranscriptEntry(entry) {
188
+ const time = entry.ts.slice(0, 19);
189
+ const prefix = `[${time}] [${entry.kind}]`;
190
+ const input = entry.input ? ` $ ${entry.input}` : '';
191
+ const output = entry.output ? `\n ${entry.output.split('\n').slice(0, 5).join('\n ')}` : '';
192
+ return `${prefix}${input}${output}`;
193
+ }
194
+ /** Shorten a path for display: replace HOME with ~, truncate if > 30 chars. */
195
+ function shortCwd(p) {
196
+ const home = homedir();
197
+ const withTilde = p.startsWith(home) ? '~' + p.slice(home.length) : p;
198
+ if (withTilde.length <= 30)
199
+ return withTilde;
200
+ const parts = withTilde.split(sep).filter(Boolean);
201
+ if (parts.length <= 2)
202
+ return withTilde;
203
+ return '…' + sep + parts.slice(-2).join(sep);
204
+ }
205
+ /** Check whether opencode is reachable on PATH. */
206
+ function checkOpencodeAvailable() {
207
+ try {
208
+ execSync('which opencode', { stdio: 'pipe', timeout: 2000 });
209
+ return true;
210
+ }
211
+ catch {
212
+ return false;
213
+ }
214
+ }
215
+ /** Extract the first ```bash / ```sh / ```shell block from markdown text. */
216
+ function extractFirstBashBlock(text) {
217
+ const match = text.match(/```(?:bash|sh|shell)\n([\s\S]*?)```/);
218
+ return match ? match[1].trim() : null;
219
+ }
220
+ /** Read a single keypress in raw mode without the full prompter. */
221
+ async function readOneKey() {
222
+ return new Promise((resolve) => {
223
+ const stdin = process.stdin;
224
+ const wasRaw = stdin.isRaw ?? false;
225
+ if (stdin.setRawMode)
226
+ stdin.setRawMode(true);
227
+ stdin.resume();
228
+ function onData(buf) {
229
+ stdin.removeListener('data', onData);
230
+ if (stdin.setRawMode)
231
+ stdin.setRawMode(wasRaw);
232
+ resolve(buf.toString()[0] ?? '');
233
+ }
234
+ stdin.on('data', onData);
235
+ });
236
+ }
237
+ /** Copy text to clipboard using pbcopy (macOS) or xclip (Linux). */
238
+ function copyToClipboard(text) {
239
+ try {
240
+ const cmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
241
+ execSync(cmd, { input: text, stdio: ['pipe', 'ignore', 'ignore'], timeout: 3000 });
242
+ }
243
+ catch {
244
+ // fall back silently
245
+ }
246
+ }
247
+ // ── Main shell loop ─────────────────────────────────────────────────────────
248
+ export async function runShell(io, style) {
249
+ const env = io.env ?? {};
250
+ const trueColor = supportsTrueColor(env);
251
+ function printHome() {
252
+ const banner = renderBanner({ color: true, trueColor });
253
+ const motto = "Developers' CLI marketplace and community hub - type a command, bash or chat:";
254
+ const mottoLine = gradientText(motto, { trueColor });
255
+ const model = FREE_MODELS[0];
256
+ const infoLine = style.dim(`v${AGORA_VERSION} · ${model} · /terminal · /help · /menu · /search · /quit`);
257
+ const slashLine = style.orange('/home · /marketplace · /community · /news · /settings');
258
+ process.stdout.write(`\n${banner}\n\n${mottoLine}\n\n${infoLine}\n${slashLine}\n\n`);
259
+ }
260
+ printHome();
261
+ const opencodeAvailable = checkOpencodeAvailable();
262
+ if (!opencodeAvailable) {
263
+ process.stdout.write(style.dim('Note: opencode not found on PATH. Chat will be unavailable until installed.') +
264
+ '\n\n');
265
+ }
266
+ const dataDir = detectAgoraDataDir({ cwd: io.cwd, env: io.env });
267
+ const cwd0 = io.cwd ?? process.cwd();
268
+ let currentCwd = cwd0;
269
+ let meta = loadSessionMeta(dataDir, cwd0);
270
+ if (meta) {
271
+ process.stdout.write(style.dim(`Resumed ${meta.turnCount} turns from ${meta.lastUsedAt.slice(0, 19)} · session ${(meta.sessionId ?? '').slice(0, 8)}…`) + '\n\n');
272
+ }
273
+ else {
274
+ meta = {
275
+ sessionId: null,
276
+ cwd: cwd0,
277
+ createdAt: new Date().toISOString(),
278
+ lastUsedAt: new Date().toISOString(),
279
+ turnCount: 0
280
+ };
281
+ writeSessionMeta(dataDir, cwd0, meta);
282
+ }
283
+ const isExecutable = makeExecutableChecker(env.PATH);
284
+ let firstTurn = meta.turnCount === 0;
285
+ const exitCode = 0;
286
+ let verbosity = 'medium';
287
+ let childActive = false;
288
+ let totalCost = 0;
289
+ // Lazy caches for completion ids
290
+ let cachedMarketplaceIds = null;
291
+ let cachedSavedIds = null;
292
+ function getMarketplaceIds() {
293
+ if (!cachedMarketplaceIds) {
294
+ cachedMarketplaceIds = getMarketplaceItems().map((item) => item.id);
295
+ }
296
+ return cachedMarketplaceIds;
297
+ }
298
+ function getSavedIds() {
299
+ if (!cachedSavedIds) {
300
+ const state = loadAgoraState(dataDir);
301
+ cachedSavedIds = resolveSavedItems(state).map((e) => e.saved.id);
302
+ }
303
+ return cachedSavedIds;
304
+ }
305
+ const agoraSlashCommands = COMMANDS.map((c) => '/' + c.name);
306
+ const slashCommands = [
307
+ '/tui',
308
+ '/home',
309
+ '/marketplace',
310
+ '/market',
311
+ '/community',
312
+ '/comm',
313
+ '/news',
314
+ '/settings',
315
+ '/help',
316
+ '/menu',
317
+ '/transcript',
318
+ '/verbose',
319
+ '/medium',
320
+ '/quiet',
321
+ '/clear',
322
+ '/quit',
323
+ '/exit',
324
+ '/last',
325
+ '/again',
326
+ ...agoraSlashCommands
327
+ ];
328
+ // Deduplicate: /news, /home etc may appear in both lists
329
+ const seen = new Set();
330
+ const deduped = slashCommands.filter((c) => {
331
+ if (seen.has(c))
332
+ return false;
333
+ seen.add(c);
334
+ return true;
335
+ });
336
+ const finalSlashCommands = deduped;
337
+ const completionContext = {
338
+ slashCommands: finalSlashCommands,
339
+ marketplaceIds: getMarketplaceIds,
340
+ savedIds: getSavedIds,
341
+ listDir: (p) => {
342
+ try {
343
+ return readdirSync(p);
344
+ }
345
+ catch {
346
+ return [];
347
+ }
348
+ },
349
+ cwd: currentCwd
350
+ };
351
+ // In-memory history for prompter (not persisted — transcript covers that)
352
+ const history = [];
353
+ const tips = [
354
+ 'type /help to see all slash commands',
355
+ 'type /menu to browse the command catalog',
356
+ 'type ?<msg> to force AI chat',
357
+ 'type !<cmd> to force bash',
358
+ 'type /clear to reset and see the home banner',
359
+ 'type /transcript to see your last 20 commands',
360
+ 'type /last to re-run the last bash command',
361
+ 'type ?how do I use MCP? to ask about the marketplace',
362
+ 'type /verbose for detailed AI responses',
363
+ 'type /quiet for minimal AI responses',
364
+ 'press Tab to auto-complete commands and paths',
365
+ 'press Ctrl-R to reverse-search your history',
366
+ 'press Ctrl-L to clear the screen',
367
+ 'press Esc to dismiss ghost suggestions',
368
+ 'run `agora search --table` for a table view',
369
+ 'run `agora search --sort stars` to sort by stars',
370
+ 'run `agora search --sort name --order asc` for alphabetical'
371
+ ];
372
+ // Amber chevron when opencode unavailable, accent otherwise
373
+ const accentChevron = opencodeAvailable ? style.accent('›') : '\x1b[38;5;214m›\x1b[0m';
374
+ // B.3 — static portion of the prompt (no chevron); suffix added dynamically
375
+ function buildPromptBase() {
376
+ return style.accent('agora') + ' ' + style.dim(shortCwd(currentCwd)) + ' ';
377
+ }
378
+ function buildPromptSuffix(line) {
379
+ const d = classifyInput(line, isExecutable);
380
+ const hint = d.kind === 'bash' ? style.accent('$') : d.kind === 'chat' ? style.accent('?') : '';
381
+ return hint + accentChevron + ' ';
382
+ }
383
+ function buildContextLine() {
384
+ const model = FREE_MODELS[0];
385
+ const tip = tips[(meta?.turnCount ?? 0) % tips.length];
386
+ return style.dim(`model: ${model} · ${tip}`);
387
+ }
388
+ const sigintHandler = () => {
389
+ if (childActive)
390
+ return;
391
+ // Ctrl-C while idle: the prompter handles abort; SIGINT from outside is rare
392
+ process.stdout.write('\n');
393
+ process.exit(0);
394
+ };
395
+ process.on('SIGINT', sigintHandler);
396
+ try {
397
+ for (;;) {
398
+ // Update completionContext.cwd on each iteration in case cd changed it
399
+ completionContext.cwd = currentCwd;
400
+ const result = await readLine({
401
+ prompt: buildPromptBase(),
402
+ promptSuffix: buildPromptSuffix,
403
+ history,
404
+ completer: (line, cursor) => completeShellLine(line, cursor, completionContext),
405
+ ghostSuggester: (line, hist) => ghostFromHistory(line, hist),
406
+ footer: () => buildContextLine()
407
+ });
408
+ if (result.kind === 'eof') {
409
+ process.stdout.write('\n');
410
+ break;
411
+ }
412
+ if (result.kind === 'abort') {
413
+ // Ctrl-C in prompter: clear input and continue
414
+ process.stdout.write('\n');
415
+ continue;
416
+ }
417
+ const line = result.value;
418
+ if (!line.trim())
419
+ continue;
420
+ // Add to history
421
+ if (history[history.length - 1] !== line) {
422
+ history.push(line);
423
+ }
424
+ const dispatch = classifyInput(line, isExecutable);
425
+ if (dispatch.kind === 'noop')
426
+ continue;
427
+ if (dispatch.kind === 'meta') {
428
+ if (dispatch.sub === 'quit' || dispatch.sub === 'exit')
429
+ break;
430
+ if (dispatch.sub === 'clear') {
431
+ process.stdout.write('\x1b[2J\x1b[H');
432
+ printHome();
433
+ continue;
434
+ }
435
+ if (dispatch.sub === 'help') {
436
+ printHelp(style);
437
+ continue;
438
+ }
439
+ if (dispatch.sub === 'transcript') {
440
+ const entries = readTranscript(dataDir, cwd0, { tail: 20 });
441
+ if (entries.length === 0) {
442
+ process.stdout.write(style.dim('No transcript entries yet.') + '\n');
443
+ }
444
+ else {
445
+ for (const e of entries) {
446
+ process.stdout.write(formatTranscriptEntry(e) + '\n');
447
+ }
448
+ }
449
+ continue;
450
+ }
451
+ if (dispatch.sub === 'menu') {
452
+ await runInteractiveMenu(io, style);
453
+ continue;
454
+ }
455
+ if (dispatch.sub === 'terminal') {
456
+ process.stdout.write(style.dim('Entering subshell. Type exit or Ctrl-D to return.\n'));
457
+ const child = spawn(process.env.SHELL || 'bash', [], {
458
+ stdio: 'inherit',
459
+ cwd: currentCwd,
460
+ env: env
461
+ });
462
+ await new Promise((res) => child.on('exit', () => res()));
463
+ process.stdout.write('\n');
464
+ continue;
465
+ }
466
+ if (dispatch.sub === 'verbose' || dispatch.sub === 'quiet' || dispatch.sub === 'medium') {
467
+ verbosity = dispatch.sub;
468
+ process.stdout.write(style.dim(`Verbosity: ${verbosity}`) + '\n');
469
+ continue;
470
+ }
471
+ if (dispatch.sub === 'last') {
472
+ const entries = readTranscript(dataDir, cwd0);
473
+ const lastBash = [...entries].reverse().find((e) => e.kind === 'bash' && e.input);
474
+ if (!lastBash || !lastBash.input) {
475
+ process.stdout.write(style.dim('No previous bash command in this session.') + '\n');
476
+ continue;
477
+ }
478
+ // Re-dispatch as bash
479
+ history.push(lastBash.input);
480
+ const bashDispatch = { kind: 'bash', cmd: lastBash.input };
481
+ await runBash(bashDispatch.cmd);
482
+ continue;
483
+ }
484
+ if (dispatch.sub === 'again') {
485
+ const entries = readTranscript(dataDir, cwd0);
486
+ const lastChat = [...entries].reverse().find((e) => e.kind === 'chat-user' && e.input);
487
+ if (!lastChat || !lastChat.input) {
488
+ process.stdout.write(style.dim('No previous chat message in this session.') + '\n');
489
+ continue;
490
+ }
491
+ await runChat(lastChat.input);
492
+ continue;
493
+ }
494
+ if (dispatch.sub === 'dry-run' && dispatch.args) {
495
+ const args = dispatch.args.split(/\s+/).filter(Boolean);
496
+ process.stdout.write(style.dim(`╤ dry-run · agora ${args.join(' ')}`) + '\n');
497
+ try {
498
+ const out = execSync(`agora ${args.join(' ')}`, { timeout: 15000, encoding: 'utf8' });
499
+ process.stdout.write(out + '\n');
500
+ }
501
+ catch (e) {
502
+ process.stdout.write((e.stdout ?? '') + (e.stderr ?? '') + '\n');
503
+ }
504
+ continue;
505
+ }
506
+ continue;
507
+ }
508
+ if (dispatch.kind === 'tui') {
509
+ await runTui(io, { initial: dispatch.page ?? 'home' });
510
+ continue;
511
+ }
512
+ if (dispatch.kind === 'bash') {
513
+ await runBash(dispatch.cmd);
514
+ continue;
515
+ }
516
+ if (dispatch.kind === 'chat') {
517
+ if (!opencodeAvailable) {
518
+ process.stdout.write(style.dim('opencode is not available on PATH. Install it to use chat.') + '\n');
519
+ continue;
520
+ }
521
+ await runChat(dispatch.msg);
522
+ }
523
+ }
524
+ }
525
+ finally {
526
+ process.removeListener('SIGINT', sigintHandler);
527
+ }
528
+ return exitCode;
529
+ // ── Bash runner ─────────────────────────────────────────────────────────
530
+ async function runBash(cmd) {
531
+ const cdMatch = cmd.match(/^cd(?:\s+(.+))?$/);
532
+ if (cdMatch) {
533
+ const target = cdMatch[1]?.trim() ?? homedir();
534
+ const resolved = resolve(currentCwd, expandHome(target));
535
+ // Verify the target exists and is a directory before updating cwd.
536
+ // Without this, subsequent spawn() calls inherit a missing cwd and
537
+ // fail with `Error: spawn /bin/sh ENOENT`.
538
+ if (!existsSync(resolved)) {
539
+ process.stdout.write(`cd: no such file or directory: ${target}\n`);
540
+ appendTranscript(dataDir, cwd0, {
541
+ ts: new Date().toISOString(),
542
+ kind: 'bash',
543
+ input: cmd,
544
+ output: `cd: no such file or directory: ${target}`,
545
+ exitCode: 1
546
+ });
547
+ return;
548
+ }
549
+ if (!statSync(resolved).isDirectory()) {
550
+ process.stdout.write(`cd: not a directory: ${target}\n`);
551
+ appendTranscript(dataDir, cwd0, {
552
+ ts: new Date().toISOString(),
553
+ kind: 'bash',
554
+ input: cmd,
555
+ output: `cd: not a directory: ${target}`,
556
+ exitCode: 1
557
+ });
558
+ return;
559
+ }
560
+ currentCwd = resolved;
561
+ completionContext.cwd = resolved;
562
+ process.stdout.write(style.dim(`→ ${resolved}`) + '\n');
563
+ appendTranscript(dataDir, cwd0, {
564
+ ts: new Date().toISOString(),
565
+ kind: 'bash',
566
+ input: cmd,
567
+ output: `→ ${resolved}`
568
+ });
569
+ if (firstTurn) {
570
+ firstTurn = false;
571
+ process.stdout.write(style.dim('Tip: type `/help` to see all agora commands.') + '\n');
572
+ }
573
+ return;
574
+ }
575
+ let buffer = '';
576
+ let done = false;
577
+ let childExitCode = 0;
578
+ let childRef = null;
579
+ const abortChild = () => {
580
+ if (childRef)
581
+ childRef.kill('SIGINT');
582
+ };
583
+ process.on('SIGINT', abortChild);
584
+ childActive = true;
585
+ await new Promise((res) => {
586
+ const child = spawn(cmd, {
587
+ shell: true,
588
+ cwd: currentCwd,
589
+ env: env,
590
+ stdio: ['inherit', 'pipe', 'pipe']
591
+ });
592
+ childRef = child;
593
+ child.stdout?.on('data', (chunk) => {
594
+ const text = chunk.toString();
595
+ process.stdout.write(text);
596
+ buffer = tailBuffer(buffer + text, MAX_BASH_BUFFER);
597
+ });
598
+ child.stderr?.on('data', (chunk) => {
599
+ const text = chunk.toString();
600
+ process.stderr.write(text);
601
+ buffer = tailBuffer(buffer + text, MAX_BASH_BUFFER);
602
+ });
603
+ child.on('close', (code) => {
604
+ childExitCode = code ?? 0;
605
+ done = true;
606
+ res();
607
+ });
608
+ child.on('error', (err) => {
609
+ process.stderr.write(`Error: ${err.message}\n`);
610
+ done = true;
611
+ res();
612
+ });
613
+ });
614
+ process.removeListener('SIGINT', abortChild);
615
+ childActive = false;
616
+ if (!done)
617
+ childExitCode = 1;
618
+ // B.4 — show non-zero exit code
619
+ if (childExitCode !== 0) {
620
+ process.stdout.write(style.dim(`· exit ${childExitCode}`) + '\n');
621
+ }
622
+ appendTranscript(dataDir, cwd0, {
623
+ ts: new Date().toISOString(),
624
+ kind: 'bash',
625
+ input: cmd,
626
+ output: buffer,
627
+ exitCode: childExitCode
628
+ });
629
+ if (firstTurn) {
630
+ firstTurn = false;
631
+ process.stdout.write(style.dim('Tip: type `/help` to see all agora commands.') + '\n');
632
+ }
633
+ }
634
+ // ── Chat runner ──────────────────────────────────────────────────────────
635
+ async function runChat(userMsg) {
636
+ const bashCtx = recentBashContext(dataDir, cwd0, { commands: 3, lines: 20 });
637
+ const systemLine = 'You are running inside the Agora shell, a marketplace TUI for OpenCode. ' +
638
+ 'Prefer using the agora_* MCP tools when the user asks marketplace questions. ' +
639
+ 'Be concise; output flows directly to a terminal.';
640
+ let fullPrompt = `<system>\n${systemLine}`;
641
+ if (bashCtx)
642
+ fullPrompt += `\n${bashCtx}`;
643
+ fullPrompt += `\n<user>\n${userMsg}`;
644
+ const modelArg = FREE_MODELS[0].includes('/') ? FREE_MODELS[0] : `opencode/${FREE_MODELS[0]}`;
645
+ const args = ['run', '--format', 'json', '--model', modelArg];
646
+ if (meta.sessionId)
647
+ args.push('--session', meta.sessionId);
648
+ args.push(fullPrompt);
649
+ const renderer = createChatRenderer({
650
+ verbosity,
651
+ style,
652
+ trueColor,
653
+ out: process.stdout
654
+ });
655
+ let chatChildRef = null;
656
+ const abortChat = () => {
657
+ if (chatChildRef)
658
+ chatChildRef.kill('SIGINT');
659
+ };
660
+ process.on('SIGINT', abortChat);
661
+ childActive = true;
662
+ // B.4 — accumulate last 4 KB of stderr for failure diagnosis
663
+ let errBuffer = '';
664
+ let chatExitCode = 0;
665
+ let spawnError = null;
666
+ await new Promise((res) => {
667
+ const child = spawn('opencode', args, {
668
+ env: env,
669
+ stdio: ['ignore', 'pipe', 'pipe'],
670
+ shell: false
671
+ });
672
+ chatChildRef = child;
673
+ child.stdout?.on('data', (chunk) => {
674
+ const text = chunk.toString();
675
+ for (const rawLine of text.split('\n').filter(Boolean)) {
676
+ renderer.handleLine(rawLine);
677
+ }
678
+ });
679
+ child.stderr?.on('data', (chunk) => {
680
+ errBuffer = tailBuffer(errBuffer + chunk.toString(), 4096);
681
+ });
682
+ child.on('close', (code) => {
683
+ chatExitCode = code ?? 0;
684
+ res();
685
+ });
686
+ child.on('error', (err) => {
687
+ spawnError = err;
688
+ res();
689
+ });
690
+ });
691
+ process.removeListener('SIGINT', abortChat);
692
+ childActive = false;
693
+ renderer.finalize();
694
+ totalCost += renderer.getTotalCost();
695
+ // B.4 — detect and report chat failures
696
+ const chatFailed = spawnError !== null || (chatExitCode !== 0 && !renderer.hasReceivedText());
697
+ if (chatFailed) {
698
+ let reason;
699
+ if (spawnError) {
700
+ reason = 'opencode binary not found';
701
+ }
702
+ else if (errBuffer.includes('Model not found')) {
703
+ reason = '/model to pick another model (or check OPENCODE_MODEL)';
704
+ }
705
+ else {
706
+ reason = 'chat failed; see /transcript for details';
707
+ }
708
+ process.stdout.write('\x1b[31m▍\x1b[0m' + style.dim(` failed · ${reason}`) + '\n');
709
+ }
710
+ const renderedSessionId = renderer.getSessionId();
711
+ if (!meta.sessionId && renderedSessionId) {
712
+ meta.sessionId = renderedSessionId;
713
+ writeSessionMeta(dataDir, cwd0, meta);
714
+ }
715
+ const assistantBuffer = renderer.getAssistantText();
716
+ appendTranscript(dataDir, cwd0, {
717
+ ts: new Date().toISOString(),
718
+ kind: 'chat-user',
719
+ input: userMsg
720
+ });
721
+ appendTranscript(dataDir, cwd0, {
722
+ ts: new Date().toISOString(),
723
+ kind: 'chat-assistant',
724
+ output: assistantBuffer
725
+ });
726
+ meta.turnCount += 1;
727
+ meta.lastUsedAt = new Date().toISOString();
728
+ writeSessionMeta(dataDir, cwd0, meta);
729
+ if (firstTurn) {
730
+ firstTurn = false;
731
+ process.stdout.write(style.dim('Tip: type `/help` to see all agora commands.') + '\n');
732
+ }
733
+ // Code-block hotkeys
734
+ await handleCodeBlock(assistantBuffer);
735
+ }
736
+ // ── Code-block hotkeys ───────────────────────────────────────────────────
737
+ async function handleCodeBlock(text) {
738
+ const block = extractFirstBashBlock(text);
739
+ if (!block)
740
+ return;
741
+ process.stdout.write(style.dim('Code block: ') + style.accent('(r)un · (c)opy · (e)dit · (s)kip') + ' ');
742
+ const key = await readOneKey();
743
+ process.stdout.write('\n');
744
+ if (key === 'r') {
745
+ await runBash(block);
746
+ appendTranscript(dataDir, cwd0, {
747
+ ts: new Date().toISOString(),
748
+ kind: 'bash',
749
+ input: block,
750
+ output: ''
751
+ });
752
+ }
753
+ else if (key === 'c') {
754
+ copyToClipboard(block);
755
+ process.stdout.write(style.dim('Copied to clipboard.') + '\n');
756
+ }
757
+ else if (key === 'e') {
758
+ const tmpDir = mkdtempSync(join(tmpdir(), 'agora-'));
759
+ const tmpFile = join(tmpDir, 'block.sh');
760
+ writeFileSync(tmpFile, block, 'utf8');
761
+ const editor = process.env.EDITOR ?? 'vi';
762
+ await new Promise((res) => {
763
+ const child = spawn(editor, [tmpFile], { stdio: 'inherit', shell: false });
764
+ child.on('close', () => res());
765
+ child.on('error', () => res());
766
+ });
767
+ // After editor, offer run/skip
768
+ process.stdout.write(style.dim('(r)un · (s)kip '));
769
+ const key2 = await readOneKey();
770
+ process.stdout.write('\n');
771
+ if (key2 === 'r') {
772
+ const { readFileSync } = await import('node:fs');
773
+ const edited = readFileSync(tmpFile, 'utf8').trim();
774
+ await runBash(edited);
775
+ }
776
+ }
777
+ // s or other: skip silently
778
+ }
779
+ }
780
+ function printHelp(style) {
781
+ const lines = [
782
+ style.accent('Agora Shell — help'),
783
+ '',
784
+ style.dim('Dispatch rules:'),
785
+ ' <command> first word on PATH → run as bash',
786
+ ' <anything> else → send to AI chat',
787
+ ' !<cmd> force bash (e.g. !ls -la)',
788
+ ' ?<msg> force chat (e.g. ?what is MCP)',
789
+ ' /tui open the full-screen TUI on Home',
790
+ ' /home /market /comm /news /settings open the TUI on that page',
791
+ ' /menu open the command-browser menu',
792
+ ' /transcript print last 20 transcript entries',
793
+ ' /clear clear screen',
794
+ ' /help this help',
795
+ ' /quit exit shell',
796
+ ' /last re-run most recent bash command',
797
+ ' /again re-send most recent chat message',
798
+ ' /? <cmd> dry-run an agora command (e.g. /? install mcp-github)',
799
+ '',
800
+ style.dim('Verbosity:'),
801
+ ' /verbose /medium /quiet',
802
+ '',
803
+ style.dim('Free AI models:'),
804
+ ...FREE_MODELS.map((m) => ` ${m}`),
805
+ '',
806
+ style.dim('Agora commands (also available as /slash shortcuts):')
807
+ ];
808
+ const groups = ['Marketplace', 'Setup', 'Library', 'Learn', 'Community'];
809
+ for (const g of groups) {
810
+ const cmds = COMMANDS.filter((c) => c.group === g);
811
+ lines.push(` ${style.dim(g)}`);
812
+ for (const c of cmds) {
813
+ lines.push(` ${style.accent(c.name.padEnd(14))} ${c.summary}`);
814
+ }
815
+ }
816
+ lines.push('', style.dim(`agora v${AGORA_VERSION} · run \`agora help <command>\` or \`/<command> --help\` for details`));
817
+ process.stdout.write(lines.join('\n') + '\n\n');
818
+ }
819
+ //# sourceMappingURL=shell.js.map