oh-aicoding-tool 0.1.0 → 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 (3) hide show
  1. package/README.md +4 -3
  2. package/bin/cli.js +273 -56
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,9 +23,10 @@ npx oh-aicoding-tool langfuse setup
23
23
  npx oh-aicoding-tool report install opencode --email user@company.com
24
24
  ```
25
25
 
26
- Running `npx oh-aicoding-tool` with no subcommand opens a guided menu. Choose
27
- Langfuse tracing or the issue report plugin; the report path asks for the
28
- install target and guides company email configuration before installing.
26
+ Running `npx oh-aicoding-tool` with no subcommand opens a highlighted TUI.
27
+ Use arrow keys to move, `Space` to select one or more tools, and `Enter` to
28
+ continue. The report path also supports multi-select install targets and guides
29
+ company email configuration before installing.
29
30
 
30
31
  ## Commands
31
32
 
package/bin/cli.js CHANGED
@@ -1,10 +1,60 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
+ import readline from "node:readline";
3
4
  import { spawnSync } from "node:child_process";
4
5
  import { createInterface } from "node:readline/promises";
5
6
  import { fileURLToPath } from "node:url";
6
7
 
7
8
  const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
+ const langfuseCli = path.join(rootDir, "bin", "langfuse-cli.js");
10
+ const reportCli = path.join(rootDir, "opencode-ohai-report", "bin", "cli.js");
11
+
12
+ const colorEnabled = process.stdout.isTTY && process.env.NO_COLOR !== "1";
13
+ const ansi = (code) => (colorEnabled ? `\x1b[${code}m` : "");
14
+ const rgb = (r, g, b) => (colorEnabled ? `\x1b[38;2;${r};${g};${b}m` : "");
15
+ const bg = (r, g, b) => (colorEnabled ? `\x1b[48;2;${r};${g};${b}m` : "");
16
+ const t = {
17
+ reset: ansi(0),
18
+ bold: ansi(1),
19
+ dim: ansi(2),
20
+ cyan: rgb(92, 214, 255),
21
+ teal: rgb(92, 232, 188),
22
+ blue: rgb(132, 167, 255),
23
+ gold: rgb(245, 202, 116),
24
+ red: rgb(255, 117, 117),
25
+ muted: rgb(140, 151, 166),
26
+ panel: rgb(75, 86, 105),
27
+ selectedBg: bg(31, 53, 68),
28
+ selectedFg: rgb(218, 250, 255),
29
+ };
30
+
31
+ function paint(value, ...styles) {
32
+ if (!colorEnabled) return String(value);
33
+ return `${styles.join("")}${value}${t.reset}`;
34
+ }
35
+
36
+ function terminalWidth() {
37
+ return Math.max(72, Math.min(process.stdout.columns || 88, 104));
38
+ }
39
+
40
+ function clearScreen() {
41
+ if (process.stdout.isTTY) process.stdout.write("\x1b[2J\x1b[H");
42
+ }
43
+
44
+ function rule(width = terminalWidth()) {
45
+ return paint("-".repeat(width), t.panel);
46
+ }
47
+
48
+ function renderHeader(subtitle = "Interactive setup") {
49
+ clearScreen();
50
+ console.log("");
51
+ console.log(`${paint("oh-aicoding-tool", t.bold, t.teal)} ${paint(subtitle, t.cyan)}`);
52
+ console.log(rule());
53
+ console.log(
54
+ `${paint("Space", t.gold)} toggle ${paint("Enter", t.gold)} continue ` +
55
+ `${paint("Up/Down", t.gold)} move ${paint("q", t.gold)} exit`
56
+ );
57
+ }
8
58
 
9
59
  function runNodeScript(script, args) {
10
60
  const result = spawnSync(process.execPath, [script, ...args], {
@@ -18,7 +68,7 @@ function printHelp() {
18
68
  console.log("oh-aicoding-tool");
19
69
  console.log("");
20
70
  console.log("Usage:");
21
- console.log(" oh-aicoding-tool Open the installer menu");
71
+ console.log(" oh-aicoding-tool Open the interactive TUI");
22
72
  console.log(" oh-aicoding-tool langfuse [setup|check] [target]");
23
73
  console.log(" oh-aicoding-tool report install opencode [--email user@company.com]");
24
74
  console.log(" oh-aicoding-tool report install claude");
@@ -35,13 +85,70 @@ function emailIsValid(value) {
35
85
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || "").trim());
36
86
  }
37
87
 
38
- async function askChoice(rl, title, choices) {
88
+ function renderChoiceScreen(title, choices, index, subtitle) {
89
+ renderHeader(subtitle);
39
90
  console.log("");
40
- console.log(title);
41
- choices.forEach((choice, index) => {
42
- console.log(` ${index + 1}. ${choice.label}`);
43
- if (choice.description) console.log(` ${choice.description}`);
91
+ console.log(paint(title, t.bold, t.blue));
92
+ console.log("");
93
+ choices.forEach((choice, idx) => {
94
+ const focused = idx === index;
95
+ const cursor = focused ? paint(">", t.teal, t.bold) : " ";
96
+ const label = focused
97
+ ? paint(` ${choice.label} `, t.bold, t.selectedFg, t.selectedBg)
98
+ : paint(choice.label, t.bold);
99
+ console.log(` ${cursor} ${paint(`[${idx + 1}]`, focused ? t.selectedFg : t.muted)} ${label}`);
100
+ if (choice.description) {
101
+ console.log(` ${paint(choice.description, focused ? t.blue : t.muted)}`);
102
+ }
103
+ if (idx < choices.length - 1) console.log("");
44
104
  });
105
+ }
106
+
107
+ async function askChoice(rl, title, choices, subtitle = "Interactive setup") {
108
+ if (process.stdin.isTTY && process.stdout.isTTY) {
109
+ rl.pause();
110
+ return await new Promise((resolve) => {
111
+ let index = 0;
112
+ const stdin = process.stdin;
113
+
114
+ function cleanup(value) {
115
+ stdin.off("keypress", onKeypress);
116
+ if (stdin.isTTY) stdin.setRawMode(false);
117
+ stdin.pause();
118
+ rl.resume();
119
+ clearScreen();
120
+ resolve(value);
121
+ }
122
+
123
+ function onKeypress(_, key = {}) {
124
+ if (key.ctrl && key.name === "c") return cleanup("exit");
125
+ if (key.name === "q" || key.name === "escape") return cleanup("exit");
126
+ if (key.name === "up") {
127
+ index = (index - 1 + choices.length) % choices.length;
128
+ renderChoiceScreen(title, choices, index, subtitle);
129
+ return;
130
+ }
131
+ if (key.name === "down") {
132
+ index = (index + 1) % choices.length;
133
+ renderChoiceScreen(title, choices, index, subtitle);
134
+ return;
135
+ }
136
+ if (key.name === "return" || key.name === "enter") return cleanup(choices[index].value);
137
+ const number = Number.parseInt(key.sequence, 10);
138
+ if (Number.isInteger(number) && choices[number - 1]) return cleanup(choices[number - 1].value);
139
+ }
140
+
141
+ readline.emitKeypressEvents(stdin);
142
+ if (stdin.isTTY) stdin.setRawMode(true);
143
+ stdin.on("keypress", onKeypress);
144
+ stdin.resume();
145
+ renderChoiceScreen(title, choices, index, subtitle);
146
+ });
147
+ }
148
+
149
+ console.log("");
150
+ console.log(title);
151
+ choices.forEach((choice, index) => console.log(` ${index + 1}. ${choice.label}`));
45
152
  while (true) {
46
153
  const answer = (await rl.question("> ")).trim().toLowerCase();
47
154
  const index = Number.parseInt(answer, 10) - 1;
@@ -52,78 +159,188 @@ async function askChoice(rl, title, choices) {
52
159
  }
53
160
  }
54
161
 
55
- const langfuseCli = path.join(rootDir, "bin", "langfuse-cli.js");
56
- const reportCli = path.join(rootDir, "opencode-ohai-report", "bin", "cli.js");
162
+ function renderMultiChoiceScreen(title, choices, index, selected, subtitle) {
163
+ renderHeader(subtitle);
164
+ console.log("");
165
+ console.log(paint(title, t.bold, t.blue));
166
+ console.log("");
167
+ choices.forEach((choice, idx) => {
168
+ const focused = idx === index;
169
+ const checked = selected.has(choice.value);
170
+ const box = checked ? paint("[x]", t.teal, t.bold) : paint("[ ]", t.muted);
171
+ const cursor = focused ? paint(">", t.teal, t.bold) : " ";
172
+ const label = focused
173
+ ? paint(` ${choice.label} `, t.bold, t.selectedFg, t.selectedBg)
174
+ : paint(choice.label, t.bold);
175
+ console.log(` ${cursor} ${box} ${label}`);
176
+ if (choice.description) {
177
+ console.log(` ${paint(choice.description, focused ? t.blue : t.muted)}`);
178
+ }
179
+ if (idx < choices.length - 1) console.log("");
180
+ });
181
+ }
182
+
183
+ async function askMultiChoice(rl, title, choices, subtitle = "Interactive setup") {
184
+ if (process.stdin.isTTY && process.stdout.isTTY) {
185
+ rl.pause();
186
+ return await new Promise((resolve) => {
187
+ let index = 0;
188
+ const selected = new Set(choices.filter((choice) => choice.selected).map((choice) => choice.value));
189
+ const stdin = process.stdin;
190
+
191
+ function cleanup(value) {
192
+ stdin.off("keypress", onKeypress);
193
+ if (stdin.isTTY) stdin.setRawMode(false);
194
+ stdin.pause();
195
+ rl.resume();
196
+ clearScreen();
197
+ resolve(value);
198
+ }
199
+
200
+ function toggle() {
201
+ const value = choices[index].value;
202
+ if (selected.has(value)) selected.delete(value);
203
+ else selected.add(value);
204
+ renderMultiChoiceScreen(title, choices, index, selected, subtitle);
205
+ }
206
+
207
+ function onKeypress(_, key = {}) {
208
+ if (key.ctrl && key.name === "c") return cleanup([]);
209
+ if (key.name === "q" || key.name === "escape") return cleanup([]);
210
+ if (key.name === "up") {
211
+ index = (index - 1 + choices.length) % choices.length;
212
+ renderMultiChoiceScreen(title, choices, index, selected, subtitle);
213
+ return;
214
+ }
215
+ if (key.name === "down") {
216
+ index = (index + 1) % choices.length;
217
+ renderMultiChoiceScreen(title, choices, index, selected, subtitle);
218
+ return;
219
+ }
220
+ if (key.name === "space") return toggle();
221
+ if (key.name === "return" || key.name === "enter") return cleanup([...selected]);
222
+ const number = Number.parseInt(key.sequence, 10);
223
+ if (Number.isInteger(number) && choices[number - 1]) {
224
+ index = number - 1;
225
+ toggle();
226
+ }
227
+ }
228
+
229
+ readline.emitKeypressEvents(stdin);
230
+ if (stdin.isTTY) stdin.setRawMode(true);
231
+ stdin.on("keypress", onKeypress);
232
+ stdin.resume();
233
+ renderMultiChoiceScreen(title, choices, index, selected, subtitle);
234
+ });
235
+ }
236
+
237
+ console.log("");
238
+ console.log(title);
239
+ choices.forEach((choice, index) => console.log(` ${index + 1}. ${choice.label}`));
240
+ const answer = await rl.question("Select numbers separated by comma > ");
241
+ return answer
242
+ .split(/[, ]+/)
243
+ .map((item) => Number.parseInt(item, 10) - 1)
244
+ .filter((index) => Number.isInteger(index) && choices[index])
245
+ .map((index) => choices[index].value);
246
+ }
57
247
 
58
248
  async function askEmail(rl) {
59
249
  const envEmail = process.env.OHAI_INSTALL_USER_EMAIL || "";
60
250
  const hint = emailIsValid(envEmail) ? ` [${envEmail}]` : "";
251
+ renderHeader("Issue report setup");
252
+ console.log("");
253
+ console.log(paint("Company email", t.bold, t.blue));
254
+ console.log(paint("Used as the stable user identity in issue reports.", t.muted));
255
+ console.log(paint("Press Enter to skip email setup for now.", t.muted));
61
256
  console.log("");
62
- console.log("Company email is used as the user identity in issue reports.");
63
- console.log("Press Enter to skip email setup for now.");
64
257
  while (true) {
65
- const answer = (await rl.question(`Company email${hint}: `)).trim();
258
+ const answer = (await rl.question(`${paint("Email", t.cyan)}${paint(hint, t.muted)} > `)).trim();
66
259
  const value = answer || (emailIsValid(envEmail) ? envEmail.trim() : "");
67
260
  if (!value) return "";
68
261
  if (emailIsValid(value)) return value;
69
- console.log("Invalid email. Example: name@company.com");
262
+ console.log(paint("Invalid email. Example: name@company.com", t.red));
70
263
  }
71
264
  }
72
265
 
73
- async function runReportInstallMenu(rl) {
74
- const target = await askChoice(rl, "Install issue report plugin for:", [
75
- { label: "OpenCode", value: "opencode", description: "Install /report-ai-issue and the OpenCode plugin." },
76
- { label: "Claude Code", value: "claude", description: "Install the command and optional hook integration." },
77
- { label: "Both", value: "both", description: "Install OpenCode and Claude Code integrations together." },
78
- { label: "Back", value: "back" },
79
- ]);
80
- if (target === "back") return 0;
81
- const email = await askEmail(rl);
82
- const args = ["install", target];
83
- if (email) args.push("--email", email);
84
- else args.push("--skip-email");
85
- rl.close();
86
- return runNodeScript(reportCli, args);
87
- }
88
-
89
- async function runMainMenu() {
90
- if (!process.stdin.isTTY) {
91
- printHelp();
92
- return 0;
93
- }
94
- const rl = createInterface({ input: process.stdin, output: process.stdout });
95
- try {
96
- console.log("");
97
- console.log("oh-aicoding-tool");
98
- const action = await askChoice(rl, "What would you like to install or configure?", [
266
+ async function collectInstallPlan(rl) {
267
+ const tools = await askMultiChoice(
268
+ rl,
269
+ "Select tools to install or configure",
270
+ [
99
271
  {
100
272
  label: "Langfuse tracing",
101
273
  value: "langfuse",
274
+ selected: true,
102
275
  description: "Configure tracing for Claude Code, OpenCode, or Codex.",
103
276
  },
104
277
  {
105
278
  label: "Issue report plugin",
106
279
  value: "report",
107
- description: "Install oh-ai-report and configure company email.",
280
+ selected: true,
281
+ description: "Install /report-ai-issue and configure company email.",
108
282
  },
109
- { label: "Check Langfuse configuration", value: "check-langfuse" },
110
- { label: "Check report plugin", value: "check-report" },
111
- { label: "Exit", value: "exit" },
112
- ]);
113
- if (action === "langfuse") {
114
- rl.close();
115
- return runNodeScript(langfuseCli, ["setup"]);
283
+ ],
284
+ "Install menu"
285
+ );
286
+
287
+ if (!tools.length) return { tools: [], reportTarget: "", email: "" };
288
+
289
+ let reportTarget = "";
290
+ let email = "";
291
+ if (tools.includes("report")) {
292
+ const targets = await askMultiChoice(
293
+ rl,
294
+ "Select issue report install targets",
295
+ [
296
+ {
297
+ label: "OpenCode",
298
+ value: "opencode",
299
+ selected: true,
300
+ description: "Install the OpenCode plugin and /report-ai-issue command.",
301
+ },
302
+ {
303
+ label: "Claude Code",
304
+ value: "claude",
305
+ selected: false,
306
+ description: "Install the command and optional hook integration.",
307
+ },
308
+ ],
309
+ "Issue report setup"
310
+ );
311
+ if (!targets.length) {
312
+ const withoutReport = tools.filter((item) => item !== "report");
313
+ return { tools: withoutReport, reportTarget: "", email: "" };
116
314
  }
117
- if (action === "report") return await runReportInstallMenu(rl);
118
- if (action === "check-langfuse") {
119
- rl.close();
120
- return runNodeScript(langfuseCli, ["check"]);
315
+ reportTarget = targets.length === 2 ? "both" : targets[0];
316
+ email = await askEmail(rl);
317
+ }
318
+
319
+ return { tools, reportTarget, email };
320
+ }
321
+
322
+ async function runInteractiveInstall() {
323
+ if (!process.stdin.isTTY) {
324
+ printHelp();
325
+ return 0;
326
+ }
327
+
328
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
329
+ try {
330
+ const plan = await collectInstallPlan(rl);
331
+ rl.close();
332
+
333
+ let code = 0;
334
+ if (plan.tools.includes("langfuse")) {
335
+ code ||= runNodeScript(langfuseCli, ["setup"]);
121
336
  }
122
- if (action === "check-report") {
123
- rl.close();
124
- return runNodeScript(reportCli, ["doctor"]);
337
+ if (plan.tools.includes("report") && plan.reportTarget) {
338
+ const args = ["install", plan.reportTarget];
339
+ if (plan.email) args.push("--email", plan.email);
340
+ else args.push("--skip-email");
341
+ code ||= runNodeScript(reportCli, args);
125
342
  }
126
- return 0;
343
+ return code;
127
344
  } finally {
128
345
  rl.close();
129
346
  }
@@ -138,7 +355,7 @@ async function main() {
138
355
  return 0;
139
356
  }
140
357
 
141
- if (!cmd) return await runMainMenu();
358
+ if (!cmd) return await runInteractiveInstall();
142
359
 
143
360
  if (cmd === "langfuse" || cmd === "trace" || cmd === "tracing") {
144
361
  return runNodeScript(langfuseCli, rest);
@@ -161,6 +378,6 @@ async function main() {
161
378
  main()
162
379
  .then((code) => process.exit(code))
163
380
  .catch((err) => {
164
- console.error(err?.message || String(err));
381
+ console.error(paint(err?.message || String(err), t.red));
165
382
  process.exit(1);
166
383
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-aicoding-tool",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Configure AI coding tools with Langfuse tracing and install the oh-ai-report issue feedback plugin.",