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 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
+ }