ispbills-icli 8.2.0 → 8.3.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.
package/README.md CHANGED
@@ -146,10 +146,29 @@ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
146
146
  |---|---|
147
147
  | `/mac [addr]` | Locate a MAC address on the network |
148
148
  | `/arp [device]` · `/route [device]` | ARP / routing tables |
149
+ | `/overlaps [ip/vlan/bgp/route]` | Find overlapping IP subnets, VLAN ids, interface assignments, BGP prefixes or routes |
150
+ | `/flapping [device]` | List flapping interfaces (frequent up/down transitions) |
149
151
  | `/dhcp` | DHCP leases and pool usage |
150
152
  | `/ping [host]` · `/trace [host]` | Ping / traceroute a host |
151
153
  | `/customer [name/IP]` | Look up a customer |
152
154
 
155
+ **Backups & version control**
156
+
157
+ | Slash | Description |
158
+ |---|---|
159
+ | `/backup [device]` | Back up device config (all devices if omitted). Each answer is archived to your per-user git repo |
160
+ | `/git [args]` | Run git in that backup repo — e.g. `/git status`, `/git log`, `/git commit -m "note"`, `/git remote add origin <url>`, `/git push` |
161
+ | `/gist` | Save the last answer or the whole transcript to a **secret** GitHub gist (uses the `gh` CLI, or `/gist token <TOKEN>`) |
162
+
163
+ **Memory**
164
+
165
+ | Slash | Description |
166
+ |---|---|
167
+ | `/remember <note>` | Save a standing note the AI keeps in mind on every turn |
168
+ | `/memory` | List your saved memory notes |
169
+ | `/forget <n/all>` | Delete a note by number, matching text, or `all` |
170
+ | `/copy` | Copy the last AI answer to the system clipboard |
171
+
153
172
  **Session**
154
173
 
155
174
  | Slash | Description |
@@ -168,6 +187,8 @@ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
168
187
  | **/** then **↑/↓** | Open the slash menu and move the selection; **Tab** / **→** completes, **Enter** runs |
169
188
  | **@** | Mention a device/entity (e.g. `@olt:`, `@onu:`); **Tab** / **→** completes the tag |
170
189
  | **↑/↓** (empty input) | Navigate input history |
190
+ | **Ctrl+Z** / **Ctrl+Y** | Undo / redo the input line |
191
+ | **Paste** | Multi-line pastes (incl. mouse right-click) keep their line breaks and are never auto-submitted — press **Enter** to send |
171
192
  | **Shift+Tab** | Toggle autopilot mode |
172
193
  | **Ctrl+T** | Toggle reasoning display |
173
194
  | **Ctrl+L** | Clear the transcript |
@@ -201,6 +222,10 @@ iCli connects to the same AI engine as the in-browser terminal:
201
222
  - **Rich rendering** — Markdown tables, colored lists, headings, and code blocks; `@`-mentions are highlighted; long lists render as aligned tables
202
223
  - **Interactive prompts** — when the AI needs a decision, it shows a selectable choice list (↑/↓, **Enter**) with an optional free-text answer (**Tab**)
203
224
  - **Plans & connect cards** — proposed fixes stream in live as a reviewable plan (`apply fix` to confirm, or use the Apply/Explain/Cancel prompt); device connections show as a connect card
225
+ - **Bot memory** — `/remember` saves standing operator notes that are injected into every answer (`/memory`, `/forget` to manage)
226
+ - **Backups in git** — `/backup` archives config to a per-user git repo; run `/git status|log|commit|push` from the prompt
227
+ - **Share as a gist** — `/gist` publishes the last answer or transcript to a secret GitHub gist
228
+ - **Copy & paste** — `/copy` puts the last answer on the clipboard; multi-line pastes (mouse right-click or Cmd/Ctrl+V) keep their line breaks, and **Ctrl+Z / Ctrl+Y** undo/redo work in the input
204
229
  - **Full-screen terminal UI** — a full-height alternate-screen TUI (built on [Ink](https://github.com/vadimdemedes/ink)) with the banner pinned to the top, a bottom-pinned bordered input, a selectable slash-command menu, input history, and a bottom-anchored transcript — the same layout as GitHub Copilot CLI / Gemini CLI. Piped/non-interactive use falls back to plain streaming output
205
230
  - **Terminal-aware rendering** — the layout reflows to your terminal width and resizes live; on classic Windows consoles (`cmd.exe` / legacy PowerShell) it auto-switches to ASCII-safe glyphs and borders so nothing shows as broken boxes
206
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.2.0",
3
+ "version": "8.3.1",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
@@ -0,0 +1,42 @@
1
+ // Cross-platform "copy to system clipboard" helper. Best-effort: resolves to
2
+ // false (rather than throwing) when no clipboard tool is available so the TUI
3
+ // can show a friendly notice.
4
+ import { spawn } from 'child_process';
5
+
6
+ function candidates() {
7
+ if (process.platform === 'darwin') return [['pbcopy', []]];
8
+ if (process.platform === 'win32') return [['clip', []]];
9
+ // Linux / BSD: prefer Wayland, then X11 tools.
10
+ return [
11
+ ['wl-copy', []],
12
+ ['xclip', ['-selection', 'clipboard']],
13
+ ['xsel', ['--clipboard', '--input']],
14
+ ];
15
+ }
16
+
17
+ function tryCopy(cmd, args, text) {
18
+ return new Promise((resolve) => {
19
+ let child;
20
+ try {
21
+ child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
22
+ } catch {
23
+ resolve(false);
24
+ return;
25
+ }
26
+ child.on('error', () => resolve(false));
27
+ child.on('close', (code) => resolve(code === 0));
28
+ try {
29
+ child.stdin.end(text);
30
+ } catch {
31
+ resolve(false);
32
+ }
33
+ });
34
+ }
35
+
36
+ /** Copy `text` to the system clipboard. Returns the tool name on success, else null. */
37
+ export async function copyToClipboard(text) {
38
+ for (const [cmd, args] of candidates()) {
39
+ if (await tryCopy(cmd, args, text)) return cmd;
40
+ }
41
+ return null;
42
+ }
package/src/commands.js CHANGED
@@ -34,11 +34,23 @@ export const SLASH_COMMANDS = [
34
34
  { cmd: '/mac', hint: '[addr]', desc: 'Locate a MAC address on the network', group: 'Lookup' },
35
35
  { cmd: '/arp', hint: '[device]', desc: 'ARP table for a device', group: 'Lookup' },
36
36
  { cmd: '/route', hint: '[device]', desc: 'Routing table for a device', group: 'Lookup' },
37
+ { cmd: '/overlaps', hint: '[ip/vlan/bgp/route]', desc: 'Find overlapping IP, VLAN, interface, BGP or routes', group: 'Lookup' },
38
+ { cmd: '/flapping', hint: '[device]', desc: 'List flapping interfaces (frequent up/down)', group: 'Lookup' },
37
39
  { cmd: '/dhcp', hint: '', desc: 'DHCP leases and pool usage', group: 'Lookup' },
38
40
  { cmd: '/ping', hint: '[host]', desc: 'Ping a host through the network', group: 'Lookup' },
39
41
  { cmd: '/trace', hint: '[host]', desc: 'Traceroute the path to a host', group: 'Lookup' },
40
42
  { cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer', group: 'Lookup' },
41
43
 
44
+ // ── Backups & version control ──
45
+ { cmd: '/git', hint: '[args]', desc: 'Run git in your backup repo (status/commit/push/log)', group: 'Backups', local: true },
46
+ { cmd: '/gist', hint: '', desc: 'Save the last answer or transcript to a GitHub gist', group: 'Backups', local: true },
47
+
48
+ // ── Memory ──
49
+ { cmd: '/remember', hint: '<note>', desc: 'Save a standing note the AI keeps in mind', group: 'Memory', local: true },
50
+ { cmd: '/memory', hint: '', desc: 'List saved memory notes', group: 'Memory', local: true },
51
+ { cmd: '/forget', hint: '[n/all]', desc: 'Delete a memory note (by number or all)', group: 'Memory', local: true },
52
+ { cmd: '/copy', hint: '', desc: 'Copy the last AI answer to the clipboard', group: 'Memory', local: true },
53
+
42
54
  // ── Session & app ──
43
55
  { cmd: '/autopilot', hint: '', desc: 'Toggle autopilot (auto-apply fixes)', group: 'Session', local: true },
44
56
  { cmd: '/model', hint: '[name]', desc: 'Show or set the AI model', group: 'Session', local: true },
@@ -106,6 +118,16 @@ export function slashToQuestion(line) {
106
118
  case '/mac': return on(`locate MAC address ${arg} — which device and port is it on?`, 'how do I locate a MAC address? ask me for the address');
107
119
  case '/arp': return on(`show the ARP table for ${arg}`, 'show ARP tables and flag any duplicate or suspicious entries');
108
120
  case '/route': return on(`show the routing table for ${arg}`, 'show routing tables across the network');
121
+ case '/overlaps': {
122
+ const t = arg.toLowerCase();
123
+ if (/vlan/.test(t)) return 'scan all devices for duplicate or overlapping VLAN ids and list each conflict with the devices/interfaces involved';
124
+ if (/bgp|prefix|as\b|peer/.test(t)) return 'check BGP for overlapping or conflicting prefixes and duplicate AS announcements across routers and list each conflict';
125
+ if (/route|routing/.test(t)) return 'find overlapping or conflicting routes across the routing tables and list each pair with the devices involved';
126
+ if (/int|port/.test(t)) return 'find interfaces/ports with overlapping or duplicate address or VLAN assignments and list each conflict';
127
+ if (/ip|subnet|network|cidr/.test(t)) return 'find overlapping or conflicting IP subnets and duplicate IP addresses across the network and list each conflict with the devices involved';
128
+ return 'scan the whole network for any overlapping or conflicting IP subnets, duplicate IPs, duplicate/overlapping VLAN ids, interface assignment conflicts, and overlapping BGP prefixes or routes — list each conflict grouped by type with the devices involved';
129
+ }
130
+ case '/flapping': return on(`check ${arg} for flapping interfaces — list ports with frequent up/down transitions, their flap counts and last change time`, 'scan all devices for flapping interfaces (frequent up/down transitions) and list them with flap counts and the last state-change time');
109
131
  case '/dhcp': return 'show DHCP leases and pool usage, and flag pools that are nearly exhausted';
110
132
  case '/ping': return on(`ping ${arg} through the network and report latency and loss`, 'which hosts should I ping? list reachable devices');
111
133
  case '/trace': return on(`traceroute the path to ${arg} and show each hop`, 'what host should I traceroute?');
package/src/gist.js ADDED
@@ -0,0 +1,90 @@
1
+ // Create GitHub gists from the terminal. Prefers the `gh` CLI (uses its stored
2
+ // auth); falls back to the REST API with a token from the icli config
3
+ // (cfg.github_token) or the GITHUB_TOKEN / GH_TOKEN environment variables.
4
+ import { spawn } from 'child_process';
5
+ import { mkdtempSync, writeFileSync, rmSync } from 'fs';
6
+ import { tmpdir } from 'os';
7
+ import { join } from 'path';
8
+
9
+ function sh(cmd, args, { input } = {}) {
10
+ return new Promise((resolve) => {
11
+ let child;
12
+ try {
13
+ child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
14
+ } catch (e) {
15
+ resolve({ code: 127, stdout: '', stderr: String(e.message || e) });
16
+ return;
17
+ }
18
+ let stdout = '', stderr = '';
19
+ child.stdout.on('data', (d) => (stdout += d));
20
+ child.stderr.on('data', (d) => (stderr += d));
21
+ child.on('error', (e) => resolve({ code: 127, stdout, stderr: String(e.message || e) }));
22
+ child.on('close', (code) => resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() }));
23
+ if (input != null) { try { child.stdin.end(input); } catch {} }
24
+ else child.stdin.end();
25
+ });
26
+ }
27
+
28
+ async function hasGh() {
29
+ const res = await sh('gh', ['--version']);
30
+ return res.code === 0;
31
+ }
32
+
33
+ function token(cfg) {
34
+ return cfg?.github_token || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
35
+ }
36
+
37
+ /** True if any GitHub auth is available (gh CLI or a token). */
38
+ export async function canGist(cfg) {
39
+ if (token(cfg)) return true;
40
+ return hasGh();
41
+ }
42
+
43
+ async function gistViaGh({ filename, content, description, isPublic }) {
44
+ const dir = mkdtempSync(join(tmpdir(), 'icli-gist-'));
45
+ const path = join(dir, filename);
46
+ try {
47
+ writeFileSync(path, content);
48
+ const args = ['gist', 'create', path, '-d', description];
49
+ if (isPublic) args.push('--public');
50
+ const res = await sh('gh', args);
51
+ if (res.code !== 0) throw new Error(res.stderr || 'gh gist create failed');
52
+ const url = (res.stdout.match(/https?:\/\/\S+/) || [res.stdout])[0];
53
+ return { url };
54
+ } finally {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ }
57
+ }
58
+
59
+ async function gistViaApi({ filename, content, description, isPublic }, tok) {
60
+ const res = await fetch('https://api.github.com/gists', {
61
+ method: 'POST',
62
+ headers: {
63
+ Authorization: 'Bearer ' + tok,
64
+ Accept: 'application/vnd.github+json',
65
+ 'Content-Type': 'application/json',
66
+ 'User-Agent': 'ispbills-icli',
67
+ },
68
+ body: JSON.stringify({ description, public: !!isPublic, files: { [filename]: { content } } }),
69
+ });
70
+ if (!res.ok) {
71
+ const txt = await res.text().catch(() => '');
72
+ throw new Error(`GitHub API ${res.status}: ${txt.slice(0, 160)}`);
73
+ }
74
+ const data = await res.json();
75
+ return { url: data.html_url };
76
+ }
77
+
78
+ /**
79
+ * Create a gist. `opts` = { filename, content, description, isPublic }.
80
+ * Returns { url }. Throws with a helpful message when no auth is available.
81
+ */
82
+ export async function createGist(cfg, opts) {
83
+ const o = { isPublic: false, filename: 'icli.md', description: 'iCli export', ...opts };
84
+ const tok = token(cfg);
85
+ if (tok) return gistViaApi(o, tok);
86
+ if (await hasGh()) return gistViaGh(o);
87
+ throw new Error(
88
+ 'No GitHub auth. Run `gh auth login`, or save a token with `/gist token <TOKEN>` (needs the "gist" scope).',
89
+ );
90
+ }
package/src/gitrepo.js ADDED
@@ -0,0 +1,78 @@
1
+ // Per-user local git repository for archiving device config backups and giving
2
+ // the operator plain /git commands (status, log, commit, push, diff, …). The
3
+ // repo lives under ~/.config/ispbills-icli/backups/<user>/ and is git-init'd on
4
+ // first use with the operator's IspBills identity.
5
+ import { spawn } from 'child_process';
6
+ import { existsSync, writeFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { userDir } from './userdir.js';
9
+
10
+ // Sub-commands the operator may run through /git. Read-mostly plus the handful
11
+ // needed to record and publish backups; anything else is refused.
12
+ const ALLOWED = new Set([
13
+ 'status', 'log', 'diff', 'show', 'add', 'commit', 'push', 'pull',
14
+ 'fetch', 'remote', 'branch', 'checkout', 'init', 'config', 'ls-files', 'rev-parse',
15
+ ]);
16
+
17
+ /** Absolute path to the user's backup git repo (created if missing). */
18
+ export function repoDir(cfg) {
19
+ return userDir(cfg, 'backups');
20
+ }
21
+
22
+ function run(cfg, args, { input } = {}) {
23
+ return new Promise((resolve) => {
24
+ let child;
25
+ try {
26
+ child = spawn('git', args, { cwd: repoDir(cfg), stdio: ['pipe', 'pipe', 'pipe'] });
27
+ } catch (e) {
28
+ resolve({ code: 127, stdout: '', stderr: String(e.message || e) });
29
+ return;
30
+ }
31
+ let stdout = '', stderr = '';
32
+ child.stdout.on('data', (d) => (stdout += d));
33
+ child.stderr.on('data', (d) => (stderr += d));
34
+ child.on('error', (e) => resolve({ code: 127, stdout, stderr: String(e.message || e) }));
35
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
36
+ if (input != null) { try { child.stdin.end(input); } catch {} }
37
+ else child.stdin.end();
38
+ });
39
+ }
40
+
41
+ /** Initialise the repo on first use and set the local identity. */
42
+ export async function ensureRepo(cfg) {
43
+ const dir = repoDir(cfg);
44
+ if (!existsSync(join(dir, '.git'))) {
45
+ await run(cfg, ['init', '-q']);
46
+ await run(cfg, ['config', 'user.name', cfg?.user?.name || 'IspBills operator']);
47
+ await run(cfg, ['config', 'user.email', cfg?.user?.email || 'operator@ispbills.local']);
48
+ await run(cfg, ['config', 'commit.gpgsign', 'false']);
49
+ writeFileSync(join(dir, 'README.md'), '# IspBills device backups\n\nManaged by iCli (`/backup`, `/git`).\n');
50
+ await run(cfg, ['add', 'README.md']);
51
+ await run(cfg, ['commit', '-q', '-m', 'Initialise backup repository']);
52
+ }
53
+ return dir;
54
+ }
55
+
56
+ /** Run a whitelisted git sub-command; returns { ok, code, stdout, stderr }. */
57
+ export async function git(cfg, args) {
58
+ const sub = (args[0] || '').toLowerCase();
59
+ if (!sub) return { ok: false, code: 2, stdout: '', stderr: 'Usage: /git <status|log|commit|push|diff|…>' };
60
+ if (!ALLOWED.has(sub)) {
61
+ return { ok: false, code: 2, stdout: '', stderr: `git ${sub} is not allowed from iCli. Allowed: ${[...ALLOWED].join(', ')}` };
62
+ }
63
+ await ensureRepo(cfg);
64
+ const res = await run(cfg, args);
65
+ return { ok: res.code === 0, ...res };
66
+ }
67
+
68
+ /** Save backup content to a file and commit it. Returns { file, committed, stderr }. */
69
+ export async function commitBackup(cfg, label, content) {
70
+ await ensureRepo(cfg);
71
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
72
+ const safe = String(label || 'fleet').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').slice(0, 40) || 'fleet';
73
+ const name = `${stamp}__${safe}.txt`;
74
+ writeFileSync(join(repoDir(cfg), name), String(content));
75
+ await run(cfg, ['add', name]);
76
+ const res = await run(cfg, ['commit', '-q', '-m', `Backup ${safe} @ ${stamp}`]);
77
+ return { file: name, committed: res.code === 0, stderr: res.stderr };
78
+ }
package/src/memory.js ADDED
@@ -0,0 +1,72 @@
1
+ // Per-user persistent "bot memory": short operator notes that are injected
2
+ // into the AI context on every turn so the assistant remembers preferences,
3
+ // naming conventions, standing instructions, etc. Managed with /remember,
4
+ // /forget and /memory.
5
+ import { readFileSync, writeFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { userDir } from './userdir.js';
8
+
9
+ const MAX_NOTES = 50;
10
+
11
+ function file(cfg) {
12
+ return join(userDir(cfg, 'memory'), 'notes.json');
13
+ }
14
+
15
+ /** Load the user's notes as an array of { at, text }. */
16
+ export function loadNotes(cfg) {
17
+ try {
18
+ const data = JSON.parse(readFileSync(file(cfg), 'utf8'));
19
+ return Array.isArray(data?.notes) ? data.notes : [];
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ function save(cfg, notes) {
26
+ writeFileSync(file(cfg), JSON.stringify({ notes }, null, 2), { mode: 0o600 });
27
+ return notes;
28
+ }
29
+
30
+ /** Append a note. Returns the updated list. */
31
+ export function addNote(cfg, text) {
32
+ const t = String(text || '').trim();
33
+ if (!t) return loadNotes(cfg);
34
+ const notes = loadNotes(cfg);
35
+ notes.push({ at: new Date().toISOString(), text: t });
36
+ return save(cfg, notes.slice(-MAX_NOTES));
37
+ }
38
+
39
+ /**
40
+ * Remove a note. `which` may be a 1-based index, the string 'all', or a
41
+ * substring to match. Returns { notes, removed }.
42
+ */
43
+ export function forgetNote(cfg, which) {
44
+ const notes = loadNotes(cfg);
45
+ if (String(which).toLowerCase() === 'all') {
46
+ save(cfg, []);
47
+ return { notes: [], removed: notes.length };
48
+ }
49
+ const idx = Number(which);
50
+ if (Number.isInteger(idx) && idx >= 1 && idx <= notes.length) {
51
+ const [gone] = notes.splice(idx - 1, 1);
52
+ save(cfg, notes);
53
+ return { notes, removed: gone ? 1 : 0 };
54
+ }
55
+ const needle = String(which || '').toLowerCase();
56
+ const before = notes.length;
57
+ const kept = needle ? notes.filter((n) => !n.text.toLowerCase().includes(needle)) : notes;
58
+ save(cfg, kept);
59
+ return { notes: kept, removed: before - kept.length };
60
+ }
61
+
62
+ /** Build the preamble message injected ahead of the conversation, or null. */
63
+ export function memoryPreamble(cfg) {
64
+ const notes = loadNotes(cfg);
65
+ if (!notes.length) return null;
66
+ const body = notes.map((n) => `- ${n.text}`).join('\n');
67
+ return {
68
+ role: 'user',
69
+ content:
70
+ `[operator memory — standing notes to keep in mind for every answer; do not reply to this message directly]\n${body}`,
71
+ };
72
+ }
package/src/tui/app.js CHANGED
@@ -14,6 +14,19 @@ import { streamChat } from '../client.js';
14
14
  import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
15
15
  import { saveConfig } from '../config.js';
16
16
  import { shortModel } from '../ui.js';
17
+ import { loadNotes, addNote, forgetNote, memoryPreamble } from '../memory.js';
18
+ import { copyToClipboard } from '../clipboard.js';
19
+ import { createGist } from '../gist.js';
20
+ import { git as gitCmd, commitBackup } from '../gitrepo.js';
21
+
22
+ // Split a command argument string into argv, honouring "quoted" segments.
23
+ function splitArgs(s) {
24
+ const out = [];
25
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
26
+ let m;
27
+ while ((m = re.exec(s))) out.push(m[1] ?? m[2] ?? m[3]);
28
+ return out;
29
+ }
17
30
 
18
31
  function useTerminalSize() {
19
32
  const { stdout } = useStdout();
@@ -84,6 +97,16 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
84
97
  const ctrlC = useRef(0); // timestamp of the last lone Ctrl+C
85
98
  const apRef = useRef(false); // live autopilot flag (read inside runTurn)
86
99
  const modelRef = useRef(cfg._model); // preferred model to request
100
+ const lastAnswerRef = useRef(''); // most recent assistant answer (for /copy, /gist)
101
+ const choiceActionRef = useRef(null); // action to run when a non-AI Choice is picked
102
+
103
+ // Enable terminal bracketed-paste so multi-line pastes (mouse right-click or
104
+ // Cmd/Ctrl+V) arrive wrapped in markers as one block — the Composer inserts
105
+ // them with newlines preserved instead of submitting on the first newline.
106
+ useEffect(() => {
107
+ try { process.stdout.write('\x1b[?2004h'); } catch {}
108
+ return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
109
+ }, []);
87
110
 
88
111
  const setAutopilotMode = useCallback((next) => {
89
112
  apRef.current = next;
@@ -93,7 +116,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
93
116
  const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
94
117
  const log = (line) => plain.current.push(line);
95
118
 
96
- const runTurn = useCallback(async (question, depth = 0) => {
119
+ const runTurn = useCallback(async (question, opts = {}) => {
120
+ const { depth = 0, archive = null, label = '' } = opts;
97
121
  push({ type: 'user', text: question });
98
122
  log(`\n${glyphs.prompt} ${question}\n`);
99
123
  convo.current.push({ role: 'user', content: question });
@@ -114,7 +138,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
114
138
  if (modelRef.current) context.model = modelRef.current;
115
139
 
116
140
  try {
117
- const { reply, text, meta } = await streamChat(cfg, [...convo.current], {
141
+ const preamble = memoryPreamble(cfg);
142
+ const outbound = preamble ? [preamble, ...convo.current] : [...convo.current];
143
+ const { reply, text, meta } = await streamChat(cfg, outbound, {
118
144
  signal: abort.signal,
119
145
  context,
120
146
  onEvent: (ev) => {
@@ -132,12 +158,25 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
132
158
  else if (type === 'prompt' || type === 'choice') { push({ type: 'assistant', text: text || reply.question || '' }); log(text || reply.question || ''); }
133
159
  else { push({ type: 'assistant', text: text || '(no response)' }); log(text || ''); }
134
160
  if (text) convo.current.push({ role: 'assistant', content: text });
161
+ lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
162
+
163
+ // Archive a /backup answer to the per-user git repo.
164
+ if (archive === 'backup' && text) {
165
+ try {
166
+ const r = await commitBackup(cfg, label, text);
167
+ push({ type: 'notice',
168
+ text: `${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`,
169
+ color: C.green });
170
+ } catch (e) {
171
+ push({ type: 'notice', text: `${glyphs.warn} Could not archive backup: ${e.message}`, color: C.yellow });
172
+ }
173
+ }
135
174
 
136
175
  // Autopilot: auto-confirm a proposed plan without waiting for the user.
137
176
  if (type === 'plan' && apRef.current && depth < 3) {
138
177
  push({ type: 'notice', text: `${glyphs.bolt} autopilot ${dash()} applying fix…`, color: C.yellow });
139
178
  setLive(null); setBusy(false); abortRef.current = null;
140
- return runTurn('apply fix', depth + 1);
179
+ return runTurn('apply fix', { depth: depth + 1 });
141
180
  }
142
181
 
143
182
  // Otherwise surface an interactive prompt for the user to choose/answer.
@@ -178,6 +217,29 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
178
217
 
179
218
  const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
180
219
 
220
+ // Create a gist from the last answer or the whole transcript (invoked by the
221
+ // /gist Choice below).
222
+ const doGist = useCallback(async (kind) => {
223
+ let content, filename, description;
224
+ if (kind === 'transcript') {
225
+ content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
226
+ filename = 'icli-transcript.md';
227
+ description = 'iCli conversation transcript';
228
+ } else {
229
+ content = lastAnswerRef.current || '(no answer)';
230
+ filename = 'icli-answer.md';
231
+ description = 'iCli answer';
232
+ }
233
+ push({ type: 'notice', text: `${glyphs.arrow} Creating secret gist…`, color: C.gray });
234
+ try {
235
+ const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
236
+ const tool = await copyToClipboard(url);
237
+ push({ type: 'notice', text: `${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, color: C.green });
238
+ } catch (e) {
239
+ push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
240
+ }
241
+ }, [cfg, push]);
242
+
181
243
  const onSubmit = useCallback((raw) => {
182
244
  const q = raw.trim();
183
245
  if (!q) return;
@@ -224,7 +286,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
224
286
  const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
225
287
  return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
226
288
  }).join('\n');
227
- push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+L clear · Ctrl+C twice to exit' });
289
+ push({ type: 'notice', text: 'Commands:' + lines + '\n\n Keys: Shift+Tab autopilot · Ctrl+T reasoning · Ctrl+Z/Ctrl+Y undo/redo · Ctrl+L clear · Ctrl+C twice to exit' });
228
290
  return;
229
291
  }
230
292
  if (q === '/history') {
@@ -235,18 +297,100 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
235
297
  if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
236
298
  if (q === '/update') { onExternal?.('update'); doExit(); return; }
237
299
 
300
+ // ── Memory ──
301
+ if (q === '/remember' || q.startsWith('/remember ')) {
302
+ const note = q.slice('/remember'.length).trim();
303
+ if (!note) { push({ type: 'notice', text: 'Usage: /remember <note>' }); return; }
304
+ const notes = addNote(cfg, note);
305
+ push({ type: 'notice', text: `${glyphs.check} Remembered (${notes.length} note${notes.length > 1 ? 's' : ''}). I'll keep this in mind.`, color: C.green });
306
+ return;
307
+ }
308
+ if (q === '/memory') {
309
+ const notes = loadNotes(cfg);
310
+ push({ type: 'notice', text: notes.length
311
+ ? 'Memory notes:\n' + notes.map((n, i) => ` ${i + 1}. ${n.text}`).join('\n') + '\n\n /forget <n|all> to remove.'
312
+ : 'No memory notes yet. Add one with /remember <note>.' });
313
+ return;
314
+ }
315
+ if (q === '/forget' || q.startsWith('/forget ')) {
316
+ const which = q.slice('/forget'.length).trim();
317
+ if (!which) { push({ type: 'notice', text: 'Usage: /forget <number | all | text>' }); return; }
318
+ const { notes, removed } = forgetNote(cfg, which);
319
+ push({ type: 'notice',
320
+ text: removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.',
321
+ color: removed ? C.green : C.gray });
322
+ return;
323
+ }
324
+
325
+ // ── Clipboard ──
326
+ if (q === '/copy') {
327
+ const ans = lastAnswerRef.current;
328
+ if (!ans) { push({ type: 'notice', text: 'Nothing to copy yet — ask something first.', color: C.gray }); return; }
329
+ copyToClipboard(ans).then((tool) => push({ type: 'notice',
330
+ text: tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
331
+ : `${glyphs.cross} No clipboard tool found. Install xclip / wl-clipboard (Linux), or use macOS/Windows.`,
332
+ color: tool ? C.green : C.red }));
333
+ return;
334
+ }
335
+
336
+ // ── GitHub gist ──
337
+ if (q === '/gist' || q.startsWith('/gist ')) {
338
+ const rest = q.slice('/gist'.length).trim();
339
+ if (rest.startsWith('token ')) {
340
+ cfg.github_token = rest.slice('token '.length).trim();
341
+ try { saveConfig(cfg); } catch {}
342
+ push({ type: 'notice', text: `${glyphs.check} GitHub token saved. /gist will use it (needs the "gist" scope).`, color: C.green });
343
+ return;
344
+ }
345
+ if (!lastAnswerRef.current && convo.current.length === 0) {
346
+ push({ type: 'notice', text: 'Nothing to save yet — ask something first.', color: C.gray });
347
+ return;
348
+ }
349
+ choiceActionRef.current = (v) => doGist(v);
350
+ setChoice({
351
+ title: 'Create a secret gist from…',
352
+ note: '', sel: 0, text: '', focusText: false, allowText: false,
353
+ options: [
354
+ { label: 'The last AI answer', value: 'answer' },
355
+ { label: 'The entire conversation transcript', value: 'transcript' },
356
+ { label: 'Cancel', value: null },
357
+ ],
358
+ });
359
+ return;
360
+ }
361
+
362
+ // ── Local git repo ──
363
+ if (q === '/git' || q.startsWith('/git ')) {
364
+ const args = q.slice('/git'.length).trim();
365
+ if (!args) { push({ type: 'notice', text: 'Usage: /git <status | log | commit -m "msg" | push | diff | remote add origin <url>>' }); return; }
366
+ gitCmd(cfg, splitArgs(args)).then((r) => {
367
+ const body = (r.stdout || '').trim() || (r.stderr || '').trim() || (r.ok ? '(done)' : '(no output)');
368
+ push({ type: 'notice', text: `$ git ${args}\n${body.slice(0, 3500)}`, color: r.ok ? undefined : C.red });
369
+ });
370
+ return;
371
+ }
372
+
238
373
  let question = q;
374
+ const opts = {};
239
375
  if (q.startsWith('/')) {
240
376
  const mapped = slashToQuestion(q);
241
- if (mapped) question = mapped;
242
- else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
377
+ if (mapped) {
378
+ question = mapped;
379
+ if (q === '/backup' || q.startsWith('/backup ')) {
380
+ opts.archive = 'backup';
381
+ opts.label = q.slice('/backup'.length).trim() || 'fleet';
382
+ }
383
+ } else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
243
384
  }
244
- runTurn(question);
245
- }, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg]);
385
+ runTurn(question, opts);
386
+ }, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg, doGist]);
246
387
 
247
388
  // Interactive prompt/choice handling (owns the keyboard while open).
248
389
  const pickChoice = useCallback((value) => {
390
+ const act = choiceActionRef.current;
391
+ choiceActionRef.current = null;
249
392
  setChoice(null);
393
+ if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.gray }); return; }
250
394
  if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.gray }); return; }
251
395
  runTurn(String(value));
252
396
  }, [push, runTurn]);
@@ -1,7 +1,7 @@
1
1
  // Bottom-pinned input composer with cursor editing, input history, readline
2
2
  // key bindings and a Copilot-style slash-command palette that appears ABOVE
3
3
  // the input and is navigable with the arrow keys.
4
- import { html, useState, useEffect, useCallback } from './dom.js';
4
+ import { html, useState, useEffect, useCallback, useRef } from './dom.js';
5
5
  import { Box, Text, useInput } from 'ink';
6
6
  import { C, glyphs, borderStyle } from './theme.js';
7
7
  import { SLASH_COMMANDS } from '../commands.js';
@@ -27,6 +27,39 @@ export function Composer({ onSubmit, busy, width = 80 }) {
27
27
  const [histIdx, setHistIdx] = useState(-1);
28
28
  const [sel, setSel] = useState(0);
29
29
 
30
+ // Undo/redo history: snapshots of { value, cursor }. Consecutive typing is
31
+ // coalesced into a single undo step via `lastMut`.
32
+ const undoRef = useRef([]);
33
+ const redoRef = useRef([]);
34
+ const lastMut = useRef('');
35
+ const snapshot = (kind) => {
36
+ if (kind === 'type' && lastMut.current === 'type') return; // coalesce a typing run
37
+ undoRef.current.push({ value, cursor });
38
+ if (undoRef.current.length > 200) undoRef.current.shift();
39
+ redoRef.current = [];
40
+ lastMut.current = kind;
41
+ };
42
+
43
+ // Bracketed-paste assembly. A right-click / Cmd+V paste is wrapped by the
44
+ // terminal in \x1b[200~ … \x1b[201~ and can arrive split across several
45
+ // reads, so we buffer until the end marker before inserting.
46
+ const pasteRef = useRef({ active: false, buf: '' });
47
+
48
+ // Insert pasted text at the cursor, PRESERVING newlines so multi-line pastes
49
+ // (configs, logs) keep their structure. Tabs → 2 spaces; other control chars
50
+ // and the bracketed-paste markers are dropped.
51
+ const insertPaste = (raw) => {
52
+ const text = raw
53
+ .replace(/\x1b?\[20[01]~/g, '')
54
+ .replace(/\r\n?/g, '\n')
55
+ .replace(/\t/g, ' ')
56
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
57
+ if (!text) return;
58
+ snapshot('paste');
59
+ setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
60
+ setCursor((c) => c + text.length);
61
+ };
62
+
30
63
  // Determine the token being typed at the cursor to drive the palette.
31
64
  const upto = value.slice(0, cursor);
32
65
  const tokStart = Math.max(upto.lastIndexOf(' '), upto.lastIndexOf('\n')) + 1;
@@ -45,6 +78,7 @@ export function Composer({ onSubmit, busy, width = 80 }) {
45
78
  const submit = useCallback((text) => {
46
79
  const q = text.trim();
47
80
  setValue(''); setCursor(0); setHistIdx(-1); setSel(0);
81
+ undoRef.current = []; redoRef.current = []; lastMut.current = '';
48
82
  if (q) {
49
83
  setHistory((h) => (h[h.length - 1] === q ? h : [...h, q]));
50
84
  onSubmit(q);
@@ -65,20 +99,49 @@ export function Composer({ onSubmit, busy, width = 80 }) {
65
99
  useInput((input, key) => {
66
100
  if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
67
101
 
102
+ // ── Bracketed paste (mouse right-click / Cmd+V), possibly split reads ──
103
+ // Ink strips the leading ESC, so the terminal's \x1b[200~ … \x1b[201~
104
+ // wrappers arrive as the marker text "[200~" / "[201~" (sometimes as their
105
+ // own useInput calls). Buffer everything between them so embedded newlines
106
+ // are never treated as Enter, then insert the whole block at once.
107
+ const START = '[200~';
108
+ const END = '[201~';
109
+ if (pasteRef.current.active || input.includes(START)) {
110
+ pasteRef.current.active = true;
111
+ pasteRef.current.buf += input;
112
+ const e = pasteRef.current.buf.indexOf(END);
113
+ if (e === -1) return; // still receiving the paste
114
+ const s = pasteRef.current.buf.indexOf(START);
115
+ const raw = pasteRef.current.buf.slice(s + START.length, e);
116
+ pasteRef.current = { active: false, buf: '' };
117
+ insertPaste(raw);
118
+ return;
119
+ }
120
+
68
121
  // ── Submit / complete ──
69
- if (key.return) {
122
+ // Some terminals/PTYs deliver Enter as input="\r" without key.return, so
123
+ // treat a lone CR/LF as Enter too (a paste is a longer chunk, never "\r").
124
+ const enter = key.return || input === '\r' || input === '\n';
125
+ if (enter) {
70
126
  // In slash mode Enter runs the command; in mention mode it completes.
71
127
  if (menuOpen && slashMode) { submit(matches[sel].cmd); return; }
72
128
  if (menuOpen && mentionMode) { complete(); return; }
73
129
  submit(value);
74
130
  return;
75
131
  }
76
- if (input && /[\r\n]/.test(input)) {
77
- const first = input.split(/[\r\n]/)[0];
78
- submit(value.slice(0, cursor) + first + value.slice(cursor));
132
+ if (key.tab && !key.shift) { complete(); return; }
133
+
134
+ // ── Undo / redo ──
135
+ if (key.ctrl && input === 'z') {
136
+ const prev = undoRef.current.pop();
137
+ if (prev) { redoRef.current.push({ value, cursor }); setValue(prev.value); setCursor(prev.cursor); lastMut.current = 'undo'; }
138
+ return;
139
+ }
140
+ if (key.ctrl && (input === 'y' || (input === 'z' && key.shift))) {
141
+ const nx = redoRef.current.pop();
142
+ if (nx) { undoRef.current.push({ value, cursor }); setValue(nx.value); setCursor(nx.cursor); lastMut.current = 'redo'; }
79
143
  return;
80
144
  }
81
- if (key.tab && !key.shift) { complete(); return; }
82
145
 
83
146
  // ── Menu / history navigation ──
84
147
  if (key.upArrow) {
@@ -115,31 +178,39 @@ export function Composer({ onSubmit, busy, width = 80 }) {
115
178
 
116
179
  // ── Deletion ──
117
180
  if (key.backspace || key.delete) {
118
- if (cursor > 0) { setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
181
+ if (cursor > 0) { snapshot('del'); setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
119
182
  return;
120
183
  }
121
- if (key.ctrl && input === 'u') { setValue(value.slice(cursor)); setCursor(0); return; } // to start
122
- if (key.ctrl && input === 'k') { setValue(value.slice(0, cursor)); return; } // to end
184
+ if (key.ctrl && input === 'u') { snapshot('cut'); setValue(value.slice(cursor)); setCursor(0); return; } // to start
185
+ if (key.ctrl && input === 'k') { snapshot('cut'); setValue(value.slice(0, cursor)); return; } // to end
123
186
  if (key.ctrl && input === 'w') { // prev word
124
187
  let i = cursor;
125
188
  while (i > 0 && value[i - 1] === ' ') i--;
126
189
  while (i > 0 && value[i - 1] !== ' ') i--;
190
+ snapshot('cut');
127
191
  setValue(value.slice(0, i) + value.slice(cursor)); setCursor(i);
128
192
  return;
129
193
  }
130
194
 
131
- // ── Printable input ──
195
+ // ── Printable input & paste ──
132
196
  if (input && !key.ctrl && !key.meta) {
133
- const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
134
- if (!printable) return;
135
- setValue((v) => v.slice(0, cursor) + printable + v.slice(cursor));
136
- setCursor((c) => c + printable.length);
197
+ // A multi-char chunk or one containing newlines/tabs is a non-bracketed
198
+ // paste (terminal without bracketed-paste support): keep its newlines.
199
+ if (input.length > 1 || /[\r\n\t]/.test(input)) { insertPaste(input); return; }
200
+ const text = input.replace(/[\x00-\x1f\x7f]/g, '');
201
+ if (!text) return;
202
+ snapshot('type');
203
+ setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
204
+ setCursor((c) => c + text.length);
137
205
  }
138
206
  });
139
207
 
140
208
  const before = value.slice(0, cursor);
141
- const at = value[cursor] ?? ' ';
142
- const after = value.slice(cursor + 1);
209
+ const rawAt = value[cursor];
210
+ // When the cursor sits on a newline, show an inverse space and keep the line
211
+ // break in `after` so the buffer still renders across multiple lines.
212
+ const at = (rawAt === undefined || rawAt === '\n') ? ' ' : rawAt;
213
+ const after = rawAt === '\n' ? '\n' + value.slice(cursor + 1) : value.slice(cursor + 1);
143
214
  const empty = value.length === 0;
144
215
  const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
145
216
  const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
package/src/userdir.js ADDED
@@ -0,0 +1,18 @@
1
+ import { mkdirSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+
5
+ const BASE = join(homedir(), '.config', 'ispbills-icli');
6
+
7
+ /** A filesystem-safe key identifying the logged-in IspBills user. */
8
+ export function userKey(cfg) {
9
+ const raw = cfg?.user?.email || cfg?.user?.name || (cfg?.user?.id != null ? 'id-' + cfg.user.id : '') || 'default';
10
+ return String(raw).toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 64) || 'default';
11
+ }
12
+
13
+ /** Ensure and return a per-user subdirectory under the config dir. */
14
+ export function userDir(cfg, ...parts) {
15
+ const dir = join(BASE, ...parts, userKey(cfg));
16
+ mkdirSync(dir, { recursive: true });
17
+ return dir;
18
+ }