ispbills-icli 1.1.0 → 3.0.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 +154 -86
- package/package.json +1 -1
- package/src/chat.js +200 -125
package/bin/icli.js
CHANGED
|
@@ -1,174 +1,242 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import { createInterface } from 'readline';
|
|
3
3
|
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 {
|
|
7
|
+
import {
|
|
8
|
+
streamChat, printBanner, printSlashMenu,
|
|
9
|
+
SLASH_COMMANDS, slashCompleter, cols,
|
|
10
|
+
} from '../src/chat.js';
|
|
11
|
+
|
|
12
|
+
// ── Copilot CLI colour tokens ──────────────────────────────────────────────────
|
|
13
|
+
const dim = chalk.hex('#9198A1');
|
|
14
|
+
const tertiary = chalk.hex('#6e7781');
|
|
15
|
+
const accent = chalk.hex('#4493F8').bold;
|
|
16
|
+
const rule = () => chalk.hex('#3D444D')(' ' + '─'.repeat(Math.max(0, cols() - 4)));
|
|
8
17
|
|
|
9
|
-
// ─── Parse args ─────────────────────────────────────────────────────────────
|
|
10
18
|
const args = argv.slice(2);
|
|
11
19
|
const cmd = args[0] ?? '';
|
|
12
20
|
|
|
13
|
-
//
|
|
21
|
+
// ── Help ───────────────────────────────────────────────────────────────────────
|
|
14
22
|
function printHelp() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
23
|
+
process.stdout.write('\n');
|
|
24
|
+
process.stdout.write(' ' + chalk.bold.white('iCli') + dim(' · IspBills AI Terminal\n'));
|
|
25
|
+
process.stdout.write(rule() + '\n\n');
|
|
26
|
+
|
|
27
|
+
process.stdout.write(chalk.bold.white(' Usage\n\n'));
|
|
28
|
+
const cmds = [
|
|
29
|
+
['icli', 'Interactive REPL with slash commands'],
|
|
30
|
+
['icli "question"', 'Single-shot query then exit'],
|
|
31
|
+
['icli login', 'Authenticate to your IspBills instance'],
|
|
32
|
+
['icli whoami', 'Show current session info'],
|
|
33
|
+
['icli --help', 'Show this help'],
|
|
34
|
+
];
|
|
35
|
+
for (const [c, d] of cmds) {
|
|
36
|
+
process.stdout.write(' ' + accent(c.padEnd(20)) + dim(d) + '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
process.stdout.write('\n' + chalk.bold.white(' Slash commands\n\n'));
|
|
40
|
+
for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
|
|
41
|
+
const key = (c + (hint ? ' ' + hint : '')).padEnd(22);
|
|
42
|
+
process.stdout.write(' ' + accent(key) + dim(desc) + '\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
process.stdout.write('\n' + chalk.bold.white(' Keyboard shortcuts\n\n'));
|
|
46
|
+
[
|
|
47
|
+
['Tab', 'Autocomplete slash commands'],
|
|
48
|
+
['↑ / ↓', 'Navigate input history'],
|
|
49
|
+
['Ctrl+T', 'Toggle reasoning display'],
|
|
50
|
+
['Ctrl+L', 'Clear screen'],
|
|
51
|
+
['Ctrl+C', 'Cancel / exit'],
|
|
52
|
+
].forEach(([k, d]) => {
|
|
53
|
+
process.stdout.write(' ' + chalk.yellow(k.padEnd(14)) + dim(d) + '\n');
|
|
54
|
+
});
|
|
55
|
+
process.stdout.write('\n');
|
|
35
56
|
}
|
|
36
57
|
|
|
37
|
-
//
|
|
58
|
+
// ── Auth ───────────────────────────────────────────────────────────────────────
|
|
38
59
|
async function ensureAuth() {
|
|
39
60
|
let cfg = loadConfig();
|
|
40
61
|
if (!cfg) {
|
|
41
|
-
|
|
62
|
+
process.stdout.write(chalk.yellow('\n No saved credentials. Setting up iCli.\n\n'));
|
|
42
63
|
cfg = await loginFlow();
|
|
43
|
-
|
|
64
|
+
process.stdout.write(
|
|
65
|
+
'\n ' + chalk.green('✓') + ' Logged in as ' +
|
|
44
66
|
chalk.white(cfg.user?.name ?? cfg.user?.email) +
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n\n'
|
|
68
|
+
);
|
|
47
69
|
}
|
|
48
70
|
return cfg;
|
|
49
71
|
}
|
|
50
72
|
|
|
51
|
-
//
|
|
73
|
+
// ── Slash → question ───────────────────────────────────────────────────────────
|
|
74
|
+
function slashToQuestion(line) {
|
|
75
|
+
const [sc, ...rest] = line.trim().split(/\s+/);
|
|
76
|
+
const arg = rest.join(' ');
|
|
77
|
+
switch (sc) {
|
|
78
|
+
case '/check': return arg ? `run diagnostics on ${arg}` : 'check status of all devices';
|
|
79
|
+
case '/vlan': return arg ? `is vlan ${arg} in use or free?` : 'list all vlans in use';
|
|
80
|
+
case '/onu': return arg ? `show ONU ${arg} status and signal level` : 'list all ONUs';
|
|
81
|
+
case '/customer': return arg ? `look up customer ${arg}` : 'show recent customers';
|
|
82
|
+
case '/ping': return arg ? `ping ${arg} through the network` : 'which devices are reachable?';
|
|
83
|
+
case '/online': return 'list currently online PPPoE sessions';
|
|
84
|
+
default: return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Single-shot ────────────────────────────────────────────────────────────────
|
|
52
89
|
async function singleShot(cfg, question) {
|
|
53
|
-
//
|
|
54
|
-
process.stdout.write('\n ' + chalk.
|
|
55
|
-
process.stdout.write(' ' + chalk.
|
|
56
|
-
process.stdout.write(
|
|
90
|
+
// Copilot CLI style: role label then content, separated by blank lines
|
|
91
|
+
process.stdout.write('\n ' + chalk.hex('#4493F8').bold('You') + '\n');
|
|
92
|
+
process.stdout.write(' ' + chalk.white(question) + '\n\n');
|
|
93
|
+
process.stdout.write(rule() + '\n\n');
|
|
94
|
+
process.stdout.write(' ' + chalk.bold.white('iCopilot') + '\n');
|
|
57
95
|
|
|
58
|
-
const messages = [{ role: 'user', content: question }];
|
|
59
96
|
try {
|
|
60
|
-
await streamChat(cfg,
|
|
97
|
+
await streamChat(cfg, [{ role: 'user', content: question }]);
|
|
61
98
|
} catch (e) {
|
|
62
|
-
|
|
99
|
+
process.stdout.write('\n ' + chalk.red('✖ ') + chalk.white(e.message) + '\n\n');
|
|
63
100
|
exit(1);
|
|
64
101
|
}
|
|
65
102
|
}
|
|
66
103
|
|
|
67
|
-
//
|
|
104
|
+
// ── REPL ───────────────────────────────────────────────────────────────────────
|
|
68
105
|
async function interactiveRepl(cfg) {
|
|
69
|
-
const history
|
|
106
|
+
const history = [];
|
|
107
|
+
let showReasoning = true;
|
|
70
108
|
|
|
71
109
|
printBanner(cfg);
|
|
72
110
|
|
|
73
|
-
|
|
111
|
+
// Copilot CLI uses `> ` style prompt
|
|
112
|
+
const PROMPT = ' ' + chalk.hex('#4493F8').bold('>') + ' ';
|
|
113
|
+
|
|
114
|
+
const rl = createInterface({
|
|
74
115
|
input, output,
|
|
75
|
-
prompt:
|
|
116
|
+
prompt: PROMPT,
|
|
117
|
+
completer: slashCompleter,
|
|
118
|
+
terminal: true,
|
|
76
119
|
});
|
|
77
120
|
|
|
121
|
+
process.stdout.on('resize', () => rl.setPrompt(PROMPT));
|
|
122
|
+
|
|
78
123
|
rl.prompt();
|
|
79
124
|
|
|
80
125
|
rl.on('line', async (line) => {
|
|
81
126
|
const q = line.trim();
|
|
82
127
|
if (!q) { rl.prompt(); return; }
|
|
83
128
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
rl.close();
|
|
87
|
-
exit(0);
|
|
129
|
+
// ── Built-in commands ──────────────────────────────────────────────────
|
|
130
|
+
if (q === '/exit' || q === 'exit' || q === 'quit') {
|
|
131
|
+
process.stdout.write(tertiary('\n Goodbye.\n\n')); rl.close(); exit(0);
|
|
88
132
|
}
|
|
89
|
-
if (q === 'clear'
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
133
|
+
if (q === '/clear' || q === 'clear') {
|
|
134
|
+
process.stdout.write('\x1Bc'); printBanner(cfg); rl.prompt(); return;
|
|
135
|
+
}
|
|
136
|
+
if (q === '/help' || q === 'help') { printHelp(); rl.prompt(); return; }
|
|
137
|
+
if (q === '/history' || q === 'history') {
|
|
138
|
+
const turns = history.filter(m => m.role === 'user');
|
|
139
|
+
if (!turns.length) {
|
|
140
|
+
process.stdout.write(tertiary('\n No history yet.\n\n'));
|
|
141
|
+
} else {
|
|
142
|
+
process.stdout.write('\n');
|
|
143
|
+
turns.forEach((m, i) =>
|
|
144
|
+
process.stdout.write(' ' + dim(String(i+1).padStart(2)+'.') + ' ' + chalk.white(m.content.slice(0, cols()-8)) + '\n')
|
|
145
|
+
);
|
|
146
|
+
process.stdout.write('\n');
|
|
147
|
+
}
|
|
148
|
+
rl.prompt(); return;
|
|
149
|
+
}
|
|
150
|
+
if (q === '/') { printSlashMenu(); rl.prompt(); return; }
|
|
151
|
+
|
|
152
|
+
// ── Resolve slash command ──────────────────────────────────────────────
|
|
153
|
+
let question = q;
|
|
154
|
+
if (q.startsWith('/')) {
|
|
155
|
+
const resolved = slashToQuestion(q);
|
|
156
|
+
if (resolved) {
|
|
157
|
+
question = resolved;
|
|
158
|
+
process.stdout.write(' ' + tertiary('→ ') + dim(question) + '\n');
|
|
159
|
+
}
|
|
98
160
|
}
|
|
99
|
-
if (q === 'help') { printHelp(); rl.prompt(); return; }
|
|
100
161
|
|
|
101
162
|
rl.pause();
|
|
102
|
-
history.push({ role: 'user', content:
|
|
163
|
+
history.push({ role: 'user', content: question });
|
|
103
164
|
|
|
104
|
-
//
|
|
105
|
-
process.stdout.write('
|
|
106
|
-
process.stdout.write(' ' + chalk.bold.
|
|
165
|
+
// Role separator — Copilot CLI style blank-line + label pattern
|
|
166
|
+
process.stdout.write('\n' + rule() + '\n\n');
|
|
167
|
+
process.stdout.write(' ' + chalk.bold.white('iCopilot') + '\n');
|
|
107
168
|
|
|
108
169
|
try {
|
|
109
|
-
const answer = await streamChat(cfg, [...history]);
|
|
170
|
+
const answer = await streamChat(cfg, [...history], {}, { thinking: showReasoning });
|
|
110
171
|
if (answer) history.push({ role: 'assistant', content: answer });
|
|
111
172
|
} catch (e) {
|
|
112
|
-
|
|
173
|
+
process.stdout.write('\n ' + chalk.red('✖ ') + chalk.white(e.message) + '\n\n');
|
|
113
174
|
}
|
|
114
175
|
|
|
115
|
-
|
|
116
|
-
process.stdout.write(' ' + chalk.dim('─'.repeat(58)) + '\n');
|
|
117
|
-
rl.setPrompt(chalk.cyan(' You ') + chalk.white(''));
|
|
176
|
+
process.stdout.write(rule() + '\n\n');
|
|
118
177
|
rl.resume();
|
|
119
178
|
rl.prompt();
|
|
120
179
|
});
|
|
121
180
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
181
|
+
// Ctrl+T toggles reasoning (matches Copilot CLI Ctrl+T)
|
|
182
|
+
if (input.setRawMode) {
|
|
183
|
+
process.stdin.on('keypress', (str, key) => {
|
|
184
|
+
if (key?.ctrl && key?.name === 't') {
|
|
185
|
+
showReasoning = !showReasoning;
|
|
186
|
+
process.stdout.write(tertiary(`\n Reasoning ${showReasoning ? 'on' : 'off'}\n\n`));
|
|
187
|
+
rl.prompt();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
rl.on('close', () => { process.stdout.write(tertiary('\n Goodbye.\n\n')); exit(0); });
|
|
126
193
|
}
|
|
127
194
|
|
|
128
|
-
//
|
|
195
|
+
// ── Main ───────────────────────────────────────────────────────────────────────
|
|
129
196
|
async function main() {
|
|
130
|
-
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
131
|
-
printHelp(); return;
|
|
132
|
-
}
|
|
197
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') { printHelp(); return; }
|
|
133
198
|
|
|
134
199
|
if (cmd === 'login') {
|
|
135
200
|
const cfg = await loginFlow();
|
|
136
|
-
|
|
201
|
+
process.stdout.write(
|
|
202
|
+
'\n ' + chalk.green('✓') + ' Logged in as ' +
|
|
137
203
|
chalk.white(cfg.user?.name ?? cfg.user?.email) +
|
|
138
|
-
|
|
139
|
-
|
|
204
|
+
dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n'
|
|
205
|
+
);
|
|
206
|
+
process.stdout.write(' ' + dim('Config: ' + configPath()) + '\n\n');
|
|
140
207
|
return;
|
|
141
208
|
}
|
|
142
209
|
|
|
143
210
|
if (cmd === 'whoami') {
|
|
144
211
|
const cfg = loadConfig();
|
|
145
212
|
if (!cfg) {
|
|
146
|
-
|
|
213
|
+
process.stdout.write('\n ' + chalk.yellow('Not logged in.') + ' Run ' + accent('icli login') + '\n\n');
|
|
147
214
|
return;
|
|
148
215
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
216
|
+
process.stdout.write('\n' + rule() + '\n');
|
|
217
|
+
[
|
|
218
|
+
['url', cfg.url],
|
|
219
|
+
['name', cfg.user?.name],
|
|
220
|
+
['email', cfg.user?.email],
|
|
221
|
+
['role', cfg.user?.role],
|
|
222
|
+
].forEach(([k, v]) => {
|
|
223
|
+
if (v) process.stdout.write(' ' + dim(k.padEnd(7)) + chalk.white(v) + '\n');
|
|
224
|
+
});
|
|
225
|
+
process.stdout.write(rule() + '\n\n');
|
|
156
226
|
return;
|
|
157
227
|
}
|
|
158
228
|
|
|
159
229
|
const cfg = await ensureAuth();
|
|
160
230
|
|
|
161
|
-
// Single-shot: icli "question here"
|
|
162
231
|
if (cmd && !cmd.startsWith('-')) {
|
|
163
232
|
await singleShot(cfg, args.join(' '));
|
|
164
233
|
return;
|
|
165
234
|
}
|
|
166
235
|
|
|
167
|
-
// Interactive REPL
|
|
168
236
|
await interactiveRepl(cfg);
|
|
169
237
|
}
|
|
170
238
|
|
|
171
239
|
main().catch(e => {
|
|
172
|
-
|
|
240
|
+
process.stdout.write('\n ' + chalk.red('✖ ') + chalk.white(e.message) + '\n\n');
|
|
173
241
|
exit(1);
|
|
174
242
|
});
|
package/package.json
CHANGED
package/src/chat.js
CHANGED
|
@@ -4,119 +4,209 @@ import { marked } from 'marked';
|
|
|
4
4
|
import TerminalRenderer from 'marked-terminal';
|
|
5
5
|
import { refreshToken } from './auth.js';
|
|
6
6
|
|
|
7
|
-
//
|
|
7
|
+
// ── Copilot CLI colour palette (matched from app.js theme tokens) ─────────────
|
|
8
|
+
const C = {
|
|
9
|
+
textPrimary: chalk.white,
|
|
10
|
+
textSecondary: chalk.hex('#9198A1'),
|
|
11
|
+
textTertiary: chalk.hex('#6e7781'),
|
|
12
|
+
accent: chalk.hex('#4493F8'),
|
|
13
|
+
border: chalk.hex('#3D444D'),
|
|
14
|
+
userLabel: chalk.hex('#4493F8').bold, // same as selected/accent
|
|
15
|
+
assistantLabel: chalk.white.bold,
|
|
16
|
+
statusOk: chalk.green,
|
|
17
|
+
statusErr: chalk.red,
|
|
18
|
+
statusWarn: chalk.yellow,
|
|
19
|
+
code: chalk.hex('#79c0ff'), // syntaxString dark
|
|
20
|
+
codeBg: chalk.bgHex('#161b22').hex('#c9d1d9'),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// ── Layout ────────────────────────────────────────────────────────────────────
|
|
24
|
+
export const cols = () => Math.min((process.stdout.columns || 80), 88);
|
|
25
|
+
const rule = (ch = '─') => C.border(' ' + ch.repeat(Math.max(0, cols() - 4)));
|
|
26
|
+
|
|
27
|
+
// ── Markdown (matched to Copilot CLI syntax colours) ──────────────────────────
|
|
8
28
|
marked.setOptions({ renderer: new TerminalRenderer({
|
|
9
|
-
firstHeading: chalk.bold.
|
|
10
|
-
heading: chalk.bold.
|
|
29
|
+
firstHeading: chalk.bold.white,
|
|
30
|
+
heading: chalk.bold.white,
|
|
11
31
|
strong: chalk.bold.white,
|
|
12
|
-
em: chalk.italic.
|
|
13
|
-
codespan: (
|
|
14
|
-
code: (
|
|
15
|
-
blockquote:
|
|
16
|
-
listitem: (
|
|
17
|
-
hr: () =>
|
|
18
|
-
link: (
|
|
32
|
+
em: chalk.italic.hex('#9198A1'),
|
|
33
|
+
codespan: (t) => chalk.bgHex('#161b22').hex('#c9d1d9')(` ${t} `),
|
|
34
|
+
code: (t) => '\n' + t.split('\n').map(l => ' ' + chalk.hex('#c9d1d9')(l)).join('\n') + '\n',
|
|
35
|
+
blockquote: (t) => C.textSecondary(' ▌ ' + t.trim()),
|
|
36
|
+
listitem: (t) => ` ${C.accent('·')} ${t.trimEnd()}`,
|
|
37
|
+
hr: () => rule() + '\n',
|
|
38
|
+
link: (_h, _t, t) => chalk.hex('#58a6ff').underline(t || _h),
|
|
19
39
|
}) });
|
|
20
40
|
|
|
21
41
|
function renderMarkdown(text) {
|
|
22
|
-
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
42
|
+
try { return marked(text).trimEnd(); } catch { return text; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Status icon (same pattern, cleaner) ───────────────────────────────────────
|
|
46
|
+
function statusIcon(line) {
|
|
47
|
+
const l = line.toLowerCase();
|
|
48
|
+
if (/unreachable|skip|fail|error/.test(l)) return C.statusErr('✗');
|
|
49
|
+
if (/done|success|found|complet|online/.test(l)) return C.statusOk('✓');
|
|
50
|
+
if (/batch|sub.?agent|fleet|parallel/.test(l)) return C.accent('◈');
|
|
51
|
+
if (/\[\d+\/\d+\]/.test(l)) return C.accent('◉');
|
|
52
|
+
if (/probe|reachab|ping/.test(l)) return C.statusWarn('◎');
|
|
53
|
+
if (/read|fetch|get|query/.test(l)) return C.textSecondary('↓');
|
|
54
|
+
if (/connect|ssh|telnet|api/.test(l)) return C.accent('⇢');
|
|
55
|
+
if (/⌁/.test(line)) return C.border('⌁');
|
|
56
|
+
return C.border('›');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Banner (Copilot CLI style: label · info · shortcuts) ─────────────────────
|
|
60
|
+
export function printBanner(cfg, meta = {}) {
|
|
61
|
+
const model = meta.model ? shortModel(meta.model) : (cfg?._model ?? null);
|
|
62
|
+
process.stdout.write('\n');
|
|
63
|
+
|
|
64
|
+
// Title row — bold label, dim subtitle
|
|
65
|
+
process.stdout.write(
|
|
66
|
+
' ' + chalk.bold.white('iCopilot') +
|
|
67
|
+
C.textSecondary(' · IspBills AI Network Engineer') + '\n'
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Session info row (model · user · role) — matches Copilot CLI footer format
|
|
71
|
+
const parts = [];
|
|
72
|
+
if (model) parts.push(C.textSecondary('model ') + chalk.white(model));
|
|
73
|
+
if (cfg?.user?.name) parts.push(C.textSecondary('user ') + chalk.white(cfg.user.name));
|
|
74
|
+
if (cfg?.user?.role) parts.push(C.textSecondary('role ') + chalk.white(cfg.user.role));
|
|
75
|
+
if (cfg?.url) parts.push(C.textSecondary('url ') + C.accent(cfg.url));
|
|
76
|
+
if (parts.length) {
|
|
77
|
+
process.stdout.write(' ' + parts.join(C.textSecondary(' · ')) + '\n');
|
|
26
78
|
}
|
|
79
|
+
|
|
80
|
+
// Hint row — matches Copilot CLI shortcut hints
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
C.textTertiary(' / commands Tab autocomplete ↑↓ history Ctrl+C exit') + '\n'
|
|
83
|
+
);
|
|
84
|
+
process.stdout.write(rule() + '\n\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function shortModel(m) {
|
|
88
|
+
return m.replace('openai/', '').replace(/^claude-/, '').replace(/^gpt-/, 'gpt-');
|
|
27
89
|
}
|
|
28
90
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
91
|
+
// ── Slash commands ─────────────────────────────────────────────────────────────
|
|
92
|
+
export const SLASH_COMMANDS = [
|
|
93
|
+
{ cmd: '/check', hint: '[device]', desc: 'Run diagnostics on a device' },
|
|
94
|
+
{ cmd: '/vlan', hint: '[id]', desc: 'Check VLAN usage' },
|
|
95
|
+
{ cmd: '/onu', hint: '[id]', desc: 'Show ONU status and signal' },
|
|
96
|
+
{ cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer' },
|
|
97
|
+
{ cmd: '/ping', hint: '[host]', desc: 'Ping a host' },
|
|
98
|
+
{ cmd: '/online', hint: '', desc: 'List online sessions' },
|
|
99
|
+
{ cmd: '/history', hint: '', desc: 'Show conversation history' },
|
|
100
|
+
{ cmd: '/clear', hint: '', desc: 'Clear the screen' },
|
|
101
|
+
{ cmd: '/help', hint: '', desc: 'Show help' },
|
|
102
|
+
{ cmd: '/exit', hint: '', desc: 'Exit iCli' },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
export function printSlashMenu() {
|
|
106
|
+
process.stdout.write('\n');
|
|
107
|
+
process.stdout.write(chalk.bold.white(' Commands') + C.textTertiary(' (Tab to autocomplete)\n\n'));
|
|
108
|
+
for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
|
|
109
|
+
const key = (c + (hint ? ' ' + hint : '')).padEnd(22);
|
|
110
|
+
process.stdout.write(' ' + C.accent(key) + C.textSecondary(desc) + '\n');
|
|
111
|
+
}
|
|
112
|
+
process.stdout.write('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function slashCompleter(line) {
|
|
116
|
+
if (line.startsWith('/')) {
|
|
117
|
+
const hits = SLASH_COMMANDS.map(s => s.cmd + ' ').filter(s => s.startsWith(line));
|
|
118
|
+
return [hits.length ? hits : SLASH_COMMANDS.map(s => s.cmd + ' '), line];
|
|
119
|
+
}
|
|
120
|
+
return [[], line];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── streamChat ────────────────────────────────────────────────────────────────
|
|
38
124
|
export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
39
125
|
const showThinking = opts.thinking !== false;
|
|
40
126
|
const body = JSON.stringify({ messages, context });
|
|
41
127
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
'Content-Type': 'application/json',
|
|
46
|
-
'Accept': 'text/event-stream',
|
|
47
|
-
'Authorization': `Bearer ${cfg.token}`,
|
|
48
|
-
},
|
|
49
|
-
body,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
// Token expired — try refresh once
|
|
53
|
-
if (res.status === 401 && cfg.refresh_token) {
|
|
54
|
-
const updated = await refreshToken(cfg);
|
|
55
|
-
if (!updated) throw new Error('Session expired. Run `icli login` to re-authenticate.');
|
|
56
|
-
Object.assign(cfg, updated);
|
|
57
|
-
res = await fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
|
|
58
|
-
method: 'POST',
|
|
128
|
+
async function doFetch(token) {
|
|
129
|
+
return fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
|
|
130
|
+
method: 'POST',
|
|
59
131
|
headers: {
|
|
60
132
|
'Content-Type': 'application/json',
|
|
61
133
|
'Accept': 'text/event-stream',
|
|
62
|
-
'Authorization': `Bearer ${
|
|
134
|
+
'Authorization': `Bearer ${token}`,
|
|
63
135
|
},
|
|
64
136
|
body,
|
|
65
137
|
});
|
|
66
138
|
}
|
|
67
139
|
|
|
140
|
+
let res = await doFetch(cfg.token);
|
|
141
|
+
if (res.status === 401 && cfg.refresh_token) {
|
|
142
|
+
const updated = await refreshToken(cfg);
|
|
143
|
+
if (!updated) throw new Error('Session expired. Run `icli login`.');
|
|
144
|
+
Object.assign(cfg, updated);
|
|
145
|
+
res = await doFetch(cfg.token);
|
|
146
|
+
}
|
|
68
147
|
if (!res.ok) {
|
|
69
148
|
const txt = await res.text().catch(() => '');
|
|
70
149
|
throw new Error(`AI request failed (HTTP ${res.status}): ${txt.slice(0, 200)}`);
|
|
71
150
|
}
|
|
72
151
|
|
|
73
|
-
// ──
|
|
152
|
+
// ── Render state ───────────────────────────────────────────────────────────
|
|
153
|
+
let reasoningOpen = false; // true while inside a live reasoning block
|
|
154
|
+
let answerBuf = '';
|
|
155
|
+
let rawAnswer = '';
|
|
156
|
+
let finalResult = null;
|
|
157
|
+
|
|
74
158
|
const spinner = ora({
|
|
75
|
-
text:
|
|
76
|
-
color:
|
|
77
|
-
spinner:
|
|
78
|
-
prefixText: '
|
|
159
|
+
text: C.textSecondary('Thinking…'),
|
|
160
|
+
color: 'blue',
|
|
161
|
+
spinner: 'dots',
|
|
162
|
+
prefixText: ' ',
|
|
79
163
|
});
|
|
80
164
|
|
|
81
|
-
|
|
82
|
-
let answerBuf = '';
|
|
83
|
-
let rawAnswer = '';
|
|
84
|
-
let finalResult = null;
|
|
165
|
+
const stopSpinner = () => { if (spinner.isSpinning) spinner.stop(); };
|
|
85
166
|
|
|
86
|
-
function
|
|
87
|
-
if (
|
|
167
|
+
function openReasoning() {
|
|
168
|
+
if (reasoningOpen) return;
|
|
169
|
+
stopSpinner();
|
|
170
|
+
reasoningOpen = true;
|
|
171
|
+
// Copilot CLI style: label then indented content
|
|
172
|
+
process.stdout.write('\n ' + C.textSecondary('Reasoning') + '\n');
|
|
173
|
+
process.stdout.write(C.border(' │ '));
|
|
88
174
|
}
|
|
89
175
|
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
176
|
+
function writeReasonDelta(delta) {
|
|
177
|
+
if (!reasoningOpen) return;
|
|
178
|
+
const parts = delta.split('\n');
|
|
179
|
+
for (let i = 0; i < parts.length; i++) {
|
|
180
|
+
if (i > 0) process.stdout.write('\n' + C.border(' │ '));
|
|
181
|
+
if (parts[i]) process.stdout.write(C.textSecondary(parts[i]));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function closeReasoning() {
|
|
186
|
+
if (!reasoningOpen) return;
|
|
187
|
+
process.stdout.write('\n\n');
|
|
188
|
+
reasoningOpen = false;
|
|
94
189
|
}
|
|
95
190
|
|
|
96
|
-
function
|
|
97
|
-
if (
|
|
191
|
+
function printStatus(line) {
|
|
192
|
+
if (reasoningOpen) closeReasoning();
|
|
98
193
|
stopSpinner();
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
process.stdout.write(chalk.dim(' └' + '─'.repeat(57)) + '\n');
|
|
106
|
-
reasonBuf = '';
|
|
194
|
+
const icon = statusIcon(line);
|
|
195
|
+
const text = line.trimStart();
|
|
196
|
+
const indent = /^[\s·•⌁]/.test(line) ? ' ' : ' ';
|
|
197
|
+
process.stdout.write(indent + icon + ' ' + C.textSecondary(text) + '\n');
|
|
198
|
+
if (showThinking) spinner.start();
|
|
107
199
|
}
|
|
108
200
|
|
|
109
201
|
function parseStreamText(raw) {
|
|
110
202
|
const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)/);
|
|
111
203
|
if (!m) return raw.trimStart().startsWith('{') ? null : raw;
|
|
112
|
-
return m[1]
|
|
113
|
-
|
|
114
|
-
.replace(/\\r/g, '').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
204
|
+
return m[1].replace(/\\n/g,'\n').replace(/\\t/g,'\t')
|
|
205
|
+
.replace(/\\r/g,'').replace(/\\"/g,'"').replace(/\\\\/g,'\\');
|
|
115
206
|
}
|
|
116
207
|
|
|
117
|
-
// ── SSE reader
|
|
208
|
+
// ── SSE reader ─────────────────────────────────────────────────────────────
|
|
118
209
|
if (showThinking) spinner.start();
|
|
119
|
-
|
|
120
210
|
const decoder = new TextDecoder();
|
|
121
211
|
let sseBuffer = '';
|
|
122
212
|
|
|
@@ -126,16 +216,18 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
|
126
216
|
sseBuffer = lines.pop();
|
|
127
217
|
|
|
128
218
|
for (const line of lines) {
|
|
129
|
-
const
|
|
130
|
-
if (!
|
|
131
|
-
let ev;
|
|
132
|
-
try { ev = JSON.parse(trimmed.slice(6)); } catch { continue; }
|
|
219
|
+
const t = line.replace(/\r$/, '');
|
|
220
|
+
if (!t.startsWith('data: ')) continue;
|
|
221
|
+
let ev; try { ev = JSON.parse(t.slice(6)); } catch { continue; }
|
|
133
222
|
|
|
134
223
|
if (ev.status_delta !== undefined) {
|
|
135
224
|
printStatus(ev.status_delta);
|
|
136
225
|
} else if (ev.reason_delta !== undefined) {
|
|
137
|
-
|
|
226
|
+
if (!reasoningOpen) openReasoning(); // re-open after STATUS closes it
|
|
227
|
+
writeReasonDelta(ev.reason_delta);
|
|
138
228
|
} else if (ev.delta !== undefined) {
|
|
229
|
+
if (reasoningOpen) closeReasoning();
|
|
230
|
+
stopSpinner();
|
|
139
231
|
rawAnswer += ev.delta;
|
|
140
232
|
const txt = parseStreamText(rawAnswer);
|
|
141
233
|
if (txt !== null) answerBuf = txt;
|
|
@@ -145,73 +237,56 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
|
145
237
|
}
|
|
146
238
|
}
|
|
147
239
|
|
|
148
|
-
// ── Final render
|
|
240
|
+
// ── Final render ───────────────────────────────────────────────────────────
|
|
149
241
|
stopSpinner();
|
|
150
|
-
|
|
242
|
+
if (reasoningOpen) closeReasoning();
|
|
151
243
|
|
|
152
244
|
const reply = finalResult?.reply ?? {};
|
|
153
245
|
const replyType = reply.type ?? 'message';
|
|
246
|
+
const meta = finalResult?._meta ?? {};
|
|
154
247
|
|
|
155
|
-
|
|
248
|
+
// Save model to cfg for banner re-use
|
|
249
|
+
if (meta.model && cfg) cfg._model = shortModel(meta.model);
|
|
250
|
+
|
|
251
|
+
// Content separator (same as Copilot CLI visual spacing)
|
|
252
|
+
process.stdout.write('\n');
|
|
156
253
|
|
|
157
254
|
if (replyType === 'message') {
|
|
158
255
|
const text = reply.text || answerBuf || '(no response)';
|
|
159
|
-
|
|
160
|
-
const indented = rendered.split('\n').map(l => ' ' + l).join('\n');
|
|
161
|
-
process.stdout.write(indented + '\n\n');
|
|
162
|
-
return text;
|
|
256
|
+
process.stdout.write(renderMarkdown(text).split('\n').map(l => ' ' + l).join('\n') + '\n');
|
|
163
257
|
|
|
164
258
|
} else if (replyType === 'plan') {
|
|
165
|
-
process.stdout.write(' ' + chalk.bold.
|
|
166
|
-
(reply.
|
|
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`));
|
|
170
|
-
});
|
|
259
|
+
process.stdout.write(' ' + chalk.bold.white('Plan') + '\n');
|
|
260
|
+
if (reply.summary) process.stdout.write(' ' + C.textSecondary(reply.summary) + '\n');
|
|
171
261
|
process.stdout.write('\n');
|
|
172
|
-
|
|
262
|
+
(reply.steps || []).forEach((s, i) => {
|
|
263
|
+
const risk = s.risk === 'high' ? C.statusErr(' ⚠ high risk') :
|
|
264
|
+
s.risk === 'medium' ? C.statusWarn(' ⚡') : '';
|
|
265
|
+
process.stdout.write(` ${C.textSecondary(String(i+1)+'.')} ${chalk.yellow(s.cmd || '')}${risk}\n`);
|
|
266
|
+
if (s.purpose) process.stdout.write(C.textTertiary(` ${s.purpose}\n`));
|
|
267
|
+
});
|
|
268
|
+
process.stdout.write('\n' + C.textSecondary(' Reply ') + chalk.white('apply fix') + C.textSecondary(' to confirm.\n'));
|
|
173
269
|
|
|
174
270
|
} else if (replyType === 'connect') {
|
|
175
271
|
const dev = reply.device ?? {};
|
|
176
272
|
process.stdout.write(
|
|
177
|
-
' ' +
|
|
178
|
-
chalk.bold.
|
|
179
|
-
chalk.white(
|
|
180
|
-
|
|
273
|
+
' ' + C.statusOk('🔌 ') +
|
|
274
|
+
chalk.bold.white(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) + ' ' +
|
|
275
|
+
chalk.white(dev.name ?? '') + ' ' +
|
|
276
|
+
C.textSecondary(`(${dev.host ?? ''})`) + '\n'
|
|
181
277
|
);
|
|
182
|
-
if (reply.note) process.stdout.write(
|
|
183
|
-
process.stdout.write('\n');
|
|
184
|
-
return reply.note || '';
|
|
278
|
+
if (reply.note) process.stdout.write(C.textSecondary(' ' + reply.note) + '\n');
|
|
185
279
|
}
|
|
186
280
|
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Print a styled welcome banner. */
|
|
191
|
-
export function printBanner(cfg) {
|
|
192
|
-
const line = '─'.repeat(58);
|
|
281
|
+
// ── Footer — matches Copilot CLI status bar format: model · user · ... ──────
|
|
193
282
|
process.stdout.write('\n');
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
'
|
|
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');
|
|
283
|
+
const footParts = [];
|
|
284
|
+
if (meta.model) footParts.push(shortModel(meta.model));
|
|
285
|
+
if (meta.user) footParts.push(meta.user);
|
|
286
|
+
if (meta.role) footParts.push(meta.role);
|
|
287
|
+
if (footParts.length) {
|
|
288
|
+
process.stdout.write(C.textTertiary(' ' + footParts.join(' · ')) + '\n');
|
|
206
289
|
}
|
|
207
|
-
|
|
208
|
-
|
|
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');
|
|
290
|
+
|
|
291
|
+
return reply.text || answerBuf || '';
|
|
217
292
|
}
|