ispbills-icli 1.0.0 → 1.1.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.
package/bin/icli.js CHANGED
@@ -4,7 +4,7 @@ import { stdin as input, stdout as output, argv, exit } from 'process';
4
4
  import chalk from 'chalk';
5
5
  import { loadConfig, configPath } from '../src/config.js';
6
6
  import { loginFlow } from '../src/auth.js';
7
- import { streamChat } from '../src/chat.js';
7
+ import { streamChat, printBanner } from '../src/chat.js';
8
8
 
9
9
  // ─── Parse args ─────────────────────────────────────────────────────────────
10
10
  const args = argv.slice(2);
@@ -12,47 +12,54 @@ const cmd = args[0] ?? '';
12
12
 
13
13
  // ─── Help ────────────────────────────────────────────────────────────────────
14
14
  function printHelp() {
15
- console.log(`
16
- ${chalk.bold.cyan('iCli')} IspBills AI network engineer in your terminal
17
- ${chalk.dim('Powered by iCopilot · github.com/lupael/IspBills')}
18
-
19
- ${chalk.bold('Usage:')}
20
- ${chalk.cyan('icli')} Interactive chat (REPL)
21
- ${chalk.cyan('icli')} ${chalk.yellow('"your question"')} Single-shot query, then exit
22
- ${chalk.cyan('icli login')} Authenticate (or re-authenticate)
23
- ${chalk.cyan('icli whoami')} Show current login info
24
- ${chalk.cyan('icli --help')} Show this help
25
-
26
- ${chalk.bold('Examples:')}
27
- ${chalk.dim('$')} icli "is vlan 82 in use?"
28
- ${chalk.dim('$')} icli "show all ONUs on olt#1"
29
- ${chalk.dim('$')} icli "which customer is using IP 192.168.1.50?"
30
- ${chalk.dim('$')} icli ${chalk.dim('# start interactive REPL')}
31
-
32
- ${chalk.bold('Config stored at:')}
33
- ${chalk.dim(configPath())}
34
- `);
15
+ const line = '─'.repeat(58);
16
+ console.log('\n' + chalk.cyan('' + line + '╮'));
17
+ console.log(chalk.cyan(' │ ') + chalk.bold.white('iCli') + chalk.dim(' — IspBills AI Terminal') + ' '.repeat(28) + chalk.cyan(' │'));
18
+ console.log(chalk.cyan(' ╰' + line + '╯') + '\n');
19
+
20
+ console.log(chalk.bold(' Usage\n'));
21
+ console.log(' ' + chalk.cyan('icli') + ' ' + chalk.dim('Interactive REPL'));
22
+ console.log(' ' + chalk.cyan('icli') + ' ' + chalk.yellow('"your question"') + ' ' + chalk.dim('Single-shot query'));
23
+ console.log(' ' + chalk.cyan('icli login') + ' ' + chalk.dim('Authenticate'));
24
+ console.log(' ' + chalk.cyan('icli whoami') + ' ' + chalk.dim('Show current login'));
25
+ console.log(' ' + chalk.cyan('icli --help') + ' ' + chalk.dim('Show this help'));
26
+
27
+ console.log('\n' + chalk.bold(' Examples\n'));
28
+ console.log(' ' + chalk.dim('$') + ' icli "is vlan 82 in use?"');
29
+ console.log(' ' + chalk.dim('$') + ' icli "show all ONUs on olt#1"');
30
+ console.log(' ' + chalk.dim('$') + ' icli "which customer is using IP 192.168.1.50?"');
31
+ console.log(' ' + chalk.dim('$') + ' icli' + chalk.dim(' # start interactive REPL'));
32
+
33
+ console.log('\n' + chalk.bold(' Config\n'));
34
+ console.log(' ' + chalk.dim(configPath()) + '\n');
35
35
  }
36
36
 
37
37
  // ─── Ensure authenticated ────────────────────────────────────────────────────
38
38
  async function ensureAuth() {
39
39
  let cfg = loadConfig();
40
40
  if (!cfg) {
41
- console.log(chalk.yellow('No saved login found. Let\'s set up iCli.\n'));
41
+ console.log(chalk.yellow('\n No saved login found. Let\'s connect iCli.\n'));
42
42
  cfg = await loginFlow();
43
- console.log(chalk.green(`\n✓ Logged in as ${cfg.user?.name ?? cfg.user?.email} (${cfg.user?.role ?? 'operator'})`));
44
- console.log(chalk.dim(` Connected to: ${cfg.url}\n`));
43
+ console.log('\n ' + chalk.green('') + ' Logged in as ' +
44
+ chalk.white(cfg.user?.name ?? cfg.user?.email) +
45
+ chalk.dim(' · ' + (cfg.user?.role ?? 'operator')));
46
+ console.log(' ' + chalk.dim('Connected to: ') + chalk.cyan(cfg.url) + '\n');
45
47
  }
46
48
  return cfg;
47
49
  }
48
50
 
49
51
  // ─── Single-shot query ───────────────────────────────────────────────────────
50
52
  async function singleShot(cfg, question) {
53
+ // Show the user's own message
54
+ process.stdout.write('\n ' + chalk.cyan('You ') + chalk.white(question) + '\n');
55
+ process.stdout.write(' ' + chalk.dim('─'.repeat(58)) + '\n');
56
+ process.stdout.write(' ' + chalk.bold.cyan('iCopilot') + '\n');
57
+
51
58
  const messages = [{ role: 'user', content: question }];
52
59
  try {
53
60
  await streamChat(cfg, messages);
54
61
  } catch (e) {
55
- console.error(chalk.red('\n✖ ' + e.message));
62
+ console.error('\n ' + chalk.red('✖ ' + e.message) + '\n');
56
63
  exit(1);
57
64
  }
58
65
  }
@@ -60,41 +67,54 @@ async function singleShot(cfg, question) {
60
67
  // ─── Interactive REPL ────────────────────────────────────────────────────────
61
68
  async function interactiveRepl(cfg) {
62
69
  const history = [];
70
+
71
+ printBanner(cfg);
72
+
63
73
  const rl = readline.createInterface({
64
74
  input, output,
65
- prompt: chalk.cyan('iCli') + chalk.dim(''),
75
+ prompt: chalk.cyan(' You ') + chalk.white(''),
66
76
  });
67
77
 
68
- console.log(
69
- chalk.bold.cyan('\n iCli') + chalk.dim(' — IspBills AI terminal\n') +
70
- chalk.dim(` Connected to: ${cfg.url}`) + '\n' +
71
- chalk.dim(' Type your question. Press Ctrl+C or type "exit" to quit.\n')
72
- );
73
78
  rl.prompt();
74
79
 
75
80
  rl.on('line', async (line) => {
76
81
  const q = line.trim();
77
82
  if (!q) { rl.prompt(); return; }
78
- if (q === 'exit' || q === 'quit') { rl.close(); return; }
79
- if (q === 'clear') { console.clear(); rl.prompt(); return; }
83
+
84
+ if (q === 'exit' || q === 'quit') {
85
+ console.log(chalk.dim('\n Goodbye.\n'));
86
+ rl.close();
87
+ exit(0);
88
+ }
89
+ if (q === 'clear') { console.clear(); printBanner(cfg); rl.prompt(); return; }
80
90
  if (q === 'history') {
81
- history.forEach((m, i) => {
82
- if (m.role === 'user') console.log(chalk.dim(` ${i}: `) + m.content.slice(0, 80));
91
+ console.log('');
92
+ history.filter(m => m.role === 'user').forEach((m, i) => {
93
+ console.log(' ' + chalk.dim(String(i + 1) + '.') + ' ' + m.content.slice(0, 80));
83
94
  });
95
+ console.log('');
84
96
  rl.prompt();
85
97
  return;
86
98
  }
99
+ if (q === 'help') { printHelp(); rl.prompt(); return; }
87
100
 
88
101
  rl.pause();
89
102
  history.push({ role: 'user', content: q });
90
103
 
104
+ // Show iCopilot label before streaming response
105
+ process.stdout.write(' ' + chalk.dim('─'.repeat(58)) + '\n');
106
+ process.stdout.write(' ' + chalk.bold.cyan('iCopilot') + '\n');
107
+
91
108
  try {
92
109
  const answer = await streamChat(cfg, [...history]);
93
110
  if (answer) history.push({ role: 'assistant', content: answer });
94
111
  } catch (e) {
95
- console.error(chalk.red('\n✖ ' + e.message + '\n'));
112
+ console.error('\n ' + chalk.red('✖ ' + e.message) + '\n');
96
113
  }
97
114
 
115
+ // Prompt for next message with label
116
+ process.stdout.write(' ' + chalk.dim('─'.repeat(58)) + '\n');
117
+ rl.setPrompt(chalk.cyan(' You ') + chalk.white(''));
98
118
  rl.resume();
99
119
  rl.prompt();
100
120
  });
@@ -113,18 +133,26 @@ async function main() {
113
133
 
114
134
  if (cmd === 'login') {
115
135
  const cfg = await loginFlow();
116
- console.log(chalk.green(`\n✓ Logged in as ${cfg.user?.name ?? cfg.user?.email} (${cfg.user?.role})`));
117
- console.log(chalk.dim(` Config saved to: ${configPath()}\n`));
136
+ console.log('\n ' + chalk.green('') + ' Logged in as ' +
137
+ chalk.white(cfg.user?.name ?? cfg.user?.email) +
138
+ chalk.dim(' · ' + (cfg.user?.role ?? 'operator')));
139
+ console.log(' ' + chalk.dim('Config saved: ') + chalk.dim(configPath()) + '\n');
118
140
  return;
119
141
  }
120
142
 
121
143
  if (cmd === 'whoami') {
122
144
  const cfg = loadConfig();
123
- if (!cfg) { console.log(chalk.yellow('Not logged in. Run `icli login`.')); return; }
124
- console.log(`\n${chalk.bold('URL:')} ${cfg.url}`);
125
- console.log(`${chalk.bold('Name:')} ${cfg.user?.name ?? '—'}`);
126
- console.log(`${chalk.bold('Email:')} ${cfg.user?.email ?? '—'}`);
127
- console.log(`${chalk.bold('Role:')} ${cfg.user?.role ?? ''}\n`);
145
+ if (!cfg) {
146
+ console.log('\n ' + chalk.yellow('Not logged in.') + ' Run ' + chalk.cyan('icli login') + '\n');
147
+ return;
148
+ }
149
+ const line = ''.repeat(58);
150
+ console.log('\n' + chalk.dim(' ' + line));
151
+ console.log(' ' + chalk.dim('URL ') + chalk.cyan(cfg.url));
152
+ console.log(' ' + chalk.dim('Name ') + chalk.white(cfg.user?.name ?? '—'));
153
+ console.log(' ' + chalk.dim('Email ') + chalk.white(cfg.user?.email ?? '—'));
154
+ console.log(' ' + chalk.dim('Role ') + chalk.white(cfg.user?.role ?? '—'));
155
+ console.log(chalk.dim(' ' + line) + '\n');
128
156
  return;
129
157
  }
130
158
 
@@ -140,4 +168,7 @@ async function main() {
140
168
  await interactiveRepl(cfg);
141
169
  }
142
170
 
143
- main().catch(e => { console.error(chalk.red('✖ ' + e.message)); exit(1); });
171
+ main().catch(e => {
172
+ console.error('\n ' + chalk.red('✖ ' + e.message) + '\n');
173
+ exit(1);
174
+ });
package/package.json CHANGED
@@ -1,11 +1,18 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
- "keywords": ["ispbills", "network", "isp", "ai", "cli", "noc"],
5
+ "keywords": [
6
+ "ispbills",
7
+ "network",
8
+ "isp",
9
+ "ai",
10
+ "cli",
11
+ "noc"
12
+ ],
6
13
  "license": "MIT",
7
14
  "bin": {
8
- "icli": "./bin/icli.js"
15
+ "icli": "bin/icli.js"
9
16
  },
10
17
  "engines": {
11
18
  "node": ">=18.0.0"
@@ -17,6 +24,9 @@
17
24
  "README.md"
18
25
  ],
19
26
  "dependencies": {
20
- "chalk": "^5.3.0"
27
+ "chalk": "^5.3.0",
28
+ "marked": "^15.0.12",
29
+ "marked-terminal": "^7.3.0",
30
+ "ora": "^9.4.1"
21
31
  }
22
32
  }
package/src/auth.js CHANGED
@@ -7,11 +7,14 @@ export async function loginFlow(existingUrl = '') {
7
7
 
8
8
  console.log('\n🔌 iCli — IspBills AI terminal\n');
9
9
 
10
+ const DEFAULT_URL = 'https://app.ispbills.com';
10
11
  let url = existingUrl;
11
12
  if (!url) {
12
- url = (await rl.question('IspBills instance URL (e.g. https://app.myisp.com): ')).trim();
13
+ const input_url = (await rl.question(`IspBills instance URL [${DEFAULT_URL}]: `)).trim();
14
+ url = input_url || DEFAULT_URL;
13
15
  }
14
16
  url = url.replace(/\/$/, '');
17
+ if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
15
18
 
16
19
  const email = (await rl.question('Email: ')).trim();
17
20
  const password = await rl.question('Password: ');
package/src/chat.js CHANGED
@@ -1,7 +1,30 @@
1
1
  import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { marked } from 'marked';
4
+ import TerminalRenderer from 'marked-terminal';
2
5
  import { refreshToken } from './auth.js';
3
6
 
4
- const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
7
+ // Configure markdown renderer
8
+ marked.setOptions({ renderer: new TerminalRenderer({
9
+ firstHeading: chalk.bold.cyan,
10
+ heading: chalk.bold.cyan,
11
+ strong: chalk.bold.white,
12
+ em: chalk.italic.dim,
13
+ codespan: (code) => chalk.bgGray.white(` ${code} `),
14
+ code: (code) => code.split('\n').map(l => chalk.green(' ' + l)).join('\n'),
15
+ blockquote: chalk.dim,
16
+ listitem: (text) => ` ${chalk.cyan('•')} ${text}`,
17
+ hr: () => chalk.dim(' ' + '─'.repeat(56)) + '\n',
18
+ link: (_href, _title, text) => chalk.cyan.underline(text || _href),
19
+ }) });
20
+
21
+ function renderMarkdown(text) {
22
+ try {
23
+ return marked(text).trimEnd();
24
+ } catch {
25
+ return text;
26
+ }
27
+ }
5
28
 
6
29
  /**
7
30
  * Stream a single chat turn to stdout.
@@ -14,7 +37,6 @@ const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','
14
37
  */
15
38
  export async function streamChat(cfg, messages, context = {}, opts = {}) {
16
39
  const showThinking = opts.thinking !== false;
17
-
18
40
  const body = JSON.stringify({ messages, context });
19
41
 
20
42
  let res = await fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
@@ -48,55 +70,44 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
48
70
  throw new Error(`AI request failed (HTTP ${res.status}): ${txt.slice(0, 200)}`);
49
71
  }
50
72
 
51
- // ── Render state ────────────────────────────────────────────────────────
52
- let spinTimer = null;
53
- let spinIdx = 0;
54
- let thinkingLine = false;
55
- let reasonBuf = '';
56
- let statusBuf = '';
57
- let answerBuf = '';
58
- let lastWasNewline = true;
59
-
60
- function clearSpinner() {
61
- if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
62
- if (thinkingLine) {
63
- process.stdout.write('\r\x1b[K'); // clear the thinking line
64
- thinkingLine = false;
65
- }
66
- }
73
+ // ── Spinner ──────────────────────────────────────────────────────────────
74
+ const spinner = ora({
75
+ text: chalk.dim('Thinking…'),
76
+ color: 'cyan',
77
+ spinner: 'dots',
78
+ prefixText: ' ',
79
+ });
67
80
 
68
- function startSpinner() {
69
- if (!showThinking) return;
70
- thinkingLine = true;
71
- spinTimer = setInterval(() => {
72
- const f = SPINNER_FRAMES[spinIdx++ % SPINNER_FRAMES.length];
73
- process.stdout.write(`\r${chalk.blue(f)} ${chalk.dim('Thinking…')}`);
74
- }, 80);
81
+ let reasonBuf = '';
82
+ let answerBuf = '';
83
+ let rawAnswer = '';
84
+ let finalResult = null;
85
+
86
+ function stopSpinner() {
87
+ if (spinner.isSpinning) spinner.stop();
75
88
  }
76
89
 
77
90
  function printStatus(line) {
78
- clearSpinner();
91
+ stopSpinner();
79
92
  process.stdout.write(chalk.magenta(' › ') + chalk.dim(line) + '\n');
80
- lastWasNewline = true;
81
- if (showThinking) startSpinner();
82
- }
83
-
84
- function printReasoning(delta) {
85
- // Reasoning streams as a running line; collapse to a single dim block.
86
- reasonBuf += delta;
93
+ if (showThinking) spinner.start();
87
94
  }
88
95
 
89
96
  function flushReasoning() {
90
- if (!reasonBuf) return;
91
- clearSpinner();
92
- const lines = reasonBuf.trim().split('\n');
93
- process.stdout.write(chalk.dim(' ┆ ' + lines.join('\n')) + '\n');
97
+ if (!reasonBuf.trim()) return;
98
+ stopSpinner();
99
+ const lines = reasonBuf.trim().split('\n').filter(Boolean);
100
+ process.stdout.write('\n');
101
+ process.stdout.write(chalk.dim(' ┌─ Reasoning ' + '─'.repeat(44)) + '\n');
102
+ for (const l of lines) {
103
+ process.stdout.write(chalk.dim(' │ ') + chalk.dim.italic(l) + '\n');
104
+ }
105
+ process.stdout.write(chalk.dim(' └' + '─'.repeat(57)) + '\n');
94
106
  reasonBuf = '';
95
- lastWasNewline = true;
96
107
  }
97
108
 
98
109
  function parseStreamText(raw) {
99
- const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)$/);
110
+ const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)/);
100
111
  if (!m) return raw.trimStart().startsWith('{') ? null : raw;
101
112
  return m[1]
102
113
  .replace(/\\n/g, '\n').replace(/\\t/g, '\t')
@@ -104,17 +115,15 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
104
115
  }
105
116
 
106
117
  // ── SSE reader ───────────────────────────────────────────────────────────
107
- if (showThinking) startSpinner();
118
+ if (showThinking) spinner.start();
108
119
 
109
120
  const decoder = new TextDecoder();
110
121
  let sseBuffer = '';
111
- let finalResult = null;
112
- let rawAnswer = '';
113
122
 
114
123
  for await (const chunk of res.body) {
115
124
  sseBuffer += decoder.decode(chunk, { stream: true });
116
125
  const lines = sseBuffer.split('\n');
117
- sseBuffer = lines.pop(); // keep incomplete line in buffer
126
+ sseBuffer = lines.pop();
118
127
 
119
128
  for (const line of lines) {
120
129
  const trimmed = line.replace(/\r$/, '');
@@ -124,15 +133,12 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
124
133
 
125
134
  if (ev.status_delta !== undefined) {
126
135
  printStatus(ev.status_delta);
127
-
128
136
  } else if (ev.reason_delta !== undefined) {
129
- printReasoning(ev.reason_delta);
130
-
137
+ reasonBuf += ev.reason_delta;
131
138
  } else if (ev.delta !== undefined) {
132
139
  rawAnswer += ev.delta;
133
140
  const txt = parseStreamText(rawAnswer);
134
141
  if (txt !== null) answerBuf = txt;
135
-
136
142
  } else if (ev.done) {
137
143
  finalResult = ev.result;
138
144
  }
@@ -140,42 +146,72 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
140
146
  }
141
147
 
142
148
  // ── Final render ─────────────────────────────────────────────────────────
143
- clearSpinner();
149
+ stopSpinner();
144
150
  flushReasoning();
145
151
 
146
- const reply = finalResult?.reply ?? {};
152
+ const reply = finalResult?.reply ?? {};
147
153
  const replyType = reply.type ?? 'message';
148
154
 
155
+ process.stdout.write('\n' + chalk.dim(' ' + '─'.repeat(58)) + '\n\n');
156
+
149
157
  if (replyType === 'message') {
150
158
  const text = reply.text || answerBuf || '(no response)';
151
- if (!lastWasNewline) process.stdout.write('\n');
152
- process.stdout.write('\n' + chalk.white(text) + '\n\n');
159
+ const rendered = renderMarkdown(text);
160
+ const indented = rendered.split('\n').map(l => ' ' + l).join('\n');
161
+ process.stdout.write(indented + '\n\n');
153
162
  return text;
154
163
 
155
164
  } else if (replyType === 'plan') {
156
- process.stdout.write('\n');
157
- process.stdout.write(chalk.bold.cyan('📋 Plan: ') + chalk.cyan(reply.summary || '') + '\n');
165
+ process.stdout.write(' ' + chalk.bold.cyan('📋 ') + chalk.bold.white(reply.summary || 'Plan') + '\n\n');
158
166
  (reply.steps || []).forEach((step, i) => {
159
- const risk = step.risk === 'high' ? chalk.red(' ⚠ high') : '';
160
- process.stdout.write(
161
- ` ${chalk.dim(String(i + 1) + '.')} ${chalk.yellow(step.cmd || '')}${risk}\n`
162
- );
163
- if (step.purpose) process.stdout.write(chalk.dim(` ${step.purpose}\n`));
167
+ const risk = step.risk === 'high' ? chalk.red(' ⚠ high risk') : '';
168
+ process.stdout.write(` ${chalk.cyan(String(i + 1) + '.')} ${chalk.yellow(step.cmd || '')}${risk}\n`);
169
+ if (step.purpose) process.stdout.write(chalk.dim(` ${step.purpose}\n`));
164
170
  });
165
171
  process.stdout.write('\n');
166
172
  return reply.summary || '';
167
173
 
168
174
  } else if (replyType === 'connect') {
169
175
  const dev = reply.device ?? {};
170
- process.stdout.write('\n');
171
176
  process.stdout.write(
172
- chalk.bold.green('🔌 Connect: ') +
173
- chalk.green(`${dev.kind ?? '?'}#${dev.id ?? '?'} ${dev.name ?? ''} (${dev.host ?? ''})`) + '\n'
177
+ ' ' + chalk.bold.green('🔌 ') +
178
+ chalk.bold.green(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) +
179
+ chalk.white(` ${dev.name ?? ''} `) +
180
+ chalk.dim(`(${dev.host ?? ''})`) + '\n'
174
181
  );
175
- if (reply.note) process.stdout.write(chalk.dim(' ' + reply.note) + '\n');
182
+ if (reply.note) process.stdout.write(chalk.dim(' ' + reply.note) + '\n');
176
183
  process.stdout.write('\n');
177
184
  return reply.note || '';
178
185
  }
179
186
 
180
187
  return answerBuf;
181
188
  }
189
+
190
+ /** Print a styled welcome banner. */
191
+ export function printBanner(cfg) {
192
+ const line = '─'.repeat(58);
193
+ process.stdout.write('\n');
194
+ process.stdout.write(chalk.cyan(' ╭' + line + '╮') + '\n');
195
+ process.stdout.write(
196
+ chalk.cyan(' │ ') +
197
+ chalk.bold.white('iCopilot') +
198
+ chalk.dim(' · IspBills AI Network Engineer') +
199
+ ' '.repeat(13) +
200
+ chalk.cyan(' │') + '\n'
201
+ );
202
+ process.stdout.write(chalk.cyan(' ╰' + line + '╯') + '\n\n');
203
+
204
+ if (cfg?.url) {
205
+ process.stdout.write(chalk.dim(' Connected ') + chalk.cyan(cfg.url) + '\n');
206
+ }
207
+ if (cfg?.user?.name || cfg?.user?.email) {
208
+ process.stdout.write(
209
+ chalk.dim(' Logged in ') +
210
+ chalk.white(cfg.user.name || cfg.user.email) +
211
+ chalk.dim(' · ' + (cfg.user.role ?? 'operator')) + '\n'
212
+ );
213
+ }
214
+ process.stdout.write('\n');
215
+ process.stdout.write(chalk.dim(' Type your question and press Enter. Type "exit" to quit.\n'));
216
+ process.stdout.write(chalk.dim(' ' + line) + '\n\n');
217
+ }