ispbills-icli 1.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/README.md +154 -0
- package/bin/icli.js +143 -0
- package/package.json +22 -0
- package/src/auth.js +55 -0
- package/src/chat.js +181 -0
- package/src/config.js +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# iCli — IspBills AI Terminal
|
|
2
|
+
|
|
3
|
+
**iCli** is a command-line interface for [IspBills](https://github.com/lupael/IspBills) that brings your iCopilot AI network engineer to any terminal.
|
|
4
|
+
|
|
5
|
+
Ask it anything your NOC team would ask:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
icli "is vlan 82 in use across my network?"
|
|
9
|
+
icli "show all ONUs on olt#1"
|
|
10
|
+
icli "which customer is using IP 10.10.1.50?"
|
|
11
|
+
icli "check high optical signal on main OLT"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Node.js 18 or later
|
|
19
|
+
- A running IspBills instance
|
|
20
|
+
- An operator account (group admin, operator, or NOC user)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Quick start (npx — no install)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx ispbills-icli
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
On first run iCli will ask for your instance URL, email, and password. Credentials are saved to `~/.config/ispbills-icli/config.json` (mode 600 — readable only by you).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Permanent install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install -g ispbills-icli
|
|
38
|
+
icli
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Interactive REPL
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
$ icli
|
|
49
|
+
|
|
50
|
+
iCli — IspBills AI terminal
|
|
51
|
+
Connected to: https://app.myisp.com
|
|
52
|
+
|
|
53
|
+
Type your question. Press Ctrl+C or type "exit" to quit.
|
|
54
|
+
|
|
55
|
+
iCli › is vlan 82 free on all devices?
|
|
56
|
+
|
|
57
|
+
› 🧭 Assigning 3 sub-agents…
|
|
58
|
+
› ✅ [1/3] nas#1: FREE
|
|
59
|
+
› ✅ [2/3] nas#2: FREE
|
|
60
|
+
› ✅ [3/3] olt#1: FREE
|
|
61
|
+
|
|
62
|
+
VLAN 82 is free across all 3 reachable devices in your fleet.
|
|
63
|
+
|
|
64
|
+
iCli › exit
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Single-shot query
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
icli "check uptime on all routers"
|
|
71
|
+
icli "show customers with overdue invoices"
|
|
72
|
+
icli "what is the RX power on olt#1?"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Commands
|
|
76
|
+
|
|
77
|
+
| Command | Description |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `icli` | Start interactive REPL |
|
|
80
|
+
| `icli "question"` | Single question, then exit |
|
|
81
|
+
| `icli login` | Re-authenticate (or switch instance) |
|
|
82
|
+
| `icli whoami` | Show current login info |
|
|
83
|
+
| `icli --help` | Show help |
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## What iCli can do
|
|
88
|
+
|
|
89
|
+
iCli connects to the same AI engine as the in-browser terminal:
|
|
90
|
+
|
|
91
|
+
- **Fleet-wide checks** — asks about all devices at once with parallel sub-agents
|
|
92
|
+
- **Live device data** — reads VLAN tables, ONU lists, optical power, ARP, PPPoE sessions
|
|
93
|
+
- **Customer lookup** — find customers by name, IP, MAC, username, or invoice
|
|
94
|
+
- **Config audits** — review running config and get concrete suggestions
|
|
95
|
+
- **Web search** — looks up vendor CLI docs when it needs to
|
|
96
|
+
- **Streaming output** — answers stream token-by-token with a live thinking indicator
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## API endpoints (for integrators)
|
|
101
|
+
|
|
102
|
+
iCli uses two endpoints added to your IspBills instance:
|
|
103
|
+
|
|
104
|
+
| Method | URL | Description |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `POST` | `/api/v1/auth/login` | Get Bearer token (`username` + `password`) |
|
|
107
|
+
| `POST` | `/api/v1/ai/chat` | Non-streaming JSON response |
|
|
108
|
+
| `POST` | `/api/v1/ai/chat/stream` | SSE stream (`delta` / `reason_delta` / `status_delta` / `done` events) |
|
|
109
|
+
|
|
110
|
+
**Authentication:** `Authorization: Bearer <token>` on all `/api/v1/ai/*` requests.
|
|
111
|
+
|
|
112
|
+
**Request body:**
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"messages": [
|
|
116
|
+
{ "role": "user", "content": "is vlan 82 in use?" }
|
|
117
|
+
],
|
|
118
|
+
"context": {
|
|
119
|
+
"vendor": "auto",
|
|
120
|
+
"connected": false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**SSE events:**
|
|
126
|
+
```
|
|
127
|
+
data: {"delta": "VLAN 82 is..."} ← streaming answer tokens
|
|
128
|
+
data: {"reason_delta": "checking..."} ← AI reasoning (dim, italic)
|
|
129
|
+
data: {"status_delta": "✅ [1/3]..."} ← sub-agent progress
|
|
130
|
+
data: {"done": true, "result": {...}} ← final result object
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Configuration
|
|
136
|
+
|
|
137
|
+
Config is stored at `~/.config/ispbills-icli/config.json`:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"url": "https://app.myisp.com",
|
|
142
|
+
"token": "...",
|
|
143
|
+
"refresh_token": "...",
|
|
144
|
+
"user": { "id": 1, "name": "Admin", "email": "admin@myisp.com", "role": "group_admin" }
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Tokens refresh automatically when they expire.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT — © IspBills
|
package/bin/icli.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as readline from 'readline/promises';
|
|
3
|
+
import { stdin as input, stdout as output, argv, exit } from 'process';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { loadConfig, configPath } from '../src/config.js';
|
|
6
|
+
import { loginFlow } from '../src/auth.js';
|
|
7
|
+
import { streamChat } from '../src/chat.js';
|
|
8
|
+
|
|
9
|
+
// ─── Parse args ─────────────────────────────────────────────────────────────
|
|
10
|
+
const args = argv.slice(2);
|
|
11
|
+
const cmd = args[0] ?? '';
|
|
12
|
+
|
|
13
|
+
// ─── Help ────────────────────────────────────────────────────────────────────
|
|
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
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Ensure authenticated ────────────────────────────────────────────────────
|
|
38
|
+
async function ensureAuth() {
|
|
39
|
+
let cfg = loadConfig();
|
|
40
|
+
if (!cfg) {
|
|
41
|
+
console.log(chalk.yellow('No saved login found. Let\'s set up iCli.\n'));
|
|
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`));
|
|
45
|
+
}
|
|
46
|
+
return cfg;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Single-shot query ───────────────────────────────────────────────────────
|
|
50
|
+
async function singleShot(cfg, question) {
|
|
51
|
+
const messages = [{ role: 'user', content: question }];
|
|
52
|
+
try {
|
|
53
|
+
await streamChat(cfg, messages);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error(chalk.red('\n✖ ' + e.message));
|
|
56
|
+
exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Interactive REPL ────────────────────────────────────────────────────────
|
|
61
|
+
async function interactiveRepl(cfg) {
|
|
62
|
+
const history = [];
|
|
63
|
+
const rl = readline.createInterface({
|
|
64
|
+
input, output,
|
|
65
|
+
prompt: chalk.cyan('iCli') + chalk.dim(' › '),
|
|
66
|
+
});
|
|
67
|
+
|
|
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
|
+
rl.prompt();
|
|
74
|
+
|
|
75
|
+
rl.on('line', async (line) => {
|
|
76
|
+
const q = line.trim();
|
|
77
|
+
if (!q) { rl.prompt(); return; }
|
|
78
|
+
if (q === 'exit' || q === 'quit') { rl.close(); return; }
|
|
79
|
+
if (q === 'clear') { console.clear(); rl.prompt(); return; }
|
|
80
|
+
if (q === 'history') {
|
|
81
|
+
history.forEach((m, i) => {
|
|
82
|
+
if (m.role === 'user') console.log(chalk.dim(` ${i}: `) + m.content.slice(0, 80));
|
|
83
|
+
});
|
|
84
|
+
rl.prompt();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
rl.pause();
|
|
89
|
+
history.push({ role: 'user', content: q });
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const answer = await streamChat(cfg, [...history]);
|
|
93
|
+
if (answer) history.push({ role: 'assistant', content: answer });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error(chalk.red('\n✖ ' + e.message + '\n'));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
rl.resume();
|
|
99
|
+
rl.prompt();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
rl.on('close', () => {
|
|
103
|
+
console.log(chalk.dim('\n Goodbye.\n'));
|
|
104
|
+
exit(0);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
109
|
+
async function main() {
|
|
110
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
111
|
+
printHelp(); return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (cmd === 'login') {
|
|
115
|
+
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`));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (cmd === 'whoami') {
|
|
122
|
+
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`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cfg = await ensureAuth();
|
|
132
|
+
|
|
133
|
+
// Single-shot: icli "question here"
|
|
134
|
+
if (cmd && !cmd.startsWith('-')) {
|
|
135
|
+
await singleShot(cfg, args.join(' '));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Interactive REPL
|
|
140
|
+
await interactiveRepl(cfg);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
main().catch(e => { console.error(chalk.red('✖ ' + e.message)); exit(1); });
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ispbills-icli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "iCli — IspBills AI network engineer in your terminal",
|
|
5
|
+
"keywords": ["ispbills", "network", "isp", "ai", "cli", "noc"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"icli": "./bin/icli.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18.0.0"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"src/",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"chalk": "^5.3.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as readline from 'readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'process';
|
|
3
|
+
import { saveConfig } from './config.js';
|
|
4
|
+
|
|
5
|
+
export async function loginFlow(existingUrl = '') {
|
|
6
|
+
const rl = readline.createInterface({ input, output });
|
|
7
|
+
|
|
8
|
+
console.log('\n🔌 iCli — IspBills AI terminal\n');
|
|
9
|
+
|
|
10
|
+
let url = existingUrl;
|
|
11
|
+
if (!url) {
|
|
12
|
+
url = (await rl.question('IspBills instance URL (e.g. https://app.myisp.com): ')).trim();
|
|
13
|
+
}
|
|
14
|
+
url = url.replace(/\/$/, '');
|
|
15
|
+
|
|
16
|
+
const email = (await rl.question('Email: ')).trim();
|
|
17
|
+
const password = await rl.question('Password: ');
|
|
18
|
+
|
|
19
|
+
rl.close();
|
|
20
|
+
|
|
21
|
+
const res = await fetch(`${url}/api/v1/auth/login`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
24
|
+
body: JSON.stringify({ username: email, password }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const err = await res.json().catch(() => ({}));
|
|
29
|
+
throw new Error(err.message || `Login failed (HTTP ${res.status})`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
const cfg = {
|
|
34
|
+
url,
|
|
35
|
+
token: data.token,
|
|
36
|
+
refresh_token: data.refresh_token,
|
|
37
|
+
user: data.user,
|
|
38
|
+
};
|
|
39
|
+
saveConfig(cfg);
|
|
40
|
+
return cfg;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function refreshToken(cfg) {
|
|
44
|
+
const res = await fetch(`${cfg.url}/api/v1/auth/refresh`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ refresh_token: cfg.refresh_token }),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) return null;
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
cfg.token = data.token;
|
|
52
|
+
cfg.refresh_token = data.refresh_token;
|
|
53
|
+
saveConfig(cfg);
|
|
54
|
+
return cfg;
|
|
55
|
+
}
|
package/src/chat.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { refreshToken } from './auth.js';
|
|
3
|
+
|
|
4
|
+
const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stream a single chat turn to stdout.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} cfg - { url, token, refresh_token, user }
|
|
10
|
+
* @param {Array} messages - [{ role, content }]
|
|
11
|
+
* @param {object} [context] - optional device context
|
|
12
|
+
* @param {object} [opts] - { thinking: bool }
|
|
13
|
+
* @returns {string} The final answer text
|
|
14
|
+
*/
|
|
15
|
+
export async function streamChat(cfg, messages, context = {}, opts = {}) {
|
|
16
|
+
const showThinking = opts.thinking !== false;
|
|
17
|
+
|
|
18
|
+
const body = JSON.stringify({ messages, context });
|
|
19
|
+
|
|
20
|
+
let res = await fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: {
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
'Accept': 'text/event-stream',
|
|
25
|
+
'Authorization': `Bearer ${cfg.token}`,
|
|
26
|
+
},
|
|
27
|
+
body,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Token expired — try refresh once
|
|
31
|
+
if (res.status === 401 && cfg.refresh_token) {
|
|
32
|
+
const updated = await refreshToken(cfg);
|
|
33
|
+
if (!updated) throw new Error('Session expired. Run `icli login` to re-authenticate.');
|
|
34
|
+
Object.assign(cfg, updated);
|
|
35
|
+
res = await fetch(`${cfg.url}/api/v1/ai/chat/stream`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
'Accept': 'text/event-stream',
|
|
40
|
+
'Authorization': `Bearer ${cfg.token}`,
|
|
41
|
+
},
|
|
42
|
+
body,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const txt = await res.text().catch(() => '');
|
|
48
|
+
throw new Error(`AI request failed (HTTP ${res.status}): ${txt.slice(0, 200)}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
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
|
+
}
|
|
67
|
+
|
|
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);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function printStatus(line) {
|
|
78
|
+
clearSpinner();
|
|
79
|
+
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;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
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');
|
|
94
|
+
reasonBuf = '';
|
|
95
|
+
lastWasNewline = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseStreamText(raw) {
|
|
99
|
+
const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)$/);
|
|
100
|
+
if (!m) return raw.trimStart().startsWith('{') ? null : raw;
|
|
101
|
+
return m[1]
|
|
102
|
+
.replace(/\\n/g, '\n').replace(/\\t/g, '\t')
|
|
103
|
+
.replace(/\\r/g, '').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── SSE reader ───────────────────────────────────────────────────────────
|
|
107
|
+
if (showThinking) startSpinner();
|
|
108
|
+
|
|
109
|
+
const decoder = new TextDecoder();
|
|
110
|
+
let sseBuffer = '';
|
|
111
|
+
let finalResult = null;
|
|
112
|
+
let rawAnswer = '';
|
|
113
|
+
|
|
114
|
+
for await (const chunk of res.body) {
|
|
115
|
+
sseBuffer += decoder.decode(chunk, { stream: true });
|
|
116
|
+
const lines = sseBuffer.split('\n');
|
|
117
|
+
sseBuffer = lines.pop(); // keep incomplete line in buffer
|
|
118
|
+
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
const trimmed = line.replace(/\r$/, '');
|
|
121
|
+
if (!trimmed.startsWith('data: ')) continue;
|
|
122
|
+
let ev;
|
|
123
|
+
try { ev = JSON.parse(trimmed.slice(6)); } catch { continue; }
|
|
124
|
+
|
|
125
|
+
if (ev.status_delta !== undefined) {
|
|
126
|
+
printStatus(ev.status_delta);
|
|
127
|
+
|
|
128
|
+
} else if (ev.reason_delta !== undefined) {
|
|
129
|
+
printReasoning(ev.reason_delta);
|
|
130
|
+
|
|
131
|
+
} else if (ev.delta !== undefined) {
|
|
132
|
+
rawAnswer += ev.delta;
|
|
133
|
+
const txt = parseStreamText(rawAnswer);
|
|
134
|
+
if (txt !== null) answerBuf = txt;
|
|
135
|
+
|
|
136
|
+
} else if (ev.done) {
|
|
137
|
+
finalResult = ev.result;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Final render ─────────────────────────────────────────────────────────
|
|
143
|
+
clearSpinner();
|
|
144
|
+
flushReasoning();
|
|
145
|
+
|
|
146
|
+
const reply = finalResult?.reply ?? {};
|
|
147
|
+
const replyType = reply.type ?? 'message';
|
|
148
|
+
|
|
149
|
+
if (replyType === 'message') {
|
|
150
|
+
const text = reply.text || answerBuf || '(no response)';
|
|
151
|
+
if (!lastWasNewline) process.stdout.write('\n');
|
|
152
|
+
process.stdout.write('\n' + chalk.white(text) + '\n\n');
|
|
153
|
+
return text;
|
|
154
|
+
|
|
155
|
+
} else if (replyType === 'plan') {
|
|
156
|
+
process.stdout.write('\n');
|
|
157
|
+
process.stdout.write(chalk.bold.cyan('📋 Plan: ') + chalk.cyan(reply.summary || '') + '\n');
|
|
158
|
+
(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`));
|
|
164
|
+
});
|
|
165
|
+
process.stdout.write('\n');
|
|
166
|
+
return reply.summary || '';
|
|
167
|
+
|
|
168
|
+
} else if (replyType === 'connect') {
|
|
169
|
+
const dev = reply.device ?? {};
|
|
170
|
+
process.stdout.write('\n');
|
|
171
|
+
process.stdout.write(
|
|
172
|
+
chalk.bold.green('🔌 Connect: ') +
|
|
173
|
+
chalk.green(`${dev.kind ?? '?'}#${dev.id ?? '?'} ${dev.name ?? ''} (${dev.host ?? ''})`) + '\n'
|
|
174
|
+
);
|
|
175
|
+
if (reply.note) process.stdout.write(chalk.dim(' ' + reply.note) + '\n');
|
|
176
|
+
process.stdout.write('\n');
|
|
177
|
+
return reply.note || '';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return answerBuf;
|
|
181
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), '.config', 'ispbills-icli');
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
export function loadConfig() {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveConfig(cfg) {
|
|
17
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
18
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function configPath() {
|
|
22
|
+
return CONFIG_FILE;
|
|
23
|
+
}
|