cawdev-cli 0.9.0 → 1.0.0-beta
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 +4 -171
- package/lib/run-plugin.mjs +40 -0
- package/lib/usage-report.mjs +27 -0
- package/lib/version.mjs +48 -0
- package/package.json +1 -1
- package/runner/README.md +56 -0
- package/runner/attach.mjs +442 -1
- package/runner/banner.mjs +11 -1
- package/runner/bootstrap.mjs +70 -35
- package/runner/cawdev.mjs +215 -19
- package/runner/configure.mjs +412 -0
- package/runner/paths.mjs +254 -0
- package/runner/runner.mjs +829 -131
- package/runner/token-store.mjs +14 -2
package/runner/attach.mjs
CHANGED
|
@@ -45,8 +45,17 @@
|
|
|
45
45
|
// Zero dependencies, so this is ANSI escapes and `setRawMode` rather than a
|
|
46
46
|
// curses library.
|
|
47
47
|
|
|
48
|
+
import { spawn } from 'node:child_process';
|
|
48
49
|
import { connect } from 'node:net';
|
|
50
|
+
import { basename, dirname } from 'node:path';
|
|
49
51
|
import { listSockets, socketPathFor } from './control.mjs';
|
|
52
|
+
import {
|
|
53
|
+
addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
|
|
54
|
+
setAcceptsRulesFromConsole, setAgentEnabled, slugsOf,
|
|
55
|
+
} from './configure.mjs';
|
|
56
|
+
import {
|
|
57
|
+
TYPE_A_PATH, absolute, completePath, describe, inspectPath, listDirectories,
|
|
58
|
+
} from './paths.mjs';
|
|
50
59
|
import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
51
60
|
import { oneLine } from './brand.mjs';
|
|
52
61
|
import { Scrollback } from './scrollback.mjs';
|
|
@@ -531,6 +540,10 @@ export function footerLines(state, width, ink = painter(3)) {
|
|
|
531
540
|
// both: a key list under a half-typed prompt is a list of keys that would
|
|
532
541
|
// land in the prompt.
|
|
533
542
|
if (input) {
|
|
543
|
+
if (input.preview) {
|
|
544
|
+
// R289: what the path on the line IS, above the line being typed.
|
|
545
|
+
lines.push(padVisible(clip(` ${ink.muted(input.preview)}`, width), width));
|
|
546
|
+
}
|
|
534
547
|
lines.push(padVisible(clip(` ${ink.accent(input.label)} ${input.text}`, width), width));
|
|
535
548
|
} else if (keys.length || status) {
|
|
536
549
|
lines.push(padVisible(clip(` ${keyList(keys, status, width - 1, ink)}`, width), width));
|
|
@@ -645,12 +658,67 @@ async function runSignIn(session, ink = painter()) {
|
|
|
645
658
|
return outcome;
|
|
646
659
|
}
|
|
647
660
|
|
|
661
|
+
/** What Esc throws out of a config step — R288. Compared by identity, never caught by message. */
|
|
662
|
+
export const CANCELLED = Symbol('cancelled');
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* A configure.mjs prompt, split into what is PRINTED and what the footer keeps
|
|
666
|
+
* — R289. `confirm` asks "…long sentence… [Y/n] ", and a footer line that
|
|
667
|
+
* clips at the width would cut the sentence and keep the brackets. So anything
|
|
668
|
+
* longer than a label is said in the transcript and the label is the trailing
|
|
669
|
+
* `[default]` with the caret, or just the caret. A short prompt stays whole.
|
|
670
|
+
*/
|
|
671
|
+
export function splitPrompt(prompt, limit = 40) {
|
|
672
|
+
const text = String(prompt ?? '').trim();
|
|
673
|
+
const tail = /\s*(\[[^\]]*\])$/.exec(text);
|
|
674
|
+
const bracket = tail ? tail[1] : '';
|
|
675
|
+
const question = tail ? text.slice(0, tail.index).trim() : text;
|
|
676
|
+
if (text.length <= limit) {
|
|
677
|
+
return { said: '', label: `${text} ▸` };
|
|
678
|
+
}
|
|
679
|
+
return { said: question, label: `${bracket ? `${bracket} ` : ''}▸` };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** The agents the config screen offers a switch for, whether or not they are on. */
|
|
683
|
+
const KNOWN_AGENTS = ['claude', 'agy'];
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The config screen's rows, with the state of each thing on the row — R288.
|
|
687
|
+
*
|
|
688
|
+
* Pure, so the wording can be read in a test: "Disable claude" when claude is
|
|
689
|
+
* on and "Enable claude" when it is not, because a row that always said
|
|
690
|
+
* "enable" would ask the person to remember what the file says. The hint is
|
|
691
|
+
* where the current value lives; the label is the change.
|
|
692
|
+
*/
|
|
693
|
+
export function configMenu(file, runner = {}) {
|
|
694
|
+
const slugs = slugsOf(file);
|
|
695
|
+
const agents = agentsOf(file);
|
|
696
|
+
const rulesOn = file.acceptsRulesFromConsole === true;
|
|
697
|
+
const checkouts = slugs.map((slug) => `${slug}: ${pathsOf(file.projects[slug]).length}`).join(', ');
|
|
698
|
+
return new Select({
|
|
699
|
+
kind: 'config',
|
|
700
|
+
title: `this machine — ${runner.name ?? file.name ?? 'cawdev'}`,
|
|
701
|
+
rows: [
|
|
702
|
+
{ id: 'add-project', label: 'Add a project', hint: slugs.length ? `serves ${slugs.join(', ')}` : 'serves nothing yet' },
|
|
703
|
+
{ id: 'add-workspace', label: 'Add a workspace to a project', hint: checkouts || 'no projects to add one to' },
|
|
704
|
+
...[...new Set([...KNOWN_AGENTS, ...agents])].map((command) => (agents.includes(command)
|
|
705
|
+
? { id: `agent:${command}`, label: `Disable ${command}`, hint: 'spawned here now' }
|
|
706
|
+
: { id: `agent:${command}`, label: `Enable ${command}`, hint: 'not spawned here' })),
|
|
707
|
+
rulesOn
|
|
708
|
+
? { id: 'rules', label: 'Stop accepting permission rules from the console', hint: 'on now' }
|
|
709
|
+
: { id: 'rules', label: 'Accept permission rules from the console', hint: 'off now' },
|
|
710
|
+
{ id: 'show', label: "Show this machine's config", hint: runner.configPath ?? '' },
|
|
711
|
+
],
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
648
715
|
/** The slash commands, and the one place they are described. */
|
|
649
716
|
const COMMANDS = [
|
|
650
717
|
['/help', 'this list'],
|
|
651
718
|
['/login', 'sign in through the browser'],
|
|
652
719
|
['/logout', 'forget the stored session on this machine'],
|
|
653
720
|
['/runs', 'the run list — the same as L'],
|
|
721
|
+
['/config', "this machine's config — the same as c"],
|
|
654
722
|
['/cancel', 'cancel the session you are watching'],
|
|
655
723
|
['/log', "the daemon's own log, on or off"],
|
|
656
724
|
['/quit', 'stop the runner and leave (--leave-running keeps it up)'],
|
|
@@ -749,6 +817,16 @@ export class Attached {
|
|
|
749
817
|
this.settled = new Set();
|
|
750
818
|
/** Ctrl+C, armed. See {@link onInterrupt}. */
|
|
751
819
|
this.interrupting = false;
|
|
820
|
+
/**
|
|
821
|
+
* The config walk in progress, if one is — R288.
|
|
822
|
+
*
|
|
823
|
+
* `answer` resolves the question it is waiting on — the line editor or
|
|
824
|
+
* the picker hands its result here instead of to a session — and `cancel`
|
|
825
|
+
* rejects it with {@link CANCELLED}, which is what unwinds the whole step
|
|
826
|
+
* on Esc. One walk at a time, because two writing one file is R41 on a
|
|
827
|
+
* laptop.
|
|
828
|
+
*/
|
|
829
|
+
this.configuring = null;
|
|
752
830
|
}
|
|
753
831
|
|
|
754
832
|
/**
|
|
@@ -1306,6 +1384,8 @@ export class Attached {
|
|
|
1306
1384
|
case 'l':
|
|
1307
1385
|
case 'L':
|
|
1308
1386
|
return this.openList();
|
|
1387
|
+
case 'c':
|
|
1388
|
+
return void this.openConfig();
|
|
1309
1389
|
case 'g':
|
|
1310
1390
|
this.showLog = !this.showLog;
|
|
1311
1391
|
return this.note(this.showLog ? "printing the daemon's log" : 'printing the session');
|
|
@@ -1386,6 +1466,8 @@ export class Attached {
|
|
|
1386
1466
|
this.deciding = null;
|
|
1387
1467
|
this.history.reset();
|
|
1388
1468
|
this.dirty = true;
|
|
1469
|
+
// A config question closed this way is cancelled, not left waiting — R288.
|
|
1470
|
+
this.configuring?.cancel?.();
|
|
1389
1471
|
return open;
|
|
1390
1472
|
}
|
|
1391
1473
|
|
|
@@ -1440,6 +1522,11 @@ export class Attached {
|
|
|
1440
1522
|
|
|
1441
1523
|
/** The commands still matching what is typed, and which one is highlighted. */
|
|
1442
1524
|
matching() {
|
|
1525
|
+
if (this.mode === 'typing' && this.input.path) {
|
|
1526
|
+
// R289: the directories that would complete the path, drawn where the
|
|
1527
|
+
// commands are drawn for a `/`.
|
|
1528
|
+
return { rows: this.input.path.rows, at: this.input.path.at };
|
|
1529
|
+
}
|
|
1443
1530
|
if (this.mode !== 'typing' || this.input.kind !== 'command') {
|
|
1444
1531
|
return { rows: [], at: 0 };
|
|
1445
1532
|
}
|
|
@@ -1460,6 +1547,7 @@ export class Attached {
|
|
|
1460
1547
|
return this.note('');
|
|
1461
1548
|
}
|
|
1462
1549
|
const back = this.input.back;
|
|
1550
|
+
const kind = this.input.kind;
|
|
1463
1551
|
this.input = null;
|
|
1464
1552
|
this.history.reset();
|
|
1465
1553
|
if (back) {
|
|
@@ -1469,6 +1557,13 @@ export class Attached {
|
|
|
1469
1557
|
this.mode = 'keys';
|
|
1470
1558
|
this.answering = null;
|
|
1471
1559
|
this.deciding = null;
|
|
1560
|
+
if (kind === 'config') {
|
|
1561
|
+
// Esc from a config question cancels the STEP, not just the line —
|
|
1562
|
+
// R288. The walk it was in is waiting on this answer and would
|
|
1563
|
+
// otherwise wait for ever.
|
|
1564
|
+
this.configuring?.cancel?.();
|
|
1565
|
+
return this.note('');
|
|
1566
|
+
}
|
|
1472
1567
|
return this.note('cancelled');
|
|
1473
1568
|
}
|
|
1474
1569
|
|
|
@@ -1483,6 +1578,10 @@ export class Attached {
|
|
|
1483
1578
|
}
|
|
1484
1579
|
|
|
1485
1580
|
if (key === '\t') {
|
|
1581
|
+
if (this.input.path) {
|
|
1582
|
+
this.completeThePath();
|
|
1583
|
+
return undefined;
|
|
1584
|
+
}
|
|
1486
1585
|
if (rows.length) {
|
|
1487
1586
|
// As far as they agree, which is what every shell does and nobody has
|
|
1488
1587
|
// to be taught. One match completes it whole.
|
|
@@ -1494,6 +1593,11 @@ export class Attached {
|
|
|
1494
1593
|
}
|
|
1495
1594
|
|
|
1496
1595
|
if (key === `${ESC}[A` || key === `${ESC}OA` || key === '\x10') {
|
|
1596
|
+
if (this.input.path && rows.length) {
|
|
1597
|
+
this.input.path.at = (at - 1 + rows.length) % rows.length;
|
|
1598
|
+
this.dirty = true;
|
|
1599
|
+
return undefined;
|
|
1600
|
+
}
|
|
1497
1601
|
if (rows.length && this.input.showing !== false) {
|
|
1498
1602
|
this.input.at = (at - 1 + rows.length) % rows.length;
|
|
1499
1603
|
this.dirty = true;
|
|
@@ -1507,6 +1611,11 @@ export class Attached {
|
|
|
1507
1611
|
return undefined;
|
|
1508
1612
|
}
|
|
1509
1613
|
if (key === `${ESC}[B` || key === `${ESC}OB` || key === '\x0e') {
|
|
1614
|
+
if (this.input.path && rows.length) {
|
|
1615
|
+
this.input.path.at = (at + 1) % rows.length;
|
|
1616
|
+
this.dirty = true;
|
|
1617
|
+
return undefined;
|
|
1618
|
+
}
|
|
1510
1619
|
if (rows.length && this.input.showing !== false) {
|
|
1511
1620
|
this.input.at = (at + 1) % rows.length;
|
|
1512
1621
|
this.dirty = true;
|
|
@@ -1547,6 +1656,9 @@ export class Attached {
|
|
|
1547
1656
|
return undefined;
|
|
1548
1657
|
}
|
|
1549
1658
|
this.dirty = true;
|
|
1659
|
+
if (this.input.path) {
|
|
1660
|
+
void this.refreshPath();
|
|
1661
|
+
}
|
|
1550
1662
|
return undefined;
|
|
1551
1663
|
}
|
|
1552
1664
|
|
|
@@ -1559,6 +1671,14 @@ export class Attached {
|
|
|
1559
1671
|
this.mode = 'keys';
|
|
1560
1672
|
this.history.reset();
|
|
1561
1673
|
|
|
1674
|
+
if (kind === 'config') {
|
|
1675
|
+
// Before the empty-line rule, on purpose: every config question shows a
|
|
1676
|
+
// default in its label, and enter on an empty line TAKES it — R288.
|
|
1677
|
+
// Not remembered either; a path typed once is not a prompt worth
|
|
1678
|
+
// offering back next launch.
|
|
1679
|
+
this.configuring?.answer?.(this.pastes.expand(typed));
|
|
1680
|
+
return this.note('');
|
|
1681
|
+
}
|
|
1562
1682
|
if (!typed || typed === '/') {
|
|
1563
1683
|
// Nothing typed is not an answer, and a question that was open is still
|
|
1564
1684
|
// open — so an empty line goes back to it rather than dropping it.
|
|
@@ -1644,6 +1764,9 @@ export class Attached {
|
|
|
1644
1764
|
// is being written when none is is one the next reader will believe.
|
|
1645
1765
|
this.answering = null;
|
|
1646
1766
|
this.deciding = null;
|
|
1767
|
+
if (select.kind === 'config') {
|
|
1768
|
+
this.configuring?.cancel?.();
|
|
1769
|
+
}
|
|
1647
1770
|
// Escape leaves without changing anything, which is the promise the key
|
|
1648
1771
|
// makes everywhere else.
|
|
1649
1772
|
return this.note('');
|
|
@@ -1685,6 +1808,11 @@ export class Attached {
|
|
|
1685
1808
|
}
|
|
1686
1809
|
return void this.allow(row.id);
|
|
1687
1810
|
}
|
|
1811
|
+
if (select.kind === 'config') {
|
|
1812
|
+
// R288: the walk asked, the walk gets the row. Nothing is decided here.
|
|
1813
|
+
this.configuring?.answer?.(row);
|
|
1814
|
+
return undefined;
|
|
1815
|
+
}
|
|
1688
1816
|
return undefined;
|
|
1689
1817
|
}
|
|
1690
1818
|
|
|
@@ -1696,6 +1824,14 @@ export class Attached {
|
|
|
1696
1824
|
*/
|
|
1697
1825
|
onLine(text) {
|
|
1698
1826
|
const typed = String(text).trim();
|
|
1827
|
+
if (this.input?.kind === 'config' && !this.select) {
|
|
1828
|
+
// The plain path's half of R288: a config question waiting on a line
|
|
1829
|
+
// takes the whole line, empty included, and nothing else reads it.
|
|
1830
|
+
this.input = null;
|
|
1831
|
+
this.mode = 'keys';
|
|
1832
|
+
this.configuring?.answer?.(typed);
|
|
1833
|
+
return undefined;
|
|
1834
|
+
}
|
|
1699
1835
|
if (this.select) {
|
|
1700
1836
|
const picked = pickFromLine(this.select, typed);
|
|
1701
1837
|
if (picked) {
|
|
@@ -1903,6 +2039,307 @@ export class Attached {
|
|
|
1903
2039
|
return false;
|
|
1904
2040
|
}
|
|
1905
2041
|
|
|
2042
|
+
// --- this machine's config — R288 ------------------------------------------
|
|
2043
|
+
|
|
2044
|
+
/**
|
|
2045
|
+
* `c` — configure this machine from inside the terminal.
|
|
2046
|
+
*
|
|
2047
|
+
* R283 built `cawdev config …` as a shell subcommand, and the first person to
|
|
2048
|
+
* launch the new CLI could not find it: they were HERE, attached, which is
|
|
2049
|
+
* where cawdev lives on a machine, and nothing here said a word about
|
|
2050
|
+
* configuration. So the same functions get a second door. The rows carry the
|
|
2051
|
+
* state each toggle is in, because a list that says "enable claude" when
|
|
2052
|
+
* claude is already on is a list asking somebody to guess.
|
|
2053
|
+
*
|
|
2054
|
+
* Every change goes through configure.mjs — one implementation, two doors —
|
|
2055
|
+
* and its questions come back through this class's own line editor and its
|
|
2056
|
+
* own picker: {@link askLine} and {@link askPick} are the adapters, and the
|
|
2057
|
+
* walk between them cannot tell readline from raw mode. The daemon is never
|
|
2058
|
+
* told anything over R52's socket; the file is written as the operator and,
|
|
2059
|
+
* for the two settings runner.mjs re-reads, the process is sent a SIGHUP
|
|
2060
|
+
* through the pid the hello already carries.
|
|
2061
|
+
*/
|
|
2062
|
+
async openConfig() {
|
|
2063
|
+
if (this.configuring) {
|
|
2064
|
+
return this.note('already configuring — finish that, or esc');
|
|
2065
|
+
}
|
|
2066
|
+
if (!this.runner) {
|
|
2067
|
+
return this.note('not connected to the daemon yet');
|
|
2068
|
+
}
|
|
2069
|
+
if (this.runner.configPath === undefined) {
|
|
2070
|
+
// An older daemon: it does not say which file it booted from, and
|
|
2071
|
+
// guessing at one is how a config screen edits the wrong machine.
|
|
2072
|
+
return this.note('this daemon is older than this client — q, then cawdev again, and c will work');
|
|
2073
|
+
}
|
|
2074
|
+
if (this.runner.configPath === null) {
|
|
2075
|
+
return this.note('this daemon was started from the environment, with no config file — nothing here to edit');
|
|
2076
|
+
}
|
|
2077
|
+
const file = await readConfigFile(this.runner.configPath);
|
|
2078
|
+
if (!file) {
|
|
2079
|
+
return this.note(`could not read ${this.runner.configPath}`);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
this.configuring = { answer: null, cancel: null };
|
|
2083
|
+
try {
|
|
2084
|
+
await this.configWalk(file);
|
|
2085
|
+
} catch (failure) {
|
|
2086
|
+
if (failure === CANCELLED) {
|
|
2087
|
+
this.note('nothing changed');
|
|
2088
|
+
} else {
|
|
2089
|
+
this.say(` ${this.ink.danger(failure.message)}`);
|
|
2090
|
+
this.note('nothing changed');
|
|
2091
|
+
}
|
|
2092
|
+
} finally {
|
|
2093
|
+
this.configuring = null;
|
|
2094
|
+
this.dirty = true;
|
|
2095
|
+
}
|
|
2096
|
+
return undefined;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/** One step of the screen: pick what to change, then change it. */
|
|
2100
|
+
async configWalk(file) {
|
|
2101
|
+
const ink = this.ink;
|
|
2102
|
+
const say = (line) => this.say(line);
|
|
2103
|
+
const configPath = this.runner.configPath;
|
|
2104
|
+
const url = this.session.url;
|
|
2105
|
+
const ask = { line: (prompt) => this.askLine(prompt) };
|
|
2106
|
+
const pick = (_ask, _say, select) => this.askPick(select).then((row) => ({ done: 'chosen', row }));
|
|
2107
|
+
|
|
2108
|
+
const chosen = await this.askPick(configMenu(file, this.runner));
|
|
2109
|
+
this.say('');
|
|
2110
|
+
this.say(`${ink.bold(ink.accent(' config '))} ${ink.text(chosen.label)}`);
|
|
2111
|
+
|
|
2112
|
+
if (chosen.id === 'show') {
|
|
2113
|
+
const shown = { ...file };
|
|
2114
|
+
if (shown.token) shown.token = `${String(shown.token).slice(0, 10)}…`;
|
|
2115
|
+
for (const line of JSON.stringify(shown, null, 2).split('\n')) {
|
|
2116
|
+
this.say(` ${ink.text(line)}`);
|
|
2117
|
+
}
|
|
2118
|
+
this.say(` ${ink.muted(configPath)}`);
|
|
2119
|
+
return this.note('');
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
if (chosen.id === 'add-project') {
|
|
2123
|
+
// Widens the machine's token on the platform, which only a person may do.
|
|
2124
|
+
// `requireSignIn` has already said so; a second note would bury it.
|
|
2125
|
+
if (!this.requireSignIn('add a project to this machine')) {
|
|
2126
|
+
return undefined;
|
|
2127
|
+
}
|
|
2128
|
+
await addProject({
|
|
2129
|
+
configPath, file, url, session: this.session, ask, say, ink, pick,
|
|
2130
|
+
askPath: (spec) => this.askPath(spec),
|
|
2131
|
+
inspect: this.options.inspect ?? inspectPath,
|
|
2132
|
+
clone: (gitUrl, path) => this.cloneInto(gitUrl, path),
|
|
2133
|
+
});
|
|
2134
|
+
return this.afterProjectsChanged();
|
|
2135
|
+
}
|
|
2136
|
+
if (chosen.id === 'add-workspace') {
|
|
2137
|
+
await addWorkspace({
|
|
2138
|
+
configPath, file, url, session: this.session, ask, say, ink, pick,
|
|
2139
|
+
askPath: (spec) => this.askPath(spec),
|
|
2140
|
+
inspect: this.options.inspect ?? inspectPath,
|
|
2141
|
+
clone: (gitUrl, path) => this.cloneInto(gitUrl, path),
|
|
2142
|
+
});
|
|
2143
|
+
return this.afterProjectsChanged();
|
|
2144
|
+
}
|
|
2145
|
+
if (chosen.id.startsWith('agent:')) {
|
|
2146
|
+
const command = chosen.id.slice('agent:'.length);
|
|
2147
|
+
const next = await setAgentEnabled({
|
|
2148
|
+
file, configPath, ask, say, ink, command, enabled: !agentsOf(file).includes(command),
|
|
2149
|
+
});
|
|
2150
|
+
this.runner.agents = agentsOf(next);
|
|
2151
|
+
return this.reloadDaemon(next);
|
|
2152
|
+
}
|
|
2153
|
+
if (chosen.id === 'rules') {
|
|
2154
|
+
const next = await setAcceptsRulesFromConsole({
|
|
2155
|
+
file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
|
|
2156
|
+
});
|
|
2157
|
+
this.runner.acceptsConsoleRules = next.acceptsRulesFromConsole === true;
|
|
2158
|
+
return this.reloadDaemon(next);
|
|
2159
|
+
}
|
|
2160
|
+
return this.note('');
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
/**
|
|
2164
|
+
* The two settings the daemon re-reads on SIGHUP, applied to the one this
|
|
2165
|
+
* terminal is attached to — R283's `reloadIfRunning` with the pid handed in,
|
|
2166
|
+
* since the hello already says which process this is.
|
|
2167
|
+
*/
|
|
2168
|
+
async reloadDaemon(next) {
|
|
2169
|
+
await reloadIfRunning(next.name, (line) => this.say(line), this.ink, {
|
|
2170
|
+
pidOf: async () => this.runner?.pid ?? null,
|
|
2171
|
+
// Injectable so a test can watch the signal without sending one.
|
|
2172
|
+
kill: this.options.kill ?? ((pid, signal) => process.kill(pid, signal)),
|
|
2173
|
+
});
|
|
2174
|
+
return this.note('');
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* Adding a project or a workspace is not hot-swappable — `config.projects`
|
|
2179
|
+
* is read once at boot — so this says what happens next rather than letting
|
|
2180
|
+
* the row above look like it took effect. The same sentence `cawdev --setup`
|
|
2181
|
+
* says, because it is the same fact.
|
|
2182
|
+
*/
|
|
2183
|
+
afterProjectsChanged() {
|
|
2184
|
+
const ink = this.ink;
|
|
2185
|
+
this.say(` ${ink.muted('This daemon is still on the config it booted with.')}`);
|
|
2186
|
+
this.say(` ${ink.muted('Press')} ${ink.text('q')} ${ink.muted('to stop it, and the next')} `
|
|
2187
|
+
+ `${ink.text('cawdev')} ${ink.muted('starts one on what you just set up.')}`);
|
|
2188
|
+
return this.note('saved — q, then cawdev, to serve it');
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
/**
|
|
2192
|
+
* One line, asked through this class's own editor — the `ask.line` shape
|
|
2193
|
+
* configure.mjs takes. Enter on an empty line resolves '' (the default the
|
|
2194
|
+
* label shows); Esc rejects with {@link CANCELLED}, which unwinds the step.
|
|
2195
|
+
*/
|
|
2196
|
+
askLine(prompt, { prefill = '', path = null } = {}) {
|
|
2197
|
+
return new Promise((resolve, reject) => {
|
|
2198
|
+
this.configuring.answer = (text) => {
|
|
2199
|
+
this.configuring.answer = null;
|
|
2200
|
+
this.configuring.cancel = null;
|
|
2201
|
+
resolve(text);
|
|
2202
|
+
};
|
|
2203
|
+
this.configuring.cancel = () => {
|
|
2204
|
+
this.configuring.answer = null;
|
|
2205
|
+
this.configuring.cancel = null;
|
|
2206
|
+
reject(CANCELLED);
|
|
2207
|
+
};
|
|
2208
|
+
const { said, label } = splitPrompt(prompt);
|
|
2209
|
+
if (said) {
|
|
2210
|
+
// R289: a question longer than a label is PRINTED, where it wraps and
|
|
2211
|
+
// stays, and the footer keeps the short part — a line the footer clips
|
|
2212
|
+
// is a question nobody read.
|
|
2213
|
+
this.say(` ${this.ink.text(said)}`);
|
|
2214
|
+
}
|
|
2215
|
+
if (this.plain && !said) {
|
|
2216
|
+
// No footer to carry the label through a pipe, so it is printed.
|
|
2217
|
+
this.say(` ${label}`);
|
|
2218
|
+
}
|
|
2219
|
+
this.type('config', label, prefill);
|
|
2220
|
+
if (path) {
|
|
2221
|
+
this.input.path = { ...path, rows: [], at: 0, preview: '', completion: null };
|
|
2222
|
+
void this.refreshPath();
|
|
2223
|
+
}
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/**
|
|
2228
|
+
* Where a checkout goes — R289. Suggestions first, each saying what is there
|
|
2229
|
+
* now; the last row opens a line PRE-FILLED with the first suggestion, with
|
|
2230
|
+
* tab completing directories and a preview under it. `~` expands. The path
|
|
2231
|
+
* comes back absolute; configure.mjs inspects it again before acting.
|
|
2232
|
+
*/
|
|
2233
|
+
async askPath({ question, suggestions, slug, gitUrl, inspect = inspectPath }) {
|
|
2234
|
+
this.say(` ${this.ink.text(question)}`);
|
|
2235
|
+
const rows = [];
|
|
2236
|
+
for (const each of suggestions) {
|
|
2237
|
+
const seen = await inspect(each.path, { gitUrl });
|
|
2238
|
+
rows.push({ id: each.path, label: each.path, hint: `${each.why} · ${describe(seen, slug)}` });
|
|
2239
|
+
}
|
|
2240
|
+
rows.push({ id: TYPE_A_PATH, label: 'Type a path', hint: 'pre-filled · tab completes · ~ works' });
|
|
2241
|
+
const chosen = await this.askPick(new Select({ title: question, rows }));
|
|
2242
|
+
if (chosen.id !== TYPE_A_PATH) {
|
|
2243
|
+
return chosen.id;
|
|
2244
|
+
}
|
|
2245
|
+
const typed = await this.askLine('path', {
|
|
2246
|
+
prefill: suggestions[0]?.path ?? '',
|
|
2247
|
+
path: { slug, gitUrl, inspect },
|
|
2248
|
+
});
|
|
2249
|
+
return absolute(typed || suggestions[0]?.path || '');
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/**
|
|
2253
|
+
* What the path on the line points at, recomputed as it is typed: the
|
|
2254
|
+
* directories that would complete it (drawn above the line, tab takes them)
|
|
2255
|
+
* and one line saying what is there (drawn under it). The inspection is
|
|
2256
|
+
* debounced and checked against the line afterwards, so a slow disk never
|
|
2257
|
+
* paints a verdict about a path that has since changed.
|
|
2258
|
+
*/
|
|
2259
|
+
async refreshPath() {
|
|
2260
|
+
const input = this.input;
|
|
2261
|
+
if (!input?.path) return;
|
|
2262
|
+
const text = input.line.text;
|
|
2263
|
+
const expanded = absolute(text);
|
|
2264
|
+
const completion = completePath(text, await listDirectories(
|
|
2265
|
+
text.endsWith('/') ? expanded : dirname(expanded),
|
|
2266
|
+
));
|
|
2267
|
+
if (this.input !== input || input.line.text !== text) return;
|
|
2268
|
+
input.path.completion = completion;
|
|
2269
|
+
input.path.rows = completion.matches
|
|
2270
|
+
.filter((match) => `${match}/` !== expanded && match !== expanded)
|
|
2271
|
+
.slice(0, 8)
|
|
2272
|
+
.map((match) => [`${basename(match)}/`, '']);
|
|
2273
|
+
input.path.at = Math.min(input.path.at, Math.max(0, input.path.rows.length - 1));
|
|
2274
|
+
this.dirty = true;
|
|
2275
|
+
|
|
2276
|
+
await new Promise((done) => setTimeout(done, 120));
|
|
2277
|
+
if (this.input !== input || input.line.text !== text) return;
|
|
2278
|
+
const seen = await input.path.inspect(expanded, { gitUrl: input.path.gitUrl });
|
|
2279
|
+
if (this.input !== input || input.line.text !== text) return;
|
|
2280
|
+
input.path.preview = `${expanded} — ${describe(seen, input.path.slug)}`;
|
|
2281
|
+
this.dirty = true;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/** Tab on a path: as far as the matches agree; cycle them when they do not. */
|
|
2285
|
+
completeThePath() {
|
|
2286
|
+
const input = this.input;
|
|
2287
|
+
const path = input?.path;
|
|
2288
|
+
if (!path?.completion) return;
|
|
2289
|
+
const { completed, matches } = path.completion;
|
|
2290
|
+
if (completed !== input.line.text) {
|
|
2291
|
+
input.line.set(completed);
|
|
2292
|
+
} else if (matches.length > 1) {
|
|
2293
|
+
const pick = matches[path.at % matches.length];
|
|
2294
|
+
input.line.set(`${pick}/`);
|
|
2295
|
+
path.at = (path.at + 1) % matches.length;
|
|
2296
|
+
}
|
|
2297
|
+
this.dirty = true;
|
|
2298
|
+
void this.refreshPath();
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/** One row, through the one select widget — R83's rule, kept. */
|
|
2302
|
+
askPick(select) {
|
|
2303
|
+
return new Promise((resolve, reject) => {
|
|
2304
|
+
this.configuring.answer = (row) => {
|
|
2305
|
+
this.configuring.answer = null;
|
|
2306
|
+
this.configuring.cancel = null;
|
|
2307
|
+
resolve(row);
|
|
2308
|
+
};
|
|
2309
|
+
this.configuring.cancel = () => {
|
|
2310
|
+
this.configuring.answer = null;
|
|
2311
|
+
this.configuring.cancel = null;
|
|
2312
|
+
reject(CANCELLED);
|
|
2313
|
+
};
|
|
2314
|
+
select.kind = 'config';
|
|
2315
|
+
this.openPicker(select);
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* `git clone` with its progress printed into the transcript rather than onto
|
|
2321
|
+
* a terminal in raw mode, and `GIT_TERMINAL_PROMPT=0` because a clone that
|
|
2322
|
+
* stops to ask for a password here would hang the screen with no prompt on
|
|
2323
|
+
* it: the credential helper may answer, a person cannot.
|
|
2324
|
+
*/
|
|
2325
|
+
cloneInto(gitUrl, path) {
|
|
2326
|
+
return new Promise((done, fail) => {
|
|
2327
|
+
const child = spawn('git', ['clone', '--progress', gitUrl, path], {
|
|
2328
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2329
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
2330
|
+
});
|
|
2331
|
+
const print = (chunk) => {
|
|
2332
|
+
for (const line of String(chunk).split(/\r?\n|\r/)) {
|
|
2333
|
+
if (line.trim()) this.say(` ${this.ink.muted(line)}`);
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2336
|
+
child.stdout.on('data', print);
|
|
2337
|
+
child.stderr.on('data', print);
|
|
2338
|
+
child.on('error', (failure) => fail(new Error(`Could not run git: ${failure.message}`)));
|
|
2339
|
+
child.on('close', (code) => (code === 0 ? done() : fail(new Error(`git clone exited ${code}.`))));
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
|
|
1906
2343
|
// --- slash commands ---------------------------------------------------------
|
|
1907
2344
|
|
|
1908
2345
|
async runCommand(text) {
|
|
@@ -1916,7 +2353,7 @@ export class Attached {
|
|
|
1916
2353
|
}
|
|
1917
2354
|
this.say('');
|
|
1918
2355
|
this.say(this.ink.bold(' keys'));
|
|
1919
|
-
this.say(this.ink.muted(' enter prompt · / command · L runs · 1-9 pick a run'));
|
|
2356
|
+
this.say(this.ink.muted(' enter prompt · / command · L runs · 1-9 pick a run · c config'));
|
|
1920
2357
|
this.say(this.ink.muted(' a answer · y/s/Y/n permission · x cancel · g log · q quit'));
|
|
1921
2358
|
this.say(this.ink.muted(' in a list: ↑↓ move · 1-9 pick · enter choose · esc leave'));
|
|
1922
2359
|
this.say(this.ink.muted(' while typing: ↑↓ history · tab complete · esc back · ctrl+c twice quits'));
|
|
@@ -1935,6 +2372,8 @@ export class Attached {
|
|
|
1935
2372
|
return this.note('signed out — /login to sign in again');
|
|
1936
2373
|
case 'runs':
|
|
1937
2374
|
return this.openList();
|
|
2375
|
+
case 'config':
|
|
2376
|
+
return void this.openConfig();
|
|
1938
2377
|
case 'cancel':
|
|
1939
2378
|
return void this.cancel();
|
|
1940
2379
|
case 'log':
|
|
@@ -2294,6 +2733,7 @@ export class Attached {
|
|
|
2294
2733
|
matches: this.input?.showing === false ? { rows: [], at: 0 } : this.matching(),
|
|
2295
2734
|
input: this.mode === 'typing' ? {
|
|
2296
2735
|
label: this.input.label,
|
|
2736
|
+
preview: this.input.path?.preview ?? '',
|
|
2297
2737
|
// The width the line has left, so the caret stays on screen when the
|
|
2298
2738
|
// text is longer than the terminal — see Line.window.
|
|
2299
2739
|
text: this.input.line.render(
|
|
@@ -2349,6 +2789,7 @@ export class Attached {
|
|
|
2349
2789
|
+ `${ink.muted('permission')}`);
|
|
2350
2790
|
}
|
|
2351
2791
|
parts.push(`${ink.text('x')} ${ink.muted('cancel')}`);
|
|
2792
|
+
parts.push(`${ink.text('c')} ${ink.muted('config')}`);
|
|
2352
2793
|
parts.push(`${ink.text('q')} ${ink.muted(this.stopsTheDaemon() ? 'stop' : 'quit')}`);
|
|
2353
2794
|
return parts;
|
|
2354
2795
|
}
|
package/runner/banner.mjs
CHANGED
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
// profile: a question is bounded here and nowhere else
|
|
20
20
|
// the browser R61, and the one line that says an agent may reach Chrome
|
|
21
21
|
//
|
|
22
|
+
// R283 adds two more, because both became things a machine can now be
|
|
23
|
+
// RECONFIGURED into without a restart (`cawdev config`, and the daemon's own
|
|
24
|
+
// SIGHUP handler in runner.mjs): which agent(s) this machine spawns, and —
|
|
25
|
+
// when it could be read — the CLI's own version, so a stale one is visible at
|
|
26
|
+
// the moment it would matter most, before anything has failed yet.
|
|
27
|
+
//
|
|
22
28
|
// It prints once, at boot. Anything that changes afterwards belongs in the
|
|
23
29
|
// log, not here.
|
|
24
30
|
|
|
@@ -31,7 +37,7 @@ import { padVisible } from '../lib/ansi.mjs';
|
|
|
31
37
|
* Lines rather than output, so the caller decides where it goes — and so this
|
|
32
38
|
* can be tested by reading it rather than by capturing a stream.
|
|
33
39
|
*/
|
|
34
|
-
export function bannerLines(config, ink) {
|
|
40
|
+
export function bannerLines(config, ink, cliVersionValue = null) {
|
|
35
41
|
const lines = ['', ...mark(ink, { tagline: 'the runner' }), ''];
|
|
36
42
|
|
|
37
43
|
const label = (text) => ink.muted(padVisible(text, 12));
|
|
@@ -39,6 +45,10 @@ export function bannerLines(config, ink) {
|
|
|
39
45
|
|
|
40
46
|
say('platform', ink.accent(config.url));
|
|
41
47
|
say('runner', ink.text(config.name));
|
|
48
|
+
if (cliVersionValue) {
|
|
49
|
+
say('cli', ink.muted(cliVersionValue));
|
|
50
|
+
}
|
|
51
|
+
say('agents', ink.text((config.agentCommands ?? []).join(', ') || 'claude'));
|
|
42
52
|
|
|
43
53
|
const projects = Object.entries(config.projects);
|
|
44
54
|
projects.forEach(([slug, project], at) => {
|