cawdev-cli 0.9.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.
@@ -0,0 +1,91 @@
1
+ // What you last sent, kept per machine — R83.
2
+ //
3
+ // `~/.cawdev/history.json`, beside the stored session and keyed the same way: by
4
+ // URL, because one machine can point at more than one cawdev and a prompt
5
+ // written for one of them is not history for the other.
6
+ //
7
+ // **It survives quitting.** Up-arrow that only remembers this launch is a
8
+ // feature people learn not to reach for; the whole value of it is the prompt you
9
+ // wrote yesterday and want to send again today.
10
+ //
11
+ // What is in here is what you typed, and it is worth being plain about that: a
12
+ // prompt, an answer to a question, a slash command. Not a permission decision
13
+ // and not a refusal reason — those are records the platform keeps about a
14
+ // session, and a local convenience file is not the second place to hold them.
15
+ // A paste is remembered as its `[pasted, 342 lines]` placeholder and never as
16
+ // its contents (see input.mjs) — this file must not become a copy of everything
17
+ // anybody has ever pasted into a terminal.
18
+ //
19
+ // Zero dependencies, like everything in tools/.
20
+
21
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
22
+ import { homedir } from 'node:os';
23
+ import { dirname, join } from 'node:path';
24
+
25
+ /** How much is kept. Long enough to be worth walking, short enough to read. */
26
+ const KEPT = 200;
27
+
28
+ export function historyFile() {
29
+ return process.env.CAWDEV_HISTORY_FILE ?? join(homedir(), '.cawdev', 'history.json');
30
+ }
31
+
32
+ function key(url) {
33
+ return String(url).replace(/\/+$/, '');
34
+ }
35
+
36
+ async function readAll() {
37
+ try {
38
+ const parsed = JSON.parse(await readFile(historyFile(), 'utf8'));
39
+ return parsed && typeof parsed === 'object' ? parsed : {};
40
+ } catch {
41
+ // No file, unreadable, or half-written. The answer to all three is the same
42
+ // and it is not an error: you have no history yet.
43
+ return {};
44
+ }
45
+ }
46
+
47
+ /** What was sent to one instance, oldest first. */
48
+ export async function loadHistory(url) {
49
+ const lines = (await readAll())[key(url)];
50
+ return Array.isArray(lines) ? lines.filter((line) => typeof line === 'string') : [];
51
+ }
52
+
53
+ /**
54
+ * Remember one more line.
55
+ *
56
+ * Read-modify-write per line rather than a flush at exit, because a terminal is
57
+ * quit by closing the window at least as often as by pressing `q`, and history
58
+ * that only survives a polite exit is history that is not there when you want
59
+ * it. The file is a few kilobytes and a line is sent a few times a minute.
60
+ *
61
+ * 0600 like the session file, and forced after writing for the same reason:
62
+ * `writeFile`'s mode is masked by the umask. What people type into an agent is
63
+ * not as sensitive as a session cookie, but it is nobody else's either.
64
+ */
65
+ export async function pushHistory(url, line) {
66
+ const text = String(line ?? '').trim();
67
+ if (!text) {
68
+ return;
69
+ }
70
+ const all = await readAll();
71
+ const kept = Array.isArray(all[key(url)]) ? all[key(url)] : [];
72
+ if (kept[kept.length - 1] !== text) {
73
+ kept.push(text);
74
+ }
75
+ all[key(url)] = kept.slice(-KEPT);
76
+ await mkdir(dirname(historyFile()), { recursive: true, mode: 0o700 });
77
+ await writeFile(historyFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
78
+ await chmod(historyFile(), 0o600).catch(() => undefined);
79
+ }
80
+
81
+ /** Forget it — what `/logout` does, since it is the same person leaving. */
82
+ export async function clearHistory(url) {
83
+ const all = await readAll();
84
+ if (!(key(url) in all)) {
85
+ return;
86
+ }
87
+ delete all[key(url)];
88
+ await mkdir(dirname(historyFile()), { recursive: true, mode: 0o700 });
89
+ await writeFile(historyFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
90
+ await chmod(historyFile(), 0o600).catch(() => undefined);
91
+ }
@@ -0,0 +1,355 @@
1
+ // The input line, borrowed from Claude Code — R83.
2
+ //
3
+ // R81 took Claude Code's biggest idea — print into scrollback, pin a small live
4
+ // region. The rest of what makes that CLI feel like one program rather than a
5
+ // script with a prompt lives here, in the line you type into: a caret you can
6
+ // move, `/` filtering the command list while you type instead of after you have
7
+ // guessed the name, up-arrow for what you last sent, and a paste that arrives as
8
+ // ONE thing rather than four hundred lines of transcript.
9
+ //
10
+ // **Everything in this file is pure.** A line editor whose only test is somebody
11
+ // sitting at a terminal is a line editor with an off-by-one in it: the caret
12
+ // arithmetic here is the same kind that put `2/2sessions` on screen in R62, and
13
+ // the answer is the same one — make it a function and read what it draws.
14
+ //
15
+ // Zero dependencies, so this is a string and an index rather than readline.
16
+
17
+ import { clip, painter, visibleWidth } from '../lib/ansi.mjs';
18
+
19
+ const ESC = '\x1b';
20
+
21
+ /**
22
+ * One line being typed, and where the caret is in it.
23
+ *
24
+ * A real editor rather than "append and backspace", because the last row of the
25
+ * footer is where an answer to a question gets written and a typo forty
26
+ * characters back should not cost the whole sentence.
27
+ */
28
+ export class Line {
29
+ constructor(text = '') {
30
+ this.text = String(text);
31
+ this.at = this.text.length;
32
+ }
33
+
34
+ insert(what) {
35
+ this.text = this.text.slice(0, this.at) + what + this.text.slice(this.at);
36
+ this.at += what.length;
37
+ }
38
+
39
+ backspace() {
40
+ if (this.at === 0) return;
41
+ this.text = this.text.slice(0, this.at - 1) + this.text.slice(this.at);
42
+ this.at -= 1;
43
+ }
44
+
45
+ /** Forward delete, which is what a terminal sends for the Delete key. */
46
+ forwardDelete() {
47
+ this.text = this.text.slice(0, this.at) + this.text.slice(this.at + 1);
48
+ }
49
+
50
+ left() {
51
+ this.at = Math.max(0, this.at - 1);
52
+ }
53
+
54
+ right() {
55
+ this.at = Math.min(this.text.length, this.at + 1);
56
+ }
57
+
58
+ home() {
59
+ this.at = 0;
60
+ }
61
+
62
+ end() {
63
+ this.at = this.text.length;
64
+ }
65
+
66
+ /** Ctrl+U — everything before the caret. */
67
+ killToStart() {
68
+ this.text = this.text.slice(this.at);
69
+ this.at = 0;
70
+ }
71
+
72
+ /** Ctrl+W — the word before the caret, trailing spaces and all. */
73
+ killWord() {
74
+ const before = this.text.slice(0, this.at).replace(/\s*\S*$/, '');
75
+ this.text = before + this.text.slice(this.at);
76
+ this.at = before.length;
77
+ }
78
+
79
+ set(text) {
80
+ this.text = String(text ?? '');
81
+ this.at = this.text.length;
82
+ }
83
+
84
+ /**
85
+ * The part of the line that fits, and where the caret is inside it.
86
+ *
87
+ * **The caret is what has to stay on screen**, not the start of the line. A
88
+ * window anchored at character zero means typing past the width types into a
89
+ * line you cannot see, which is the failure a one-line editor has to avoid
90
+ * before it has any other features.
91
+ */
92
+ window(width) {
93
+ if (width <= 0 || this.text.length < width) {
94
+ return { text: this.text, at: this.at };
95
+ }
96
+ // Two thirds ahead of the caret, so there is room to keep typing before it
97
+ // slides again, and the tail of a long line is what you are usually reading.
98
+ const from = Math.max(0, Math.min(
99
+ this.at - Math.floor((width * 2) / 3),
100
+ this.text.length - width + 1,
101
+ ));
102
+ return { text: this.text.slice(from, from + width), at: this.at - from };
103
+ }
104
+
105
+ /** The line with a block where the caret is. */
106
+ render(width, ink = painter(3)) {
107
+ const { text, at } = this.window(width);
108
+ const under = text.slice(at, at + 1) || ' ';
109
+ return `${text.slice(0, at)}${ink.reverse(under)}${text.slice(at + 1)}`;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * What you last sent, and the walk back through it.
115
+ *
116
+ * The line being typed is kept as the DRAFT: walking up to something older and
117
+ * back down again returns what you had written, because losing it is what makes
118
+ * people stop pressing up.
119
+ */
120
+ export class History {
121
+ constructor(entries = []) {
122
+ this.entries = [...entries];
123
+ this.at = this.entries.length;
124
+ this.draft = '';
125
+ }
126
+
127
+ /** Remember something that was actually sent. */
128
+ add(line) {
129
+ const text = String(line ?? '').trim();
130
+ // Consecutive repeats are one entry: sending the same prompt twice is
131
+ // normal and filling the history with it is not useful.
132
+ if (text && this.entries[this.entries.length - 1] !== text) {
133
+ this.entries.push(text);
134
+ }
135
+ this.at = this.entries.length;
136
+ this.draft = '';
137
+ }
138
+
139
+ /** Older. Returns null when there is nothing older, so the line is left alone. */
140
+ back(current = '') {
141
+ if (this.at === this.entries.length) {
142
+ this.draft = current;
143
+ }
144
+ if (this.at === 0) {
145
+ return null;
146
+ }
147
+ this.at -= 1;
148
+ return this.entries[this.at];
149
+ }
150
+
151
+ /** Newer, ending at the draft you were writing. */
152
+ forward() {
153
+ if (this.at >= this.entries.length) {
154
+ return null;
155
+ }
156
+ this.at += 1;
157
+ return this.at === this.entries.length ? this.draft : this.entries[this.at];
158
+ }
159
+
160
+ reset() {
161
+ this.at = this.entries.length;
162
+ this.draft = '';
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The commands that match what has been typed so far — R83.
168
+ *
169
+ * **Filtered while you type, not after you have guessed the name.** Guessing a
170
+ * command name and being told `no such command` is a step, and it is the step
171
+ * this removes.
172
+ *
173
+ * A line with an argument in it has stopped being a name, so nothing matches:
174
+ * completing `/runs foo` to `/runs` would eat the argument.
175
+ */
176
+ export function completions(text, commands) {
177
+ const typed = String(text ?? '');
178
+ if (!typed.startsWith('/') || /\s/.test(typed)) {
179
+ return [];
180
+ }
181
+ const word = typed.slice(1).toLowerCase();
182
+ return commands.filter(([name]) => name.slice(1).toLowerCase().startsWith(word));
183
+ }
184
+
185
+ /**
186
+ * What Tab completes to: the longest prefix every match shares.
187
+ *
188
+ * One match completes it whole. Several complete as far as they agree, which is
189
+ * the behaviour every shell has and nobody has to be taught.
190
+ */
191
+ export function commonPrefix(names) {
192
+ if (!names.length) return '';
193
+ let prefix = names[0];
194
+ for (const name of names.slice(1)) {
195
+ while (prefix && !name.toLowerCase().startsWith(prefix.toLowerCase())) {
196
+ prefix = prefix.slice(0, -1);
197
+ }
198
+ }
199
+ return prefix;
200
+ }
201
+
202
+ /**
203
+ * A paste, as one line — R83.
204
+ *
205
+ * Four hundred lines of somebody's stack trace scrolling past is the transcript
206
+ * this program exists to keep, buried by the thing that was meant to go INTO it.
207
+ * So a multi-line paste becomes a placeholder that says what it is, and the full
208
+ * text is sent.
209
+ *
210
+ * **The threshold is "more than one line" rather than "a few".** The input is
211
+ * one row of a footer, and a newline in it has nowhere to go: two lines already
212
+ * cannot be drawn honestly, so two lines is already a placeholder.
213
+ */
214
+ export function pasteMark(text) {
215
+ const lines = String(text).split('\n').length;
216
+ return `[pasted, ${lines} lines]`;
217
+ }
218
+
219
+ /**
220
+ * A held paste, and the text that goes back in its place when it is sent.
221
+ *
222
+ * The map is the process's, so a placeholder recalled from a PREVIOUS launch's
223
+ * history has nothing to expand to and goes as the literal text it looks like.
224
+ * That is the honest outcome: the paste was never stored, only the fact of it.
225
+ */
226
+ export class Pastes {
227
+ constructor() {
228
+ this.held = new Map();
229
+ }
230
+
231
+ /** The placeholder to type, having remembered what it stands for. */
232
+ hold(text) {
233
+ let mark = pasteMark(text);
234
+ if (this.held.has(mark) && this.held.get(mark) !== text) {
235
+ // Two pastes of the same length in one line. Rare, and silently sending
236
+ // the first one twice would be much worse than an ugly suffix.
237
+ let nth = 2;
238
+ while (this.held.has(`${mark.slice(0, -1)} #${nth}]`)) nth += 1;
239
+ mark = `${mark.slice(0, -1)} #${nth}]`;
240
+ }
241
+ this.held.set(mark, text);
242
+ return mark;
243
+ }
244
+
245
+ /** What was typed, with every placeholder in it put back. */
246
+ expand(text) {
247
+ let out = String(text);
248
+ for (const [mark, held] of this.held) {
249
+ out = out.split(mark).join(held);
250
+ }
251
+ return out;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * The rows of the completion list, drawn directly above the line being typed.
257
+ *
258
+ * Above it rather than in the overlay at the top, because a list of what you are
259
+ * halfway through typing belongs next to what you are typing. Each row carries
260
+ * its one-line description: the list exists so that the name is not something
261
+ * you have to know.
262
+ */
263
+ export function completionLines(matches, at, width, ink = painter(3)) {
264
+ return matches.map(([name, what], index) => {
265
+ const chosen = index === at;
266
+ const head = `${chosen ? ink.bold('❯') : ' '} `;
267
+ const shown = `${head}${chosen ? ink.accent(name) : ink.text(name)}`;
268
+ const room = Math.max(0, width - visibleWidth(shown) - 3);
269
+ return clip(`${shown} ${ink.muted(clip(what, room))}`, width);
270
+ });
271
+ }
272
+
273
+ /**
274
+ * One chunk of stdin, split into the keystrokes it actually contains — R81, and
275
+ * a paste is one of them since R83.
276
+ *
277
+ * **A `data` event is not a keypress.** It is however many bytes arrived
278
+ * together, and the client used to treat the whole chunk as one key: it compared
279
+ * it against `'\r'`, found `"help\r"`, and appended the lot to the prompt as
280
+ * text.
281
+ *
282
+ * An escape sequence is ONE key. `ESC[B` is Down, not three characters, and a
283
+ * bare `ESC` is Escape — so a sequence is taken whole when one is there and the
284
+ * escape stands alone when it is not.
285
+ *
286
+ * **And a bracketed paste is one key too.** The terminal wraps a paste in
287
+ * `ESC[200~` and `ESC[201~` when asked to, which is the only way to tell a
288
+ * pasted newline from somebody pressing enter — without it, pasting a paragraph
289
+ * sends the first line and types the rest into whatever opens next.
290
+ */
291
+ export function keysIn(chunk) {
292
+ return new KeyStream().push(chunk);
293
+ }
294
+
295
+ const PASTE_START = `${ESC}[200~`;
296
+ const PASTE_END = `${ESC}[201~`;
297
+
298
+ /**
299
+ * `keysIn` across chunks, because a paste is not one of them.
300
+ *
301
+ * A pasted file arrives in however many reads the pipe felt like; the start
302
+ * marker can be in one and the end marker three chunks later. Everything between
303
+ * them is held here rather than being handed out as keystrokes — which is
304
+ * exactly what it must not be.
305
+ */
306
+ export class KeyStream {
307
+ constructor() {
308
+ this.pasting = null;
309
+ }
310
+
311
+ push(chunk) {
312
+ const keys = [];
313
+ let text = String(chunk);
314
+
315
+ while (text.length) {
316
+ if (this.pasting !== null) {
317
+ const end = text.indexOf(PASTE_END);
318
+ if (end === -1) {
319
+ this.pasting += text;
320
+ return keys;
321
+ }
322
+ keys.push({ paste: this.pasting + text.slice(0, end) });
323
+ this.pasting = null;
324
+ text = text.slice(end + PASTE_END.length);
325
+ continue;
326
+ }
327
+ const start = text.indexOf(PASTE_START);
328
+ const upTo = start === -1 ? text.length : start;
329
+ for (const key of plainKeys(text.slice(0, upTo))) {
330
+ keys.push(key);
331
+ }
332
+ if (start === -1) {
333
+ return keys;
334
+ }
335
+ this.pasting = '';
336
+ text = text.slice(start + PASTE_START.length);
337
+ }
338
+ return keys;
339
+ }
340
+ }
341
+
342
+ function plainKeys(text) {
343
+ const keys = [];
344
+ for (let at = 0; at < text.length;) {
345
+ if (text[at] === ESC) {
346
+ const sequence = /^\x1b(\[[0-9;?]*[a-zA-Z~]|O[A-Z]|.)?/.exec(text.slice(at));
347
+ keys.push(sequence[0]);
348
+ at += sequence[0].length;
349
+ continue;
350
+ }
351
+ keys.push(text[at]);
352
+ at += 1;
353
+ }
354
+ return keys;
355
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "url": "http://localhost:4200",
3
+ "name": "macbook",
4
+ "browser": true,
5
+ "maxSessions": 4,
6
+ "projects": {
7
+ "dycrypt": "/Users/dali/Documents/Dev/dycrypt",
8
+ "medymo": "/Users/dali/Documents/Dev/medymo",
9
+ "saasng": "/Users/dali/Documents/Dev/saasng",
10
+ "cawdev": {
11
+ "workspaces": [
12
+ "/Users/dali/Documents/Dev/cawdev-dev",
13
+ "/Users/dali/Documents/Dev/cawdev-dev-2"
14
+ ],
15
+ "allowedTools": [
16
+ "Bash"
17
+ ],
18
+ "grantable": [
19
+ "Bash"
20
+ ]
21
+ },
22
+ "ngtechnologies-website": {
23
+ "path": "/Users/dali/Documents/Dev/ng-technologies-website",
24
+ "allowedTools": [
25
+ "Bash"
26
+ ],
27
+ "grantable": [
28
+ "Bash"
29
+ ]
30
+ }
31
+ },
32
+ "allowedTools": [
33
+ "Bash(npm *)",
34
+ "Bash(npx *)",
35
+ "Bash(ng *)",
36
+ "Bash(mvn *)",
37
+ "Bash(node *)",
38
+ "Bash(env *)"
39
+ ],
40
+ "grantable": [
41
+ "Bash(npm *)",
42
+ "Bash(npx *)",
43
+ "Bash(ng *)",
44
+ "Bash(mvn *)",
45
+ "Bash(node *)",
46
+ "Bash(env *)"
47
+ ]
48
+ }