@opencode-cockpit/trust 0.0.0 → 0.8.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 +240 -5
- package/dist/cli/preview.js +251 -0
- package/dist/core/adapt/seen.js +24 -0
- package/dist/core/adapt/v1.js +109 -0
- package/dist/core/adapt/v2.js +109 -0
- package/dist/core/config.js +93 -0
- package/dist/core/danger.js +366 -0
- package/dist/core/engine.js +245 -0
- package/dist/core/family.js +512 -0
- package/dist/core/history.js +169 -0
- package/dist/core/index.js +16 -0
- package/dist/core/keys.js +149 -0
- package/dist/core/ledger.js +202 -0
- package/dist/core/paths.js +38 -0
- package/dist/core/policy.js +128 -0
- package/dist/core/rules.js +152 -0
- package/dist/core/sample.js +311 -0
- package/dist/core/shell.js +253 -0
- package/dist/core/signature.js +38 -0
- package/dist/core/view/actions.js +321 -0
- package/dist/core/view/activity.js +557 -0
- package/dist/core/view/explorer.js +970 -0
- package/dist/core/view/model.js +193 -0
- package/dist/core/view/parts.js +290 -0
- package/dist/core/view/rows.js +165 -0
- package/dist/core/view/sidebar.js +170 -0
- package/dist/tui/index.js +920 -0
- package/dist/tui/journal.js +80 -0
- package/dist/tui/render.js +85 -0
- package/dist/tui/source.js +112 -0
- package/dist/tui/view/dialog.js +43 -0
- package/dist/tui/view/rows.js +69 -0
- package/package.json +49 -3
- package/tui.js +6 -0
- package/types/cli/preview.d.ts +22 -0
- package/types/core/adapt/seen.d.ts +46 -0
- package/types/core/adapt/v1.d.ts +18 -0
- package/types/core/adapt/v2.d.ts +20 -0
- package/types/core/config.d.ts +57 -0
- package/types/core/danger.d.ts +50 -0
- package/types/core/engine.d.ts +117 -0
- package/types/core/family.d.ts +97 -0
- package/types/core/history.d.ts +81 -0
- package/types/core/index.d.ts +16 -0
- package/types/core/keys.d.ts +68 -0
- package/types/core/ledger.d.ts +158 -0
- package/types/core/paths.d.ts +24 -0
- package/types/core/policy.d.ts +52 -0
- package/types/core/rules.d.ts +55 -0
- package/types/core/sample.d.ts +22 -0
- package/types/core/shell.d.ts +40 -0
- package/types/core/signature.d.ts +18 -0
- package/types/core/view/actions.d.ts +76 -0
- package/types/core/view/activity.d.ts +101 -0
- package/types/core/view/explorer.d.ts +154 -0
- package/types/core/view/model.d.ts +106 -0
- package/types/core/view/parts.d.ts +76 -0
- package/types/core/view/rows.d.ts +60 -0
- package/types/core/view/sidebar.d.ts +62 -0
- package/types/tui/index.d.ts +15 -0
- package/types/tui/journal.d.ts +19 -0
- package/types/tui/render.d.ts +14 -0
- package/types/tui/source.d.ts +35 -0
- package/types/tui/view/dialog.d.ts +24 -0
- package/types/tui/view/rows.d.ts +19 -0
package/README.md
CHANGED
|
@@ -1,7 +1,242 @@
|
|
|
1
|
-
# @opencode-cockpit/trust
|
|
1
|
+
# @opencode-cockpit/trust
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
**Permissions that learn.** You approve a command yourself. After you have approved *the same
|
|
4
|
+
command* a few times in a row, Trust approves it for you — and records it, every time. A reject resets
|
|
5
|
+
the count, a dangerous command costs more approvals, and nothing you told OpenCode to always ask
|
|
6
|
+
about is ever answered for you.
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
Part of [opencode-cockpit](https://github.com/Codestz/opencode-cockpit). Install it on its own, or
|
|
9
|
+
through the bundle. Works on OpenCode 1.18+ and 2.0.15+.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
opencode plugin @opencode-cockpit/trust@0.8.0 --global --force # OpenCode 1
|
|
13
|
+
opencode plugin add @opencode-cockpit/trust@0.8.0 # OpenCode 2
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It only acts where OpenCode asks you — a `"bash": "ask"` (or `"permission": "ask"`) in
|
|
17
|
+
`opencode.json`. Where config already says `allow` or `deny`, OpenCode never asks, and Trust never
|
|
18
|
+
sees it.
|
|
19
|
+
|
|
20
|
+
## What counts as "the same command"
|
|
21
|
+
|
|
22
|
+
Exactly the same, and nothing wider — unless you widen it yourself, in the ledger (`w`).
|
|
23
|
+
`docker compose -p cockpit up -d` and
|
|
24
|
+
`docker compose -p prod down -v` are two commands — OpenCode's own "Always" would treat them as one
|
|
25
|
+
(`docker compose -p *`). The same command in another directory (`cd /tmp && rm -rf dist`) is
|
|
26
|
+
another command too. Quoting is the only thing normalised: `echo 'a b'` is `echo "a b"`.
|
|
27
|
+
|
|
28
|
+
A line with several commands (`git add -A && git commit -m wip`) counts for each of them, and is
|
|
29
|
+
answered only when **every** one is trusted — or allowed by your config. Anything that cannot be read
|
|
30
|
+
for certain is always yours to answer: `$(…)`, backticks, `$VAR`, `eval`, `sh -c`, a pipe into a
|
|
31
|
+
shell, a heredoc.
|
|
32
|
+
|
|
33
|
+
Each count is kept per project and **per agent**: `build` earning `git push` is not `general` earning
|
|
34
|
+
it. Other permissions have their own notion of "the same": an edit by its file, a web fetch by its
|
|
35
|
+
host, a subagent by its type. `external_directory` and `doom_loop` are never answered.
|
|
36
|
+
|
|
37
|
+
## What it will not touch
|
|
38
|
+
|
|
39
|
+
- **A specific `ask` in your config.** `"git push *": "ask"` means *always ask me*; Trust never
|
|
40
|
+
answers a request it matches, however many times you approve it. A catch-all — `"bash": "ask"`,
|
|
41
|
+
`{ "*": "ask" }`, or no rule at all — is a default, and that is the gap Trust fills.
|
|
42
|
+
- **Your config file.** Trust reads OpenCode's merged config through OpenCode; it never writes
|
|
43
|
+
`opencode.json`. The ledger can copy a rule for you to paste.
|
|
44
|
+
- **Answers that were not yours.** A reply under 300ms is not a person's (OpenCode's `--auto` answers
|
|
45
|
+
in about 20ms) and is not counted; neither are Trust's own answers.
|
|
46
|
+
|
|
47
|
+
## Dangerous commands cost more
|
|
48
|
+
|
|
49
|
+
`rm`, `git push`, `git reset --hard`, `--force`, `docker compose down -v`, `kubectl delete`,
|
|
50
|
+
`terraform apply`, `DROP` in a SQL client, `npm publish`, `sudo`, and the like need the normal
|
|
51
|
+
threshold **plus** `dangerExtra` approvals in a row — eight by default. They still earn trust; slowly.
|
|
52
|
+
`sudo`, `env`, `time`, `nohup`, `timeout` and `xargs` are looked through: `timeout 5 rm x` is `rm`.
|
|
53
|
+
|
|
54
|
+
## In the sidebar
|
|
55
|
+
|
|
56
|
+
The block is **hidden by default** — the sidebar already carries the statusline, subagents and
|
|
57
|
+
shells, and Trust answers the same without it. `"trust": { "sidebar": true }` in
|
|
58
|
+
`~/.config/opencode-cockpit/config.json` or a project's `.cockpit.json` shows it; the palette's
|
|
59
|
+
"Show or hide Trust in the sidebar" flips it for this session only. Hidden or not, a failure — a
|
|
60
|
+
ledger that could not be saved — always shows there.
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Trust 4 auto
|
|
64
|
+
● git status 7×
|
|
65
|
+
● edit src/app.ts 3×
|
|
66
|
+
○ docker compose -p cockpit… 2/3
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A filled dot is something Trust just answered, with how many times it has answered it in all; a
|
|
70
|
+
hollow one is a request on screen now and how far it is from being trusted. No answers and nothing
|
|
71
|
+
counting, no block. Each answer is also a line in `~/.cache/opencode-cockpit/cockpit.log`. No toasts.
|
|
72
|
+
|
|
73
|
+
## What Trust did, and the ledger
|
|
74
|
+
|
|
75
|
+
`/trust`, `ctrl+x p` or the palette opens on **what Trust did for you**: today's answers, newest
|
|
76
|
+
first, each with why it was answered; a sparkline of the week; what is one approval away; and
|
|
77
|
+
OpenCode's own broad "always" approvals in a band of their own.
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Trust · app ● answering
|
|
82
|
+
|
|
83
|
+
TODAY Trust answered 5 prompts for you ▂▁▅▂█▄█ last 7 days
|
|
84
|
+
▌20:31 ✓ git status --short build trusted since Sep 16, 3 in a row
|
|
85
|
+
20:30 ✓ ls -la general trusted since Sep 16, 3 in a row
|
|
86
|
+
20:21 ✓ bun test build trusted since Sep 16, 3 in a row
|
|
87
|
+
20:13 ✓ git status --short && echo tr… build both commands trusted
|
|
88
|
+
19:43 ✓ cat src/app.ts general in a family you widened: cat
|
|
89
|
+
|
|
90
|
+
ALMOST THERE closest first
|
|
91
|
+
○ bun --version build ▰▰▱ 2 of 3
|
|
92
|
+
○ git status --short -uno general ▰▰▱ 2 of 3
|
|
93
|
+
○ head -60 general ▰▰▱ 2 of 3
|
|
94
|
+
○ head -40 general ▰▰▱ 2 of 3
|
|
95
|
+
○ git push origin feat/trust build ▰▰▰▰▰▱▱▱ 5 of 8 dangerous
|
|
96
|
+
|
|
97
|
+
! WATCH OUT OpenCode's own "always" approves more than it looks, until it restarts
|
|
98
|
+
! find . * sort -rn * general [enter] what it covers
|
|
99
|
+
|
|
100
|
+
RULES 8 trusted · 5 learning · 7 seen once l Open the ledger
|
|
101
|
+
|
|
102
|
+
[enter] Why [x] Revoke [w] Trust Family [l] Ledger [p] Pause [?] Keys [esc] Close
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- **Today**: every answer Trust gave in this project — from any window — with the time, the agent
|
|
106
|
+
and why in a few words: the approvals that earned it and when, or the family you widened. The same
|
|
107
|
+
line answered again and again is one row with a count (`3×`). With nothing today, the latest from
|
|
108
|
+
earlier days, with their day.
|
|
109
|
+
- **Almost there**: what is still learning, closest first, a dangerous command last. The meter is
|
|
110
|
+
the approvals in a row that count (`▰`) and those still to go (`▱`); a dangerous one is red and
|
|
111
|
+
longer (`threshold + dangerExtra`).
|
|
112
|
+
- **Watch out**: only when you gave OpenCode an "always" — it approves every command that starts
|
|
113
|
+
that way until OpenCode restarts, and Trust cannot take it back.
|
|
114
|
+
- **`enter`** shows why: the card of that rule, in the ledger. **`l`** opens the ledger.
|
|
115
|
+
|
|
116
|
+
**The ledger** (`l`) is every rule, as a tree of **families** — `ls -la`, `ls -x` and `ls -R docs`
|
|
117
|
+
are all `ls` — and a card for the one selected, always on screen:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
Trust · app 8 trusted · 5 learning · 7 seen once ● answering
|
|
121
|
+
────────────────────────────────────────┬───────────────────────────────────────────────────────────
|
|
122
|
+
FAMILIES 1–15 of 18 │ git status --short
|
|
123
|
+
▾ git status 1 ✓ 1 ○ │ ✓ Trusted for build · answered 8×
|
|
124
|
+
▌ … --short ✓ trusted 8× │
|
|
125
|
+
… --short -uno ▰▰▱ 2 of 3 │ Exactly git status --short
|
|
126
|
+
ls -la ✓ trusted 2× │ Still asks git status · git status --short > out.txt ·
|
|
127
|
+
bun test ✓ trusted 2× │ any other argument
|
|
128
|
+
echo trust-test ✓ trusted 3× │ History ✓ 7d ✓ 7d ✓ 7d → trusted answered 8×
|
|
129
|
+
▸ cat any 2 ✓ │ 3 approvals in a row, all yours
|
|
130
|
+
▸ head 1 ✓ 3 ○ │ Family git status · 2 commands, 1 trusted
|
|
131
|
+
edit src/app.ts ✓ trusted 2× │ [w] trusts any git status … for build, not
|
|
132
|
+
bun --version ▰▰▱ 2 of 3 │ one by one
|
|
133
|
+
git push origin fe… ! ▰▰▰▰▰▱▱▱ 5/8 │ Expires if unused for 30 days
|
|
134
|
+
pwd ▰▱▱ 1 of 3 │
|
|
135
|
+
sed -n 1,40p src/a… ▰▱▱ 1 of 3 │ x Revoke w Trust any git status c Copy rule
|
|
136
|
+
wc -l src/app.ts ▰▱▱ 1 of 3 │
|
|
137
|
+
sort -rn ▰▱▱ 1 of 3 │
|
|
138
|
+
────────────────────────────────────────┴───────────────────────────────────────────────────────────
|
|
139
|
+
[↑/↓] Move [←/→] Fold [tab] Card [/] Filter [p] Pause [?] Keys [esc] Back
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **Each command is one row**, under its family, whatever its agents say about it; the card lists
|
|
143
|
+
each agent's standing. A family with one command is drawn as that row; others start folded, and
|
|
144
|
+
an open family lists its first three commands and `+ N more`.
|
|
145
|
+
- **The card**: the command whole, on a raised panel; where each agent stands; exactly what it is,
|
|
146
|
+
every argument quoted where a font could merge it (`echo "---"`, never `echo ──`, and said in words
|
|
147
|
+
too: "3 hyphens"); what still asks; the **history** that earned it (`✓ 7d ✓ 7d ✓ 7d → trusted`);
|
|
148
|
+
its family and what `w` would do; when it expires. Its buttons — `x`, `w`, `c` — can be clicked,
|
|
149
|
+
or reached with `tab`.
|
|
150
|
+
- **Below 90 columns** the card moves under the tree, the selection kept in view above it.
|
|
151
|
+
- **A family** is the program, or the program and its subcommand for tools that have them:
|
|
152
|
+
`git status`, `docker compose up` (`-p prod` and other global flags are not part of it),
|
|
153
|
+
`npm run test` (the script is). A wrapper is: `sudo ls` is not `ls`. So is where it runs and the
|
|
154
|
+
environment it is given: `(in web) bun test`, `NODE_ENV=… npm run build`. An edit's family is its
|
|
155
|
+
folder.
|
|
156
|
+
- **`w` trusts the whole family**, for the selected command's agent — on purpose, never by itself.
|
|
157
|
+
Any `ls …` is then answered for that agent, **except** a dangerous command, one that writes a file
|
|
158
|
+
through a redirection (`ls > out.txt`; `2>/dev/null` and `2>&1` write nothing and are fine), one
|
|
159
|
+
that runs another program (`find -exec`, `git -c`), and any line that cannot be read. A specific
|
|
160
|
+
`ask` in your config still wins. A dangerous family (`git push`, `rm`, `sudo …`) can never be
|
|
161
|
+
widened. `w` again — or `x` on the family — goes back to exact rules. Answers through a widened
|
|
162
|
+
family say so, on both screens, the sidebar (`● ls -x · any ls`) and the log.
|
|
163
|
+
|
|
164
|
+
On the activity:
|
|
165
|
+
|
|
166
|
+
| Key | |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| `j` `k` `↑` `↓`, wheel, click | Move over answers, what is close, and OpenCode's own approvals |
|
|
169
|
+
| `enter` | Why: the rule's card in the ledger |
|
|
170
|
+
| `x` | Revoke what answered (the rule, or the widening that answered it); forget a count still learning |
|
|
171
|
+
| `w` | Trust any command in the family, or undo it |
|
|
172
|
+
| `c` | Copy it as an `opencode.json` rule, to paste yourself |
|
|
173
|
+
| `l`, click on the button | Open the ledger |
|
|
174
|
+
| `/` | Open the ledger and filter it |
|
|
175
|
+
| `p` | Pause Trust in this project (it keeps counting, and answers nothing) — again to resume |
|
|
176
|
+
| `?` | Every key, one line each |
|
|
177
|
+
| `esc` | Close (`q` too) |
|
|
178
|
+
|
|
179
|
+
In the ledger:
|
|
180
|
+
|
|
181
|
+
| Key | |
|
|
182
|
+
| --- | --- |
|
|
183
|
+
| `j` `k` `↑` `↓`, wheel, click | Move over families and commands |
|
|
184
|
+
| `←` `h` / `→` `l` | Fold / open a family — on a command inside one, go to its heading |
|
|
185
|
+
| `space` `enter` | Open or fold a family; on `+ N more`, list the rest |
|
|
186
|
+
| `tab` | Into the card's buttons and back; `←` `→` choose one, `enter` presses it |
|
|
187
|
+
| `x` | Revoke a command for every agent, or a family — every command in it, and its widening. Still learning, it forgets the count |
|
|
188
|
+
| `w` | Trust any command in the family, or undo it |
|
|
189
|
+
| `c` | Copy it as an `opencode.json` rule — a family as `"ls *": "allow"` (config cannot say which agent) |
|
|
190
|
+
| `/` | Filter by text; `enter` keeps it, `esc` clears it |
|
|
191
|
+
| `p` | Pause or resume |
|
|
192
|
+
| `?` | Every key, one line each |
|
|
193
|
+
| `esc` | One step back: the keys, the card's buttons, the filter — then to the activity, which `esc` closes |
|
|
194
|
+
|
|
195
|
+
Trust unused for `expireDays` (30 by default) has to be earned again.
|
|
196
|
+
|
|
197
|
+
## Settings
|
|
198
|
+
|
|
199
|
+
In the bundle's entry (`"trust": { … }`), this package's own, or the `trust` section of
|
|
200
|
+
`~/.config/opencode-cockpit/config.json` and a project's `.cockpit.json`:
|
|
201
|
+
|
|
202
|
+
| Setting | Default | |
|
|
203
|
+
| --- | --- | --- |
|
|
204
|
+
| `threshold` | `3` | Approvals in a row, by you, before Trust answers |
|
|
205
|
+
| `dangerExtra` | `5` | What a dangerous command costs on top |
|
|
206
|
+
| `expireDays` | `30` | Days unused before trust has to be earned again; `0` never |
|
|
207
|
+
| `enabled` | `true` | `false` turns Trust off (the bundle also has `features.trust: false`) |
|
|
208
|
+
| `sidebar` | `false` | Show the block in the sidebar. The palette's "Show or hide Trust in the sidebar" flips it for the session |
|
|
209
|
+
| `sidebarRows` | `3` | Answers listed in the sidebar |
|
|
210
|
+
| `sidebarOrder` | `160` | Where the block sits in the sidebar; lower draws first |
|
|
211
|
+
| `keybinds` | `{ "cockpit.trust.ledger": "<leader>p" }` | The key that opens the ledger |
|
|
212
|
+
|
|
213
|
+
The ledger lives outside the project, in
|
|
214
|
+
`~/.local/share/opencode-cockpit/trust/<project>-<hash>/events.ndjson` (`$COCKPIT_HOME` or
|
|
215
|
+
`$XDG_DATA_HOME` move it): one line per event, appended, shared by every OpenCode window on the
|
|
216
|
+
project.
|
|
217
|
+
|
|
218
|
+
## See it without OpenCode
|
|
219
|
+
|
|
220
|
+
```sh
|
|
221
|
+
bunx @opencode-cockpit/trust preview
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Draws the sidebar block, the activity and the ledger from sample projects, in your terminal —
|
|
225
|
+
`--view activity` or `--view ledger` for one screen, `--columns`/`--rows` for another size, `--html`
|
|
226
|
+
to judge the colours in a browser.
|
|
227
|
+
|
|
228
|
+
## Troubleshooting
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
npx opencode-cockpit@latest doctor
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
checks OpenCode, its config, Cockpit's logs and the daemon, and prints the fix for anything wrong
|
|
235
|
+
([what it checks](https://codestz.github.io/opencode-cockpit/help/doctor/)).
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
tail -50 ~/.cache/opencode-cockpit/cockpit.log
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
shows every answer Trust gave and why; `COCKPIT_DEBUG=1 opencode` adds every request it looked at and
|
|
242
|
+
every reply it did not count.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The see-it loop: the sidebar block and both Trust screens — the activity `/trust` opens on and the
|
|
4
|
+
* ledger behind `l` — drawn in this terminal from sample worlds, with no OpenCode running. The same
|
|
5
|
+
* rows OpenCode draws; only the colours come from a fixed palette (OpenCode's default theme) instead
|
|
6
|
+
* of the user's.
|
|
7
|
+
*
|
|
8
|
+
* bunx @opencode-cockpit/trust preview every sample, both screens
|
|
9
|
+
* bunx @opencode-cockpit/trust preview --sample busy one of them
|
|
10
|
+
* bunx @opencode-cockpit/trust preview --view activity only the screen /trust opens on
|
|
11
|
+
* bunx @opencode-cockpit/trust preview --view ledger only the ledger (the explorer and its card)
|
|
12
|
+
* bunx @opencode-cockpit/trust preview --columns 80 the dialog at another width
|
|
13
|
+
* bunx @opencode-cockpit/trust preview --rows 20 the dialog in a short window
|
|
14
|
+
* bunx @opencode-cockpit/trust preview --width 30 the sidebar at another width
|
|
15
|
+
* bunx @opencode-cockpit/trust preview --keys the key lists (`?`) too
|
|
16
|
+
* bunx @opencode-cockpit/trust preview --html > a.html the same, as a page, to judge colour in a browser
|
|
17
|
+
*
|
|
18
|
+
* The ledger is drawn more than once a sample: as it opens, then with the cursor on a trusted command
|
|
19
|
+
* in its open family, on a family you widened, on one still counting, on a dangerous command, on
|
|
20
|
+
* OpenCode's own "always", and with `tab` into the card's buttons.
|
|
21
|
+
*/
|
|
22
|
+
import { SAMPLE_NOW, SAMPLE_ROOT, SAMPLE_SETTINGS, SAMPLES } from "../core/sample.js";
|
|
23
|
+
import { activityRows } from "../core/view/activity.js";
|
|
24
|
+
import { explorerModel, explorerRows, reveal } from "../core/view/explorer.js";
|
|
25
|
+
import { TINTS } from "../core/view/rows.js";
|
|
26
|
+
import { sidebarRows } from "../core/view/sidebar.js";
|
|
27
|
+
|
|
28
|
+
/** OpenCode's default theme, measured (docs/opencode/v2.md). The dialog is drawn on the panel colour. */
|
|
29
|
+
const HEX = {
|
|
30
|
+
text: "#eeeeee",
|
|
31
|
+
muted: "#808080",
|
|
32
|
+
accent: "#9d7cd8",
|
|
33
|
+
info: "#56b6c2",
|
|
34
|
+
tool: "#fab283",
|
|
35
|
+
success: "#7fd88f",
|
|
36
|
+
error: "#e06c75",
|
|
37
|
+
warning: "#f5a742",
|
|
38
|
+
border: "#484848"
|
|
39
|
+
};
|
|
40
|
+
const PANEL = "#141414";
|
|
41
|
+
const ELEMENT = "#1e1e1e";
|
|
42
|
+
const channels = hex => [1, 3, 5].map(i => Number.parseInt(hex.slice(i, i + 2), 16));
|
|
43
|
+
const toHex = values => `#${values.map(v => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
|
|
44
|
+
/** A tint as `tui/render.ts` mixes it: the tone faded into the panel colour. */
|
|
45
|
+
const mix = (tone, amount) => {
|
|
46
|
+
const a = channels(tone);
|
|
47
|
+
const b = channels(PANEL);
|
|
48
|
+
return toHex(a.map((value, i) => b[i] + (value - b[i]) * amount));
|
|
49
|
+
};
|
|
50
|
+
const FILL = {
|
|
51
|
+
selected: ELEMENT,
|
|
52
|
+
panel: ELEMENT,
|
|
53
|
+
button: ELEMENT,
|
|
54
|
+
buttonOn: HEX.accent,
|
|
55
|
+
chip: mix(HEX[TINTS.chip.tone], TINTS.chip.amount),
|
|
56
|
+
ok: mix(HEX[TINTS.ok.tone], TINTS.ok.amount),
|
|
57
|
+
warn: mix(HEX[TINTS.warn.tone], TINTS.warn.amount),
|
|
58
|
+
err: mix(HEX[TINTS.err.tone], TINTS.err.amount)
|
|
59
|
+
};
|
|
60
|
+
const ink = tone => tone === "ink" ? PANEL : HEX[tone ?? "text"];
|
|
61
|
+
const rgb = hex => channels(hex).join(";");
|
|
62
|
+
const args = process.argv.slice(2);
|
|
63
|
+
const html = args.includes("--html");
|
|
64
|
+
const color = html || process.stdout.isTTY && !process.env.NO_COLOR;
|
|
65
|
+
const entities = text => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
66
|
+
function paint(row) {
|
|
67
|
+
if (!color) return row.map(run => run.text).join("");
|
|
68
|
+
if (html) return row.map(run => {
|
|
69
|
+
const style = [`color:${ink(run.tone)}`];
|
|
70
|
+
if (run.fill && run.fill !== "none") style.push(`background:${FILL[run.fill]}`);
|
|
71
|
+
if (run.bold) style.push("font-weight:bold");
|
|
72
|
+
if (run.faint) style.push("opacity:.55");
|
|
73
|
+
return `<span style="${style.join(";")}">${entities(run.text)}</span>`;
|
|
74
|
+
}).join("");
|
|
75
|
+
return row.map(run => {
|
|
76
|
+
const codes = [`38;2;${rgb(ink(run.tone))}`];
|
|
77
|
+
if (run.fill && run.fill !== "none") codes.push(`48;2;${rgb(FILL[run.fill])}`);
|
|
78
|
+
if (run.bold) codes.push("1");
|
|
79
|
+
if (run.faint) codes.push("2");
|
|
80
|
+
return `\x1b[${codes.join(";")}m${run.text}\x1b[0m`;
|
|
81
|
+
}).join("");
|
|
82
|
+
}
|
|
83
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
84
|
+
process.stdout.write(`Usage: trust preview [--sample ${Object.keys(SAMPLES).join("|")}] [--view activity|ledger] [--columns <dialog columns>] [--rows <dialog rows>] [--width <sidebar columns>] [--keys] [--html]\n`);
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
const value = flag => {
|
|
88
|
+
const at = args.indexOf(flag);
|
|
89
|
+
return at >= 0 ? args[at + 1] : undefined;
|
|
90
|
+
};
|
|
91
|
+
const sidebarWidth = Number(value("--width")) || 36;
|
|
92
|
+
const columns = Number(value("--columns")) || Math.max(60, Math.min(process.stdout.columns || 100, 116));
|
|
93
|
+
const fixedRows = Number(value("--rows")) || undefined;
|
|
94
|
+
const only = value("--sample");
|
|
95
|
+
const view = value("--view");
|
|
96
|
+
if (view !== undefined && view !== "activity" && view !== "ledger") {
|
|
97
|
+
process.stderr.write(`No view "${view}". Try: activity, ledger\n`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const keys = args.includes("--keys");
|
|
101
|
+
const names = only ? [only] : Object.keys(SAMPLES);
|
|
102
|
+
/** The folder name the header shows: the sample project's. */
|
|
103
|
+
const project = SAMPLE_ROOT.split("/").at(-1) ?? "";
|
|
104
|
+
const frame = rows => {
|
|
105
|
+
const edge = html ? `<span style="color:${HEX.border}">│</span>` : color ? `\x1b[38;2;${rgb(HEX.border)}m│\x1b[0m` : "│";
|
|
106
|
+
return rows.map(row => `${edge}${paint(row)}${edge}`);
|
|
107
|
+
};
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
const make = SAMPLES[name];
|
|
111
|
+
if (!make) {
|
|
112
|
+
process.stderr.write(`No sample "${name}". Try: ${Object.keys(SAMPLES).join(", ")}\n`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
const {
|
|
116
|
+
engine,
|
|
117
|
+
trouble
|
|
118
|
+
} = make();
|
|
119
|
+
out.push("", `── ${name} ──`);
|
|
120
|
+
if (view === undefined) {
|
|
121
|
+
out.push("", "Sidebar", "");
|
|
122
|
+
const sidebar = sidebarRows({
|
|
123
|
+
width: sidebarWidth,
|
|
124
|
+
recent: engine.recent(),
|
|
125
|
+
count: engine.count(),
|
|
126
|
+
pending: engine.pending(),
|
|
127
|
+
state: engine.state,
|
|
128
|
+
limit: 3,
|
|
129
|
+
...(trouble ? {
|
|
130
|
+
trouble
|
|
131
|
+
} : {})
|
|
132
|
+
});
|
|
133
|
+
out.push(...(sidebar.length > 0 ? frame(sidebar) : ["(nothing: the block is silent)"]));
|
|
134
|
+
}
|
|
135
|
+
const reading = {
|
|
136
|
+
state: engine.state,
|
|
137
|
+
settings: SAMPLE_SETTINGS,
|
|
138
|
+
now: SAMPLE_NOW,
|
|
139
|
+
history: engine.history
|
|
140
|
+
};
|
|
141
|
+
const height = fixedRows ?? 24;
|
|
142
|
+
if (view !== "ledger") {
|
|
143
|
+
const drawActivity = (title, pick, withKeys = false) => {
|
|
144
|
+
const first = activityRows({
|
|
145
|
+
...reading,
|
|
146
|
+
width: columns,
|
|
147
|
+
height,
|
|
148
|
+
project
|
|
149
|
+
});
|
|
150
|
+
const selected = pick?.(first.model.items.map(item => item.key));
|
|
151
|
+
const {
|
|
152
|
+
rows
|
|
153
|
+
} = activityRows({
|
|
154
|
+
...reading,
|
|
155
|
+
width: columns,
|
|
156
|
+
height,
|
|
157
|
+
project,
|
|
158
|
+
...(selected !== undefined ? {
|
|
159
|
+
selected
|
|
160
|
+
} : {}),
|
|
161
|
+
...(withKeys ? {
|
|
162
|
+
keys: true
|
|
163
|
+
} : {})
|
|
164
|
+
});
|
|
165
|
+
out.push("", title, "");
|
|
166
|
+
out.push(...frame(rows));
|
|
167
|
+
};
|
|
168
|
+
drawActivity("Activity — as /trust opens");
|
|
169
|
+
if (keys) drawActivity("Activity — ? keys", undefined, true);
|
|
170
|
+
}
|
|
171
|
+
if (view !== "activity") {
|
|
172
|
+
const open = new Set();
|
|
173
|
+
const full = new Set();
|
|
174
|
+
const tree = {
|
|
175
|
+
open,
|
|
176
|
+
full,
|
|
177
|
+
filter: ""
|
|
178
|
+
};
|
|
179
|
+
const families = explorerModel({
|
|
180
|
+
...reading,
|
|
181
|
+
...tree
|
|
182
|
+
}).families;
|
|
183
|
+
const drawLedger = (title, pick, extra = {}) => {
|
|
184
|
+
const filter = extra.filter ?? "";
|
|
185
|
+
const model = explorerModel({
|
|
186
|
+
...reading,
|
|
187
|
+
open,
|
|
188
|
+
full,
|
|
189
|
+
filter
|
|
190
|
+
});
|
|
191
|
+
const selected = pick(model.nodes)?.key;
|
|
192
|
+
const {
|
|
193
|
+
rows
|
|
194
|
+
} = explorerRows({
|
|
195
|
+
...reading,
|
|
196
|
+
open,
|
|
197
|
+
full,
|
|
198
|
+
filter,
|
|
199
|
+
width: columns,
|
|
200
|
+
height,
|
|
201
|
+
project,
|
|
202
|
+
...(selected !== undefined ? {
|
|
203
|
+
selected
|
|
204
|
+
} : {}),
|
|
205
|
+
...(extra.focus ? {
|
|
206
|
+
focus: extra.focus
|
|
207
|
+
} : {}),
|
|
208
|
+
...(extra.keys ? {
|
|
209
|
+
keys: true
|
|
210
|
+
} : {}),
|
|
211
|
+
...(extra.typing !== undefined ? {
|
|
212
|
+
typing: extra.typing
|
|
213
|
+
} : {})
|
|
214
|
+
});
|
|
215
|
+
out.push("", title, "");
|
|
216
|
+
out.push(...frame(rows));
|
|
217
|
+
};
|
|
218
|
+
drawLedger("Ledger — as l opens it", nodes => nodes[0]);
|
|
219
|
+
/** A trusted command in a family of several, revealed the way `enter` on the activity does. */
|
|
220
|
+
const trusted = families.flatMap(family => family.commands.length > 1 ? family.commands : []).find(command => command.phase === "answering");
|
|
221
|
+
if (trusted) {
|
|
222
|
+
const key = reveal(tree, families, trusted);
|
|
223
|
+
drawLedger("Ledger — a trusted command, its family open", nodes => nodes.find(node => node.key === key));
|
|
224
|
+
}
|
|
225
|
+
const widened = families.find(family => family.widened.length > 0);
|
|
226
|
+
if (widened) drawLedger("Ledger — a family you widened", nodes => nodes.find(node => node.kind === "family" && node.family === widened) ?? nodes.find(node => node.kind === "family" && node.family.key === widened.key));
|
|
227
|
+
const counting = families.flatMap(family => family.commands).find(command => command.phase === "learning" && !command.danger);
|
|
228
|
+
if (counting) {
|
|
229
|
+
const key = reveal(tree, families, counting);
|
|
230
|
+
drawLedger("Ledger — a command still learning", nodes => nodes.find(node => node.key === key));
|
|
231
|
+
}
|
|
232
|
+
const danger = families.flatMap(family => family.commands).find(command => command.danger && command.phase !== "answering");
|
|
233
|
+
if (danger) {
|
|
234
|
+
const key = reveal(tree, families, danger);
|
|
235
|
+
drawLedger("Ledger — a dangerous command", nodes => nodes.find(node => node.key === key));
|
|
236
|
+
}
|
|
237
|
+
if (engine.state.always.length > 0) drawLedger("Ledger — OpenCode's own always", nodes => nodes.find(node => node.kind === "always"));
|
|
238
|
+
if (trusted) {
|
|
239
|
+
const key = reveal(tree, families, trusted);
|
|
240
|
+
drawLedger("Ledger — tab into the card", nodes => nodes.find(node => node.key === key), {
|
|
241
|
+
focus: {
|
|
242
|
+
button: 1
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (keys) drawLedger("Ledger — ? keys", nodes => nodes[0], {
|
|
247
|
+
keys: true
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
process.stdout.write(html ? `<!doctype html><meta charset="utf-8"><title>Trust preview</title><body style="margin:0;padding:16px;background:${PANEL};color:${HEX.text}"><pre style="font:14px/1.35 Menlo,monospace;margin:0">${out.map(line => line.includes("<span") ? line : entities(line)).join("\n")}</pre>\n` : `${out.join("\n")}\n`);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Trust takes from the host's events, whichever OpenCode sent them. `v1.ts` and `v2.ts` turn
|
|
3
|
+
* their version's events into these, and nothing past them knows which version it is.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const obj = value => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
7
|
+
export const str = value => typeof value === "string" ? value : undefined;
|
|
8
|
+
export const strings = value => Array.isArray(value) ? value.filter(item => typeof item === "string") : [];
|
|
9
|
+
export const isReply = value => value === "once" || value === "always" || value === "reject";
|
|
10
|
+
|
|
11
|
+
/** The command line and working directory from a shell call's arguments (v1 `workdir`, v2 `cwd`). */
|
|
12
|
+
export function commandOf(input) {
|
|
13
|
+
if (!input) return {};
|
|
14
|
+
const line = str(input.command) ?? str(input.cmd);
|
|
15
|
+
const workdir = str(input.workdir) ?? str(input.cwd);
|
|
16
|
+
return {
|
|
17
|
+
...(line !== undefined ? {
|
|
18
|
+
line
|
|
19
|
+
} : {}),
|
|
20
|
+
...(workdir ? {
|
|
21
|
+
workdir
|
|
22
|
+
} : {})
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode 1's events, as what Trust takes from them.
|
|
3
|
+
*
|
|
4
|
+
* `api.event.on(type)` hands over `{ type, properties }`. Shapes measured on 1.18.32
|
|
5
|
+
* (docs/opencode/permissions.md, and Subagents' recorded run for the message events):
|
|
6
|
+
*
|
|
7
|
+
* permission.asked { id, sessionID, permission: "bash", patterns, always, metadata,
|
|
8
|
+
* tool: { messageID, callID } }
|
|
9
|
+
* permission.replied { sessionID, requestID, reply: "once" | "always" | "reject" }
|
|
10
|
+
* message.part.updated { part: { type: "tool", callID, sessionID, messageID, state: { input } } }
|
|
11
|
+
* message.updated { info: { sessionID, agent } }
|
|
12
|
+
* session.created/updated { info: { id, agent } } — a subagent's session names its agent
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { canonical } from "../rules.js";
|
|
16
|
+
import { isReply, obj, str, strings } from "./seen.js";
|
|
17
|
+
export const V1_EVENTS = ["permission.asked", "permission.replied", "message.part.updated", "message.updated", "session.created", "session.updated"];
|
|
18
|
+
export function fromV1Event(raw) {
|
|
19
|
+
const event = obj(raw);
|
|
20
|
+
const props = obj(event.properties);
|
|
21
|
+
switch (event.type) {
|
|
22
|
+
case "permission.asked":
|
|
23
|
+
{
|
|
24
|
+
const id = str(props.id);
|
|
25
|
+
const sessionID = str(props.sessionID);
|
|
26
|
+
const permission = str(props.permission);
|
|
27
|
+
if (!id || !sessionID || !permission) return [];
|
|
28
|
+
const tool = obj(props.tool);
|
|
29
|
+
return [{
|
|
30
|
+
type: "asked",
|
|
31
|
+
request: {
|
|
32
|
+
id,
|
|
33
|
+
sessionID,
|
|
34
|
+
permission: canonical(permission),
|
|
35
|
+
patterns: strings(props.patterns),
|
|
36
|
+
always: strings(props.always),
|
|
37
|
+
...(str(tool.callID) ? {
|
|
38
|
+
call: str(tool.callID)
|
|
39
|
+
} : {}),
|
|
40
|
+
...(str(tool.messageID) ? {
|
|
41
|
+
messageID: str(tool.messageID)
|
|
42
|
+
} : {})
|
|
43
|
+
}
|
|
44
|
+
}];
|
|
45
|
+
}
|
|
46
|
+
case "permission.replied":
|
|
47
|
+
{
|
|
48
|
+
const sessionID = str(props.sessionID);
|
|
49
|
+
const requestID = str(props.requestID) ?? str(props.permissionID);
|
|
50
|
+
const reply = props.reply ?? props.response;
|
|
51
|
+
return sessionID && requestID && isReply(reply) ? [{
|
|
52
|
+
type: "replied",
|
|
53
|
+
sessionID,
|
|
54
|
+
requestID,
|
|
55
|
+
reply
|
|
56
|
+
}] : [];
|
|
57
|
+
}
|
|
58
|
+
case "message.part.updated":
|
|
59
|
+
{
|
|
60
|
+
const part = obj(props.part);
|
|
61
|
+
const call = str(part.callID);
|
|
62
|
+
const sessionID = str(part.sessionID);
|
|
63
|
+
const input = obj(obj(part.state).input);
|
|
64
|
+
if (part.type !== "tool" || !call || !sessionID || Object.keys(input).length === 0) return [];
|
|
65
|
+
return [{
|
|
66
|
+
type: "call",
|
|
67
|
+
sessionID,
|
|
68
|
+
call,
|
|
69
|
+
...(str(part.messageID) ? {
|
|
70
|
+
messageID: str(part.messageID)
|
|
71
|
+
} : {}),
|
|
72
|
+
input
|
|
73
|
+
}];
|
|
74
|
+
}
|
|
75
|
+
case "message.updated":
|
|
76
|
+
{
|
|
77
|
+
const info = obj(props.info);
|
|
78
|
+
const sessionID = str(info.sessionID);
|
|
79
|
+
const agent = str(info.agent) ?? str(info.mode);
|
|
80
|
+
return sessionID && agent ? [{
|
|
81
|
+
type: "agent",
|
|
82
|
+
sessionID,
|
|
83
|
+
agent
|
|
84
|
+
}] : [];
|
|
85
|
+
}
|
|
86
|
+
case "session.created":
|
|
87
|
+
case "session.updated":
|
|
88
|
+
{
|
|
89
|
+
const info = obj(props.info);
|
|
90
|
+
const sessionID = str(info.id);
|
|
91
|
+
const agent = str(info.agent);
|
|
92
|
+
return sessionID && agent ? [{
|
|
93
|
+
type: "agent",
|
|
94
|
+
sessionID,
|
|
95
|
+
agent
|
|
96
|
+
}] : [];
|
|
97
|
+
}
|
|
98
|
+
default:
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A pending request as `client.permission.list()` returns it: the same shape as the event. */
|
|
104
|
+
export function fromV1Pending(list) {
|
|
105
|
+
return (Array.isArray(list) ? list : []).flatMap(request => fromV1Event({
|
|
106
|
+
type: "permission.asked",
|
|
107
|
+
properties: request
|
|
108
|
+
}));
|
|
109
|
+
}
|