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.
- package/README.md +175 -0
- package/lib/ansi.mjs +224 -0
- package/lib/cawdev.mjs +104 -0
- package/lib/code-map.mjs +164 -0
- package/lib/harness-prompt.mjs +197 -0
- package/lib/roadmap-format.mjs +453 -0
- package/lib/run-plugin.mjs +119 -0
- package/lib/secrets.mjs +290 -0
- package/lib/stage-tools.mjs +384 -0
- package/lib/tool-line.mjs +92 -0
- package/lib/tool-rules.mjs +282 -0
- package/lib/transcript-batch.mjs +88 -0
- package/lib/usage-limit.mjs +80 -0
- package/lib/usage-report.mjs +142 -0
- package/lib/usage.mjs +119 -0
- package/mcp/README.md +273 -0
- package/mcp/orchestration-smoke.mjs +267 -0
- package/mcp/server.mjs +2163 -0
- package/mcp/smoke.mjs +220 -0
- package/package.json +20 -0
- package/runner/README.md +930 -0
- package/runner/attach.mjs +2397 -0
- package/runner/banner.mjs +106 -0
- package/runner/bootstrap.mjs +501 -0
- package/runner/brand.mjs +57 -0
- package/runner/cawdev.mjs +414 -0
- package/runner/control.mjs +225 -0
- package/runner/history.mjs +91 -0
- package/runner/input.mjs +355 -0
- package/runner/macbook-laptop.json +48 -0
- package/runner/runner.mjs +7445 -0
- package/runner/scrollback.mjs +165 -0
- package/runner/select.mjs +316 -0
- package/runner/session-store.mjs +78 -0
- package/runner/sign-in.mjs +210 -0
- package/runner/stub-agent.mjs +212 -0
- package/runner/token-store.mjs +107 -0
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# tools
|
|
2
|
+
|
|
3
|
+
Plain Node, **zero dependencies**, by design (see `CLAUDE.md`): stdio JSON-RPC
|
|
4
|
+
and spawning processes are Node's home turf, and a file with no dependencies is
|
|
5
|
+
one you can read before running it against your repositories.
|
|
6
|
+
|
|
7
|
+
Everything here reads `CAWDEV_URL`, `CAWDEV_TOKEN` and optionally
|
|
8
|
+
`CAWDEV_PROJECT` from the environment or a `.env` at the repository root —
|
|
9
|
+
**read on every call**, so editing `.env` needs no restart.
|
|
10
|
+
|
|
11
|
+
Mint a token in the console under **Agent tokens**. It needs `roadmap:write`
|
|
12
|
+
and `changelog:write` on the project you are exporting.
|
|
13
|
+
|
|
14
|
+
## The generated files
|
|
15
|
+
|
|
16
|
+
`ROADMAP.md` and `CHANGELOG.md` at the repository root are **exports**. Editing
|
|
17
|
+
them by hand is lost at the next export. Change the entry in the console or
|
|
18
|
+
through MCP, then regenerate:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
node tools/roadmap/export.mjs cawdev
|
|
22
|
+
node tools/changelog/export.mjs cawdev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Both are byte-stable: the same data produces the same bytes, so a regenerated
|
|
26
|
+
file shows a diff only when the roadmap actually changed. CI proves
|
|
27
|
+
export → import → export is a fixed point.
|
|
28
|
+
|
|
29
|
+
### Where a section goes in the file
|
|
30
|
+
|
|
31
|
+
An entry's `section` is a planning decision and lives in the database. The
|
|
32
|
+
**order** the sections appear in is prose layout, and lives beside the exporter
|
|
33
|
+
as `SECTION_ORDER` in `tools/roadmap/export.mjs`. `Considering` and `Declined`
|
|
34
|
+
are not in that list: they are derived from status by `sectionOf`, and always
|
|
35
|
+
come last.
|
|
36
|
+
|
|
37
|
+
A section that is not in `SECTION_ORDER` is **appended, and named in the note
|
|
38
|
+
the exporter prints** under its per-status counts, along with the number of
|
|
39
|
+
entries it holds. It is a note rather than a failure — a new phase is
|
|
40
|
+
legitimate, and an exporter that refused one would block the release that
|
|
41
|
+
introduced it — but it should not arrive unannounced. Silence is how the list
|
|
42
|
+
fell three sections behind and `"Roadmap"`, the fallback for an entry with no
|
|
43
|
+
section of its own, became a junk drawer holding eight built features (R45).
|
|
44
|
+
|
|
45
|
+
When you see the note: add the section to `SECTION_ORDER` in the position it
|
|
46
|
+
should read in, or give those entries a section that is already there. Sections
|
|
47
|
+
are renamed, merged and unfiled from the console's **Sections** dialog on the
|
|
48
|
+
roadmap board (R264), and that dialog cannot reach this file: after a rename,
|
|
49
|
+
update `SECTION_ORDER` too, or the note will list the new name.
|
|
50
|
+
|
|
51
|
+
## `tools/roadmap/import.mjs`
|
|
52
|
+
|
|
53
|
+
The one-time move from a hand-written `ROADMAP.md` into the platform, and the
|
|
54
|
+
other half of that fixed point.
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
node tools/roadmap/import.mjs cawdev --file ROADMAP.md
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
It is **idempotent enough to re-run after a partial failure**: entries that
|
|
61
|
+
already exist are updated rather than duplicated. It refuses to start when
|
|
62
|
+
preserving ids is impossible — ids are permanent, and silently renumbering them
|
|
63
|
+
would break every commit message that points at one.
|
|
64
|
+
|
|
65
|
+
## `roadmap.mjs` (repository root)
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
node roadmap.mjs # shape: ids unique, statuses legal, required fields present
|
|
69
|
+
node roadmap.mjs --live # also: SHIPPED versions are real tags, CODING and
|
|
70
|
+
# REVIEW branches exist, and the file matches the database
|
|
71
|
+
node roadmap.mjs --live --project cawdev # when CAWDEV_PROJECT is not set
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Both forms run in CI. The shape checks catch what a review forgets — an entry
|
|
75
|
+
that says `CODING` without naming a branch, an entry that says `MERGED` without
|
|
76
|
+
naming the merge, a `Related:` pointing at nothing. `--live` catches the claims a
|
|
77
|
+
file cannot check about itself: that a version someone wrote down was in fact
|
|
78
|
+
tagged, and that the file is still the export it says it is. A `REVIEW` entry's
|
|
79
|
+
branch is checked like a `CODING` one — the status claims there is something to
|
|
80
|
+
read. A `MERGED` entry is checked against neither tags nor branches, because its
|
|
81
|
+
branch is meant to have been deleted.
|
|
82
|
+
|
|
83
|
+
That last one re-runs `tools/roadmap/export.mjs` into a temporary file and
|
|
84
|
+
compares the bytes. It goes through the exporter rather than rendering a second
|
|
85
|
+
copy of it, so the two cannot drift apart. A difference fails and names the
|
|
86
|
+
entries — missing here, missing there, or changed — with the remedy. If every
|
|
87
|
+
entry agrees and the bytes still differ, the preamble or the section layout was
|
|
88
|
+
edited by hand.
|
|
89
|
+
|
|
90
|
+
It needs a `CAWDEV_TOKEN` and a project. Without them it prints a note saying it
|
|
91
|
+
was skipped and the git checks still run; a check that cannot answer says so
|
|
92
|
+
rather than passing (R41). If the token is set and the platform rejects it or is
|
|
93
|
+
down, that is not "cannot answer" — it fails, with the API's own message.
|
|
94
|
+
|
|
95
|
+
## Tests
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
node --test "tools/**/*.test.mjs"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`tools/lib/roadmap-format.test.mjs` covers the parse/render round trip. If those
|
|
102
|
+
two stop agreeing, the generated file churns on every export and its diff stops
|
|
103
|
+
meaning "the roadmap changed".
|
|
104
|
+
|
|
105
|
+
`tools/lib/usage.test.mjs` covers what a turn reports. One of its tests asserts
|
|
106
|
+
that **no dollar figure reaches a transcript**: the CLI quotes list prices, and
|
|
107
|
+
a subscription does not work that way.
|
|
108
|
+
|
|
109
|
+
`tools/lib/tool-rules.test.mjs` covers the matcher for what a run may do (R51).
|
|
110
|
+
Every assertion of `false` in it is asserting that somebody gets asked a
|
|
111
|
+
question they may have answered before — the cheap failure. The expensive one is
|
|
112
|
+
a `true` nobody intended.
|
|
113
|
+
|
|
114
|
+
`tools/mcp/approve.test.mjs` drives the real server over stdio against a faked
|
|
115
|
+
platform, and pins the shape Claude Code reads back: a permission decision is
|
|
116
|
+
JSON in the result text, and getting it wrong does not fail loudly — it stops a
|
|
117
|
+
session, which is the failure R51 exists to end.
|
|
118
|
+
|
|
119
|
+
`tools/runner/control.test.mjs` covers the socket a daemon offers (R52) on a
|
|
120
|
+
real socket, including the backlog somebody attaching to an hour-old session
|
|
121
|
+
needs, and that an unknown command is ignored rather than obeyed — that socket
|
|
122
|
+
is a read-only view on purpose.
|
|
123
|
+
|
|
124
|
+
`tools/runner/attach.test.mjs` covers the rendering arithmetic, which is where a
|
|
125
|
+
terminal UI goes wrong silently: transcripts carry the agent's ANSI colour, and
|
|
126
|
+
measuring it as plain text wraps the pane into nonsense.
|
|
127
|
+
|
|
128
|
+
`tools/runner/workspaces.test.mjs` is R47 against the real daemon: two coding
|
|
129
|
+
runs on one project, at the same time, in different checkouts, each reporting
|
|
130
|
+
which. Getting that gate wrong is expensive in both directions — too tight and a
|
|
131
|
+
machine with three checkouts still runs one session, too loose and two agents
|
|
132
|
+
share a working copy.
|
|
133
|
+
|
|
134
|
+
`tools/runner/test-platform.mjs` is the cawdev those two tests register with.
|
|
135
|
+
Not a test itself, and not production code: it is a fake platform with a queue,
|
|
136
|
+
shared because two fakes drift.
|
|
137
|
+
|
|
138
|
+
`tools/runner/daemon-boot.test.mjs` starts the **real daemon** against a faked
|
|
139
|
+
platform and a fake agent command. It is the only test of the first ten seconds
|
|
140
|
+
— registering, the R51 permission-flag probe, opening the socket, and removing
|
|
141
|
+
it again on the way out — a path that otherwise needs a `runner:operate` token
|
|
142
|
+
to reach at all.
|
|
143
|
+
|
|
144
|
+
## `tools/mcp/`
|
|
145
|
+
|
|
146
|
+
The MCP server an agent talks to — zero dependencies, stdio JSON-RPC.
|
|
147
|
+
`tools/mcp/README.md` covers setting it up in a repository, and is also written
|
|
148
|
+
for the agent that reads it: it teaches the working method (branch first,
|
|
149
|
+
`CODING` names the branch before the first commit, `roadmap_where` when in
|
|
150
|
+
doubt).
|
|
151
|
+
|
|
152
|
+
`tools/mcp/smoke.mjs` drives the server the way a client does — spawn, write
|
|
153
|
+
lines to stdin, read lines from stdout — because the parts most likely to break
|
|
154
|
+
are the transport and the framing. CI runs it against the compose stack.
|
|
155
|
+
|
|
156
|
+
## `tools/runner/`
|
|
157
|
+
|
|
158
|
+
The daemon that claims queued runs and spawns a real agent CLI in a working
|
|
159
|
+
copy. `tools/runner/README.md` covers running one.
|
|
160
|
+
|
|
161
|
+
## `tools/console/`
|
|
162
|
+
|
|
163
|
+
`console-smoke.mjs` is the end-to-end test for the console's half of the loop
|
|
164
|
+
(R12): start a run on an entry, watch the messages, answer the question from the
|
|
165
|
+
inbox, see the finish report carrying the branch — all over the endpoints the
|
|
166
|
+
browser calls, on a real session cookie. Point it at a **scratch project**: it
|
|
167
|
+
creates an entry, cancels any live run, and declines the entry afterwards.
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
node tools/console/console-smoke.mjs scratch
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Unlike everything else here it signs in rather than using `CAWDEV_TOKEN`, because
|
|
174
|
+
starting a run and answering a question are both a *person's* acts — an agent
|
|
175
|
+
cannot do either, so the test cannot bootstrap itself from a token.
|
package/lib/ansi.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Colour in a terminal, and the good manners around it — R62.
|
|
2
|
+
//
|
|
3
|
+
// Zero dependencies, like everything in tools/. That is not austerity for its
|
|
4
|
+
// own sake: a colour library is a supply chain, and what it does for us is
|
|
5
|
+
// thirty lines of escape codes that have not changed since 1979.
|
|
6
|
+
//
|
|
7
|
+
// Three rules, and the second two are the ones people forget:
|
|
8
|
+
//
|
|
9
|
+
// 1. Say what the terminal can hear. Truecolor where it advertises it, the
|
|
10
|
+
// 256-colour cube where it does not, nothing where colour is unwanted.
|
|
11
|
+
// 2. **Never carry meaning in colour alone.** Every state that has a colour
|
|
12
|
+
// here also has a word beside it, because a pipe, a CI log and a
|
|
13
|
+
// red-green reader are all normal.
|
|
14
|
+
// 3. **Measure what is visible, not what is written.** A coloured string is
|
|
15
|
+
// longer than it looks, and padding it by `length` is how a table stops
|
|
16
|
+
// lining up the moment anything in it goes red.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether to colour at all, and how richly.
|
|
20
|
+
*
|
|
21
|
+
* `NO_COLOR` is honoured for any value, which is what the convention asks
|
|
22
|
+
* (no-color.org): its presence is the signal, not its contents. `FORCE_COLOR`
|
|
23
|
+
* overrides in the other direction, for the case this is piped somewhere that
|
|
24
|
+
* does understand escapes — a file somebody will `less -R`, a CI log viewer.
|
|
25
|
+
*/
|
|
26
|
+
export function colourDepth(env = process.env, stream = process.stdout) {
|
|
27
|
+
if (env.NO_COLOR !== undefined) return 0;
|
|
28
|
+
if (env.FORCE_COLOR !== undefined) {
|
|
29
|
+
// `FORCE_COLOR=` with nothing after it means ON, which is how people
|
|
30
|
+
// usually write it. `Number('')` is 0, so reading this numerically without
|
|
31
|
+
// saying so first silences colour for exactly the person who asked for it.
|
|
32
|
+
const asked = env.FORCE_COLOR.trim();
|
|
33
|
+
if (asked === '') return 3;
|
|
34
|
+
if (asked === '0' || asked.toLowerCase() === 'false') return 0;
|
|
35
|
+
const level = Number(asked);
|
|
36
|
+
return Number.isFinite(level) ? Math.min(3, Math.max(0, level)) : 3;
|
|
37
|
+
}
|
|
38
|
+
if (!stream?.isTTY) return 0;
|
|
39
|
+
if (env.TERM === 'dumb') return 0;
|
|
40
|
+
if (env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit') return 3;
|
|
41
|
+
if (/-256(color)?$/i.test(env.TERM ?? '')) return 2;
|
|
42
|
+
return env.TERM ? 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The nearest colour the terminal can actually show. */
|
|
46
|
+
function code(depth, [r, g, b], basic) {
|
|
47
|
+
if (depth >= 3) return `\x1b[38;2;${r};${g};${b}m`;
|
|
48
|
+
if (depth === 2) return `\x1b[38;5;${cube(r, g, b)}m`;
|
|
49
|
+
return `\x1b[${basic}m`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A 24-bit colour in the 256-colour cube.
|
|
54
|
+
*
|
|
55
|
+
* The greys are a separate ramp and are worth using: rounding a near-grey into
|
|
56
|
+
* the 6×6×6 cube gives it a colour cast, which on the dim text this file is
|
|
57
|
+
* mostly used for reads as "the terminal is broken".
|
|
58
|
+
*/
|
|
59
|
+
function cube(r, g, b) {
|
|
60
|
+
if (Math.abs(r - g) < 12 && Math.abs(g - b) < 12) {
|
|
61
|
+
if (r < 8) return 16;
|
|
62
|
+
if (r > 248) return 231;
|
|
63
|
+
return 232 + Math.round(((r - 8) / 247) * 23);
|
|
64
|
+
}
|
|
65
|
+
const axis = (value) => Math.round((value / 255) * 5);
|
|
66
|
+
return 16 + 36 * axis(r) + 6 * axis(g) + axis(b);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A painter bound to one terminal's abilities.
|
|
71
|
+
*
|
|
72
|
+
* Every colour names a **basic fallback** as well as its true value, so a
|
|
73
|
+
* 16-colour terminal gets something deliberate rather than whatever the cube
|
|
74
|
+
* rounds to.
|
|
75
|
+
*/
|
|
76
|
+
export function painter(depth = colourDepth()) {
|
|
77
|
+
const on = depth > 0;
|
|
78
|
+
const wrap = (open) => (text) => (on ? `${open}${text}\x1b[0m` : String(text));
|
|
79
|
+
const ink = (rgb, basic) => wrap(code(depth, rgb, basic));
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
depth,
|
|
83
|
+
enabled: on,
|
|
84
|
+
|
|
85
|
+
// cawdev's own mark, from cawdev-mark.svg. Used for the logo and nowhere
|
|
86
|
+
// else: the brand is not a status.
|
|
87
|
+
violet: ink([0x4b, 0x3f, 0xd4], 35),
|
|
88
|
+
violetLight: ink([0x75, 0x68, 0xe3], 95),
|
|
89
|
+
amber: ink([0xf0, 0xa2, 0x2e], 33),
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The working palette, in Claude Code's warm register rather than the
|
|
93
|
+
* console's violet: this is a terminal tool sitting beside the CLI it
|
|
94
|
+
* drives, and matching that is what makes it look like it belongs.
|
|
95
|
+
* One place to change if that judgement changes.
|
|
96
|
+
*/
|
|
97
|
+
accent: ink([0xd9, 0x77, 0x57], 33),
|
|
98
|
+
success: ink([0x71, 0xa5, 0x78], 32),
|
|
99
|
+
warn: ink([0xd9, 0xa0, 0x57], 33),
|
|
100
|
+
danger: ink([0xc0, 0x62, 0x62], 31),
|
|
101
|
+
muted: ink([0x8a, 0x86, 0x82], 90),
|
|
102
|
+
text: ink([0xd4, 0xd0, 0xcc], 37),
|
|
103
|
+
|
|
104
|
+
bold: wrap('\x1b[1m'),
|
|
105
|
+
dim: wrap('\x1b[2m'),
|
|
106
|
+
reverse: wrap('\x1b[7m'),
|
|
107
|
+
/** Plain, for a value that should not compete with the label beside it. */
|
|
108
|
+
plain: (text) => String(text),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A string's width on screen, ignoring escape sequences.
|
|
114
|
+
*
|
|
115
|
+
* The one function everything that lines up depends on. Padding by `length`
|
|
116
|
+
* counts the escapes, so a coloured cell is silently ~10 characters wide and
|
|
117
|
+
* the column after it walks.
|
|
118
|
+
*/
|
|
119
|
+
export function visibleWidth(text) {
|
|
120
|
+
return stripAnsi(String(text)).length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function stripAnsi(text) {
|
|
124
|
+
// eslint-disable-next-line no-control-regex
|
|
125
|
+
return String(text).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Pads to `width` by what is visible, never by what is written. */
|
|
129
|
+
export function padVisible(text, width) {
|
|
130
|
+
const short = width - visibleWidth(text);
|
|
131
|
+
return short > 0 ? `${text}${' '.repeat(short)}` : text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A row of keys, and whatever was just said, in the room there is.
|
|
136
|
+
*
|
|
137
|
+
* **Whole keys are dropped rather than a key being cut in half.** At sixty
|
|
138
|
+
* columns this row ended `x c`, and at forty `y/`, which is not a shorter list
|
|
139
|
+
* — it is a list with a typo at the end of it. The status goes first, because
|
|
140
|
+
* it is a sentence about something that already happened; then keys from the
|
|
141
|
+
* right, where the rarer ones are.
|
|
142
|
+
*
|
|
143
|
+
* Here rather than in the terminal client since R83, because the client is no
|
|
144
|
+
* longer the only thing that draws one: every select widget ends in a row of
|
|
145
|
+
* keys, and two implementations of "drop from the right" is two chances to get
|
|
146
|
+
* the arithmetic wrong.
|
|
147
|
+
*/
|
|
148
|
+
export function keyList(keys, status, width, ink = painter(3)) {
|
|
149
|
+
const parts = Array.isArray(keys) ? [...keys] : [String(keys)];
|
|
150
|
+
const said = status ? ` ${ink.warn(status)}` : '';
|
|
151
|
+
const join = (of, tail) => of.join(ink.muted(' · ')) + (of.length < parts.length ? ink.muted(' …') : '') + tail;
|
|
152
|
+
|
|
153
|
+
if (visibleWidth(join(parts, said)) <= width) {
|
|
154
|
+
return join(parts, said);
|
|
155
|
+
}
|
|
156
|
+
const shown = [...parts];
|
|
157
|
+
while (shown.length > 1 && visibleWidth(join(shown, '')) > width) {
|
|
158
|
+
shown.pop();
|
|
159
|
+
}
|
|
160
|
+
return join(shown, '');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Wraps to a width, keeping escape sequences where they fall.
|
|
165
|
+
*
|
|
166
|
+
* Here rather than in the terminal client because rule 3 above is what it is
|
|
167
|
+
* for: a transcript carries the agent's own colour (R23), and wrapping it by
|
|
168
|
+
* `length` breaks every coloured line forty characters early.
|
|
169
|
+
*/
|
|
170
|
+
export function wrap(text, width) {
|
|
171
|
+
const lines = [];
|
|
172
|
+
for (const paragraph of String(text).split('\n')) {
|
|
173
|
+
if (visibleWidth(paragraph) <= width) {
|
|
174
|
+
lines.push(paragraph);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
let current = '';
|
|
178
|
+
let visible = 0;
|
|
179
|
+
for (let i = 0; i < paragraph.length; i++) {
|
|
180
|
+
const escape = /^\x1b\[[0-9;?]*[a-zA-Z]/.exec(paragraph.slice(i));
|
|
181
|
+
if (escape) {
|
|
182
|
+
current += escape[0];
|
|
183
|
+
i += escape[0].length - 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
current += paragraph[i];
|
|
187
|
+
visible += 1;
|
|
188
|
+
if (visible >= width) {
|
|
189
|
+
lines.push(current);
|
|
190
|
+
current = '';
|
|
191
|
+
visible = 0;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (current) {
|
|
195
|
+
// Trailing escapes with nothing after them — the reset at the end of a
|
|
196
|
+
// coloured line — belong to the line they close, not to a new empty one.
|
|
197
|
+
// Without this every coloured line is followed by a blank.
|
|
198
|
+
if (visible === 0 && lines.length) {
|
|
199
|
+
lines[lines.length - 1] += current;
|
|
200
|
+
} else {
|
|
201
|
+
lines.push(current);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return lines;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Cuts to a width without cutting an escape sequence in half. */
|
|
209
|
+
export function clip(text, width) {
|
|
210
|
+
if (visibleWidth(text) <= width) return text;
|
|
211
|
+
let out = '';
|
|
212
|
+
let visible = 0;
|
|
213
|
+
for (let i = 0; i < text.length && visible < width; i++) {
|
|
214
|
+
const escape = /^\x1b\[[0-9;?]*[a-zA-Z]/.exec(text.slice(i));
|
|
215
|
+
if (escape) {
|
|
216
|
+
out += escape[0];
|
|
217
|
+
i += escape[0].length - 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
out += text[i];
|
|
221
|
+
visible += 1;
|
|
222
|
+
}
|
|
223
|
+
return `${out}\x1b[0m`;
|
|
224
|
+
}
|
package/lib/cawdev.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// A tiny cawdev API client. Plain Node, zero dependencies — see CLAUDE.md:
|
|
2
|
+
// a zero-dep file is one anyone can read before running it against their
|
|
3
|
+
// repositories, and every tool here is meant to be read.
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reads CAWDEV_URL / CAWDEV_TOKEN / CAWDEV_PROJECT from the environment, or
|
|
10
|
+
* from a .env beside the repository root.
|
|
11
|
+
*
|
|
12
|
+
* Read on every call rather than cached at import: editing .env should not
|
|
13
|
+
* need a restart. (R8's MCP server keeps the same rule — dycrypt learned it
|
|
14
|
+
* the hard way, and losing it is how an agent ends up writing to the wrong
|
|
15
|
+
* platform for an hour.)
|
|
16
|
+
*/
|
|
17
|
+
export async function readConfig(startDirectory = process.cwd()) {
|
|
18
|
+
const fromEnv = {
|
|
19
|
+
url: process.env.CAWDEV_URL,
|
|
20
|
+
token: process.env.CAWDEV_TOKEN,
|
|
21
|
+
project: process.env.CAWDEV_PROJECT,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const fromFile = await readDotEnv(startDirectory);
|
|
25
|
+
const config = {
|
|
26
|
+
url: (fromEnv.url ?? fromFile.CAWDEV_URL ?? 'http://localhost:4200').replace(/\/+$/, ''),
|
|
27
|
+
token: fromEnv.token ?? fromFile.CAWDEV_TOKEN,
|
|
28
|
+
project: fromEnv.project ?? fromFile.CAWDEV_PROJECT,
|
|
29
|
+
// Where each value came from, so a surprising result can be traced.
|
|
30
|
+
source: {
|
|
31
|
+
url: fromEnv.url ? 'environment' : fromFile.CAWDEV_URL ? '.env' : 'default',
|
|
32
|
+
token: fromEnv.token ? 'environment' : fromFile.CAWDEV_TOKEN ? '.env' : 'unset',
|
|
33
|
+
project: fromEnv.project ? 'environment' : fromFile.CAWDEV_PROJECT ? '.env' : 'unset',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return config;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readDotEnv(startDirectory) {
|
|
40
|
+
let directory = resolve(startDirectory);
|
|
41
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
42
|
+
try {
|
|
43
|
+
const text = await readFile(join(directory, '.env'), 'utf8');
|
|
44
|
+
const values = {};
|
|
45
|
+
for (const line of text.split('\n')) {
|
|
46
|
+
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
|
|
47
|
+
if (match) values[match[1]] = match[2].replace(/^["']|["']$/g, '');
|
|
48
|
+
}
|
|
49
|
+
return values;
|
|
50
|
+
} catch {
|
|
51
|
+
const parent = dirname(directory);
|
|
52
|
+
if (parent === directory) break;
|
|
53
|
+
directory = parent;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Throws with the API's own message, which is the one that explains the rule. */
|
|
60
|
+
export async function call(config, path, { method = 'GET', body } = {}) {
|
|
61
|
+
if (!config.token) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'No CAWDEV_TOKEN. Mint one in the console under Agent tokens, then put it in ' +
|
|
64
|
+
'the environment or a .env file.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const response = await fetch(`${config.url}${path}`, {
|
|
69
|
+
method,
|
|
70
|
+
headers: {
|
|
71
|
+
authorization: `Bearer ${config.token}`,
|
|
72
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
73
|
+
},
|
|
74
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const text = await response.text();
|
|
78
|
+
const parsed = text ? safeJson(text) : null;
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const detail = parsed?.message ?? text ?? `HTTP ${response.status}`;
|
|
82
|
+
throw new Error(`${method} ${path} -> ${response.status}: ${detail}`);
|
|
83
|
+
}
|
|
84
|
+
return parsed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function safeJson(text) {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(text);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolves the project slug, preferring an explicit argument. */
|
|
96
|
+
export function resolveProject(config, explicit) {
|
|
97
|
+
const slug = explicit ?? config.project;
|
|
98
|
+
if (!slug) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'No project. Pass one as an argument, or set CAWDEV_PROJECT in the environment or .env.',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return slug;
|
|
104
|
+
}
|
package/lib/code-map.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The shape of a codebase: which files exist, and which of them reach for which
|
|
2
|
+
// others — R77's Map tab.
|
|
3
|
+
//
|
|
4
|
+
// **Pure, and deliberately so.** Everything here takes text and returns data, so
|
|
5
|
+
// the hard part — resolving a written import to a file that exists — can be
|
|
6
|
+
// tested without a repository, a daemon or a network. The runner does the
|
|
7
|
+
// reading; this does the thinking.
|
|
8
|
+
//
|
|
9
|
+
// **Why not CodeGraph.** R76 says use it rather than rebuild it, and that still
|
|
10
|
+
// holds for what R76 is for: symbols and call paths, queried by an agent over
|
|
11
|
+
// MCP, one question at a time. This is a different graph for a different
|
|
12
|
+
// consumer — every file and every edge at once, laid out for a person to look
|
|
13
|
+
// at — and R76's own text names that as the case where rebuilding is right: "if
|
|
14
|
+
// the graph it exposes over MCP is not the graph the next entry needs". An
|
|
15
|
+
// import edge is also the one thing a reader can check by opening the file,
|
|
16
|
+
// which a call path inferred through a parser is not.
|
|
17
|
+
|
|
18
|
+
/** Files we can honestly extract dependencies from. Others are drawn with no edges. */
|
|
19
|
+
const READABLE = /\.(java|mjs|js|ts|tsx|jsx)$/;
|
|
20
|
+
|
|
21
|
+
/** What counts as a source file at all — the map's population. */
|
|
22
|
+
const SOURCE = /\.(java|mjs|js|ts|tsx|jsx|html|css|sql|yaml|yml)$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Java imports.
|
|
26
|
+
*
|
|
27
|
+
* <p>All of them, including `java.util.List`; the resolver drops the ones that
|
|
28
|
+
* point outside the repository. Filtering here would need this function to know
|
|
29
|
+
* what the project is called, which is the resolver's business.
|
|
30
|
+
*/
|
|
31
|
+
function javaImports(text) {
|
|
32
|
+
const found = [];
|
|
33
|
+
for (const line of text.split('\n')) {
|
|
34
|
+
const match = /^\s*import\s+(?:static\s+)?([\w.]+)\s*;/.exec(line);
|
|
35
|
+
if (match) {
|
|
36
|
+
found.push(match[1]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** ESM imports and re-exports, which in this corpus are always relative. */
|
|
43
|
+
function esmImports(text) {
|
|
44
|
+
const found = [];
|
|
45
|
+
const pattern = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g;
|
|
46
|
+
let match;
|
|
47
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
48
|
+
found.push(match[1]);
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `a/b/c.ts` + `../d` -> `a/d`. Null when it is not relative, or climbs out. */
|
|
54
|
+
export function resolveRelative(from, specifier) {
|
|
55
|
+
if (!specifier.startsWith('.')) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const parts = from.split('/').slice(0, -1);
|
|
59
|
+
for (const step of specifier.split('/')) {
|
|
60
|
+
if (step === '.' || step === '') continue;
|
|
61
|
+
if (step === '..') {
|
|
62
|
+
if (!parts.length) return null;
|
|
63
|
+
parts.pop();
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
parts.push(step);
|
|
67
|
+
}
|
|
68
|
+
return parts.join('/');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The file a specifier means, or null.
|
|
73
|
+
*
|
|
74
|
+
* <p>Null is the honest answer and is kept as one: a specifier resolving to
|
|
75
|
+
* nothing in this repository is a dependency on something outside it, and
|
|
76
|
+
* inventing a node for it would fill the map with somebody else's libraries.
|
|
77
|
+
*/
|
|
78
|
+
function resolveIn(known, base) {
|
|
79
|
+
if (!base) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (known.has(base)) {
|
|
83
|
+
return base;
|
|
84
|
+
}
|
|
85
|
+
for (const suffix of ['.ts', '.tsx', '.js', '.mjs', '.jsx']) {
|
|
86
|
+
if (known.has(base + suffix)) {
|
|
87
|
+
return base + suffix;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const suffix of ['/index.ts', '/index.js', '/index.mjs']) {
|
|
91
|
+
if (known.has(base + suffix)) {
|
|
92
|
+
return base + suffix;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A Java class name to the file that declares it.
|
|
100
|
+
*
|
|
101
|
+
* <p>By package path rather than by class name: two packages may each hold a
|
|
102
|
+
* `Runner`, and picking whichever was read first would draw an edge to the
|
|
103
|
+
* wrong one — a lie a reader cannot see. `a.b.C` is `.../a/b/C.java`, and
|
|
104
|
+
* indexing by the tail after `/java/` is what copes with `src/main/java` in
|
|
105
|
+
* front of it.
|
|
106
|
+
*/
|
|
107
|
+
function resolveJava(byJavaPath, name) {
|
|
108
|
+
return byJavaPath.get(name.replace(/\./g, '/') + '.java') ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The map: every source file, and every edge between two of them.
|
|
113
|
+
*
|
|
114
|
+
* @param files `[{path, text}]`. `text` may be absent for a file whose
|
|
115
|
+
* dependencies are not read — it is still drawn, because a map missing the
|
|
116
|
+
* CSS is a map of somewhere else.
|
|
117
|
+
*/
|
|
118
|
+
export function codeMapOf(files) {
|
|
119
|
+
const paths = files.map((each) => each.path).filter((path) => SOURCE.test(path));
|
|
120
|
+
const known = new Set(paths);
|
|
121
|
+
|
|
122
|
+
const byJavaPath = new Map();
|
|
123
|
+
for (const path of paths) {
|
|
124
|
+
if (!path.endsWith('.java')) continue;
|
|
125
|
+
const at = path.indexOf('/java/');
|
|
126
|
+
byJavaPath.set(at === -1 ? path : path.slice(at + '/java/'.length), path);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Nested rather than a composite key: a delimiter is a character somebody's
|
|
130
|
+
// filename eventually contains.
|
|
131
|
+
const out = new Map();
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
if (!file.text || !READABLE.test(file.path) || !known.has(file.path)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const targets = file.path.endsWith('.java')
|
|
137
|
+
? javaImports(file.text).map((name) => resolveJava(byJavaPath, name))
|
|
138
|
+
: esmImports(file.text).map(
|
|
139
|
+
(specifier) => resolveIn(known, resolveRelative(file.path, specifier)));
|
|
140
|
+
|
|
141
|
+
for (const to of targets) {
|
|
142
|
+
// A file importing itself is a re-export shim, not a dependency, and a
|
|
143
|
+
// self-loop is a circle drawn on top of a box.
|
|
144
|
+
if (!to || to === file.path) continue;
|
|
145
|
+
if (!out.has(file.path)) {
|
|
146
|
+
out.set(file.path, new Map());
|
|
147
|
+
}
|
|
148
|
+
const mine = out.get(file.path);
|
|
149
|
+
mine.set(to, (mine.get(to) ?? 0) + 1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const edges = [];
|
|
154
|
+
for (const [from, targets] of out) {
|
|
155
|
+
for (const [to, weight] of targets) {
|
|
156
|
+
edges.push({ from, to, weight });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
files: paths.sort().map((path) => ({ path, dir: path.split('/').slice(0, -1).join('/') })),
|
|
162
|
+
edges,
|
|
163
|
+
};
|
|
164
|
+
}
|