ispbills-icli 8.2.0 → 8.3.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 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 are inserted as one line (never auto-submitted) |
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 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.0",
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,15 @@ 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 arrive as one chunk
104
+ // (the Composer flattens them) instead of submitting on the first newline.
105
+ useEffect(() => {
106
+ try { process.stdout.write('\x1b[?2004h'); } catch {}
107
+ return () => { try { process.stdout.write('\x1b[?2004l'); } catch {} };
108
+ }, []);
87
109
 
88
110
  const setAutopilotMode = useCallback((next) => {
89
111
  apRef.current = next;
@@ -93,7 +115,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
93
115
  const push = useCallback((item) => setItems((xs) => [...xs, { id: nextId(), ...item }]), []);
94
116
  const log = (line) => plain.current.push(line);
95
117
 
96
- const runTurn = useCallback(async (question, depth = 0) => {
118
+ const runTurn = useCallback(async (question, opts = {}) => {
119
+ const { depth = 0, archive = null, label = '' } = opts;
97
120
  push({ type: 'user', text: question });
98
121
  log(`\n${glyphs.prompt} ${question}\n`);
99
122
  convo.current.push({ role: 'user', content: question });
@@ -114,7 +137,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
114
137
  if (modelRef.current) context.model = modelRef.current;
115
138
 
116
139
  try {
117
- const { reply, text, meta } = await streamChat(cfg, [...convo.current], {
140
+ const preamble = memoryPreamble(cfg);
141
+ const outbound = preamble ? [preamble, ...convo.current] : [...convo.current];
142
+ const { reply, text, meta } = await streamChat(cfg, outbound, {
118
143
  signal: abort.signal,
119
144
  context,
120
145
  onEvent: (ev) => {
@@ -132,12 +157,25 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
132
157
  else if (type === 'prompt' || type === 'choice') { push({ type: 'assistant', text: text || reply.question || '' }); log(text || reply.question || ''); }
133
158
  else { push({ type: 'assistant', text: text || '(no response)' }); log(text || ''); }
134
159
  if (text) convo.current.push({ role: 'assistant', content: text });
160
+ lastAnswerRef.current = text || reply.summary || reply.question || lastAnswerRef.current;
161
+
162
+ // Archive a /backup answer to the per-user git repo.
163
+ if (archive === 'backup' && text) {
164
+ try {
165
+ const r = await commitBackup(cfg, label, text);
166
+ push({ type: 'notice',
167
+ text: `${glyphs.check} Backup saved to git repo (${r.file})${r.committed ? '' : ' — nothing new to commit'}.`,
168
+ color: C.green });
169
+ } catch (e) {
170
+ push({ type: 'notice', text: `${glyphs.warn} Could not archive backup: ${e.message}`, color: C.yellow });
171
+ }
172
+ }
135
173
 
136
174
  // Autopilot: auto-confirm a proposed plan without waiting for the user.
137
175
  if (type === 'plan' && apRef.current && depth < 3) {
138
176
  push({ type: 'notice', text: `${glyphs.bolt} autopilot ${dash()} applying fix…`, color: C.yellow });
139
177
  setLive(null); setBusy(false); abortRef.current = null;
140
- return runTurn('apply fix', depth + 1);
178
+ return runTurn('apply fix', { depth: depth + 1 });
141
179
  }
142
180
 
143
181
  // Otherwise surface an interactive prompt for the user to choose/answer.
@@ -178,6 +216,29 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
178
216
 
179
217
  const doExit = useCallback(() => { onExit?.(plain.current); exit(); }, [exit, onExit]);
180
218
 
219
+ // Create a gist from the last answer or the whole transcript (invoked by the
220
+ // /gist Choice below).
221
+ const doGist = useCallback(async (kind) => {
222
+ let content, filename, description;
223
+ if (kind === 'transcript') {
224
+ content = convo.current.map((m) => `### ${m.role}\n\n${m.content}`).join('\n\n---\n\n') || '(empty)';
225
+ filename = 'icli-transcript.md';
226
+ description = 'iCli conversation transcript';
227
+ } else {
228
+ content = lastAnswerRef.current || '(no answer)';
229
+ filename = 'icli-answer.md';
230
+ description = 'iCli answer';
231
+ }
232
+ push({ type: 'notice', text: `${glyphs.arrow} Creating secret gist…`, color: C.gray });
233
+ try {
234
+ const { url } = await createGist(cfg, { filename, content, description, isPublic: false });
235
+ const tool = await copyToClipboard(url);
236
+ push({ type: 'notice', text: `${glyphs.check} Gist created: ${url}${tool ? ' (URL copied)' : ''}`, color: C.green });
237
+ } catch (e) {
238
+ push({ type: 'notice', text: `${glyphs.cross} ${e.message}`, color: C.red });
239
+ }
240
+ }, [cfg, push]);
241
+
181
242
  const onSubmit = useCallback((raw) => {
182
243
  const q = raw.trim();
183
244
  if (!q) return;
@@ -224,7 +285,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
224
285
  const head = c.group !== group ? ((group = c.group), `\n ${c.group}\n`) : '';
225
286
  return head + ` ${(c.cmd + (c.hint ? ' ' + c.hint : '')).padEnd(22)}${c.desc}`;
226
287
  }).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' });
288
+ 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
289
  return;
229
290
  }
230
291
  if (q === '/history') {
@@ -235,18 +296,100 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion })
235
296
  if (q === '/logout') { onExternal?.('logout'); doExit(); return; }
236
297
  if (q === '/update') { onExternal?.('update'); doExit(); return; }
237
298
 
299
+ // ── Memory ──
300
+ if (q === '/remember' || q.startsWith('/remember ')) {
301
+ const note = q.slice('/remember'.length).trim();
302
+ if (!note) { push({ type: 'notice', text: 'Usage: /remember <note>' }); return; }
303
+ const notes = addNote(cfg, note);
304
+ push({ type: 'notice', text: `${glyphs.check} Remembered (${notes.length} note${notes.length > 1 ? 's' : ''}). I'll keep this in mind.`, color: C.green });
305
+ return;
306
+ }
307
+ if (q === '/memory') {
308
+ const notes = loadNotes(cfg);
309
+ push({ type: 'notice', text: notes.length
310
+ ? 'Memory notes:\n' + notes.map((n, i) => ` ${i + 1}. ${n.text}`).join('\n') + '\n\n /forget <n|all> to remove.'
311
+ : 'No memory notes yet. Add one with /remember <note>.' });
312
+ return;
313
+ }
314
+ if (q === '/forget' || q.startsWith('/forget ')) {
315
+ const which = q.slice('/forget'.length).trim();
316
+ if (!which) { push({ type: 'notice', text: 'Usage: /forget <number | all | text>' }); return; }
317
+ const { notes, removed } = forgetNote(cfg, which);
318
+ push({ type: 'notice',
319
+ text: removed ? `${glyphs.check} Forgot ${removed} note(s). ${notes.length} remain.` : 'No matching note found.',
320
+ color: removed ? C.green : C.gray });
321
+ return;
322
+ }
323
+
324
+ // ── Clipboard ──
325
+ if (q === '/copy') {
326
+ const ans = lastAnswerRef.current;
327
+ if (!ans) { push({ type: 'notice', text: 'Nothing to copy yet — ask something first.', color: C.gray }); return; }
328
+ copyToClipboard(ans).then((tool) => push({ type: 'notice',
329
+ text: tool ? `${glyphs.check} Copied last answer (${ans.length} chars) to the clipboard.`
330
+ : `${glyphs.cross} No clipboard tool found. Install xclip / wl-clipboard (Linux), or use macOS/Windows.`,
331
+ color: tool ? C.green : C.red }));
332
+ return;
333
+ }
334
+
335
+ // ── GitHub gist ──
336
+ if (q === '/gist' || q.startsWith('/gist ')) {
337
+ const rest = q.slice('/gist'.length).trim();
338
+ if (rest.startsWith('token ')) {
339
+ cfg.github_token = rest.slice('token '.length).trim();
340
+ try { saveConfig(cfg); } catch {}
341
+ push({ type: 'notice', text: `${glyphs.check} GitHub token saved. /gist will use it (needs the "gist" scope).`, color: C.green });
342
+ return;
343
+ }
344
+ if (!lastAnswerRef.current && convo.current.length === 0) {
345
+ push({ type: 'notice', text: 'Nothing to save yet — ask something first.', color: C.gray });
346
+ return;
347
+ }
348
+ choiceActionRef.current = (v) => doGist(v);
349
+ setChoice({
350
+ title: 'Create a secret gist from…',
351
+ note: '', sel: 0, text: '', focusText: false, allowText: false,
352
+ options: [
353
+ { label: 'The last AI answer', value: 'answer' },
354
+ { label: 'The entire conversation transcript', value: 'transcript' },
355
+ { label: 'Cancel', value: null },
356
+ ],
357
+ });
358
+ return;
359
+ }
360
+
361
+ // ── Local git repo ──
362
+ if (q === '/git' || q.startsWith('/git ')) {
363
+ const args = q.slice('/git'.length).trim();
364
+ if (!args) { push({ type: 'notice', text: 'Usage: /git <status | log | commit -m "msg" | push | diff | remote add origin <url>>' }); return; }
365
+ gitCmd(cfg, splitArgs(args)).then((r) => {
366
+ const body = (r.stdout || '').trim() || (r.stderr || '').trim() || (r.ok ? '(done)' : '(no output)');
367
+ push({ type: 'notice', text: `$ git ${args}\n${body.slice(0, 3500)}`, color: r.ok ? undefined : C.red });
368
+ });
369
+ return;
370
+ }
371
+
238
372
  let question = q;
373
+ const opts = {};
239
374
  if (q.startsWith('/')) {
240
375
  const mapped = slashToQuestion(q);
241
- if (mapped) question = mapped;
242
- else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
376
+ if (mapped) {
377
+ question = mapped;
378
+ if (q === '/backup' || q.startsWith('/backup ')) {
379
+ opts.archive = 'backup';
380
+ opts.label = q.slice('/backup'.length).trim() || 'fleet';
381
+ }
382
+ } else { push({ type: 'notice', text: `Unknown command: ${q}. Type /help.` }); return; }
243
383
  }
244
- runTurn(question);
245
- }, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg]);
384
+ runTurn(question, opts);
385
+ }, [doExit, push, runTurn, onExternal, setAutopilotMode, model, cfg, doGist]);
246
386
 
247
387
  // Interactive prompt/choice handling (owns the keyboard while open).
248
388
  const pickChoice = useCallback((value) => {
389
+ const act = choiceActionRef.current;
390
+ choiceActionRef.current = null;
249
391
  setChoice(null);
392
+ if (act) { if (value != null) act(value); else push({ type: 'notice', text: 'Cancelled.', color: C.gray }); return; }
250
393
  if (value == null) { push({ type: 'notice', text: 'Dismissed.', color: C.gray }); return; }
251
394
  runTurn(String(value));
252
395
  }, [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,19 @@ 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
+
30
43
  // Determine the token being typed at the cursor to drive the palette.
31
44
  const upto = value.slice(0, cursor);
32
45
  const tokStart = Math.max(upto.lastIndexOf(' '), upto.lastIndexOf('\n')) + 1;
@@ -45,6 +58,7 @@ export function Composer({ onSubmit, busy, width = 80 }) {
45
58
  const submit = useCallback((text) => {
46
59
  const q = text.trim();
47
60
  setValue(''); setCursor(0); setHistIdx(-1); setSel(0);
61
+ undoRef.current = []; redoRef.current = []; lastMut.current = '';
48
62
  if (q) {
49
63
  setHistory((h) => (h[h.length - 1] === q ? h : [...h, q]));
50
64
  onSubmit(q);
@@ -66,19 +80,29 @@ export function Composer({ onSubmit, busy, width = 80 }) {
66
80
  if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
67
81
 
68
82
  // ── Submit / complete ──
69
- if (key.return) {
83
+ // Some terminals/PTYs deliver Enter as input="\r" without key.return, so
84
+ // treat a lone CR/LF as Enter too (a paste is a longer chunk, never "\r").
85
+ const enter = key.return || input === '\r' || input === '\n';
86
+ if (enter) {
70
87
  // In slash mode Enter runs the command; in mention mode it completes.
71
88
  if (menuOpen && slashMode) { submit(matches[sel].cmd); return; }
72
89
  if (menuOpen && mentionMode) { complete(); return; }
73
90
  submit(value);
74
91
  return;
75
92
  }
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));
93
+ if (key.tab && !key.shift) { complete(); return; }
94
+
95
+ // ── Undo / redo ──
96
+ if (key.ctrl && input === 'z') {
97
+ const prev = undoRef.current.pop();
98
+ if (prev) { redoRef.current.push({ value, cursor }); setValue(prev.value); setCursor(prev.cursor); lastMut.current = 'undo'; }
99
+ return;
100
+ }
101
+ if (key.ctrl && (input === 'y' || (input === 'z' && key.shift))) {
102
+ const nx = redoRef.current.pop();
103
+ if (nx) { undoRef.current.push({ value, cursor }); setValue(nx.value); setCursor(nx.cursor); lastMut.current = 'redo'; }
79
104
  return;
80
105
  }
81
- if (key.tab && !key.shift) { complete(); return; }
82
106
 
83
107
  // ── Menu / history navigation ──
84
108
  if (key.upArrow) {
@@ -115,25 +139,30 @@ export function Composer({ onSubmit, busy, width = 80 }) {
115
139
 
116
140
  // ── Deletion ──
117
141
  if (key.backspace || key.delete) {
118
- if (cursor > 0) { setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
142
+ if (cursor > 0) { snapshot('del'); setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
119
143
  return;
120
144
  }
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
145
+ if (key.ctrl && input === 'u') { snapshot('cut'); setValue(value.slice(cursor)); setCursor(0); return; } // to start
146
+ if (key.ctrl && input === 'k') { snapshot('cut'); setValue(value.slice(0, cursor)); return; } // to end
123
147
  if (key.ctrl && input === 'w') { // prev word
124
148
  let i = cursor;
125
149
  while (i > 0 && value[i - 1] === ' ') i--;
126
150
  while (i > 0 && value[i - 1] !== ' ') i--;
151
+ snapshot('cut');
127
152
  setValue(value.slice(0, i) + value.slice(cursor)); setCursor(i);
128
153
  return;
129
154
  }
130
155
 
131
- // ── Printable input ──
156
+ // ── Printable input & paste ──
132
157
  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);
158
+ // Strip bracketed-paste markers, then flatten newlines/tabs from a paste.
159
+ let text = input.replace(/\x1b\[20[01]~/g, '');
160
+ const isPaste = text.length > 1 || /[\r\n\t]/.test(text);
161
+ text = text.replace(/[\r\n\t]+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '');
162
+ if (!text) return;
163
+ snapshot(isPaste ? 'paste' : 'type');
164
+ setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
165
+ setCursor((c) => c + text.length);
137
166
  }
138
167
  });
139
168
 
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
+ }