opencode-skill-audit 0.1.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/LICENSE +21 -0
- package/README.md +367 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +126 -0
- package/dist/log.d.ts +77 -0
- package/dist/record.d.ts +10 -0
- package/dist/tui.d.ts +7 -0
- package/dist/tui.js +307 -0
- package/dist/view.d.ts +30 -0
- package/dist/watch.d.ts +12 -0
- package/package.json +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sven Depickere
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# skill-audit
|
|
2
|
+
|
|
3
|
+
[](https://github.com/DepickereSven/skill-audit/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Deterministic audit trail for [Claude Code](https://code.claude.com),
|
|
7
|
+
[Codex](https://developers.openai.com/codex/) and [opencode](https://opencode.ai) sessions: which
|
|
8
|
+
observable **skills** were invoked, when, and which **files** were changed afterwards. It gives you
|
|
9
|
+
a quick skill-compliance signal before you read the code.
|
|
10
|
+
|
|
11
|
+
On opencode it also renders live in the TUI sidebar, so the audit sits next to the conversation
|
|
12
|
+
with no command to run.
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
● Skill audit — f3a91c2e · ~/Dev/Web · 14:02→14:20 UTC
|
|
16
|
+
|
|
17
|
+
14:02 ⚡ superpowers:brainstorming
|
|
18
|
+
14:05 ⚡ superpowers:test-driven-development
|
|
19
|
+
14:06 │ ✎ src/auth/token.ts Edit
|
|
20
|
+
14:09 │ ✎ src/auth/token.test.ts Write
|
|
21
|
+
14:20 ⚠ (no skill active)
|
|
22
|
+
14:20 │ ✎ src/index.ts Edit
|
|
23
|
+
|
|
24
|
+
● 2 skill runs (2 distinct) · 3 files touched · ⚠ 1 edits outside skill context
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Why
|
|
28
|
+
|
|
29
|
+
You ask an agent to follow a skill. Did it? Reading the transcript to find out is slow, and asking
|
|
30
|
+
another model to judge costs tokens and is itself non-deterministic.
|
|
31
|
+
|
|
32
|
+
LLMs are not deterministic; hook events are. This plugin logs the facts exposed by each host's
|
|
33
|
+
documented hook API:
|
|
34
|
+
|
|
35
|
+
- **No LLM judging, no tokens.** Everything except the two in-session slash/skill commands runs
|
|
36
|
+
entirely outside the model.
|
|
37
|
+
- **No transcript parsing.** Only documented hook payloads, so nothing silently breaks on a
|
|
38
|
+
transcript format change.
|
|
39
|
+
- **One data model across three hosts.** The NDJSON log is the only contract, so the CLI reads
|
|
40
|
+
opencode sessions and the opencode sidebar reads Claude Code sessions.
|
|
41
|
+
- **The red flag is a number.** `⚠ edits outside skill context` counts files changed while no
|
|
42
|
+
observable skill was active.
|
|
43
|
+
|
|
44
|
+
## Contents
|
|
45
|
+
|
|
46
|
+
- [How it works](#how-it-works)
|
|
47
|
+
- [Install](#install)
|
|
48
|
+
- [Verify the install](#verify-the-install)
|
|
49
|
+
- [Usage](#usage)
|
|
50
|
+
- [Data format](#data-format)
|
|
51
|
+
- [Troubleshooting](#troubleshooting)
|
|
52
|
+
- [Honest limitations](#honest-limitations)
|
|
53
|
+
- [Uninstall](#uninstall)
|
|
54
|
+
- [Requirements](#requirements)
|
|
55
|
+
- [Development](#development)
|
|
56
|
+
- [License](#license)
|
|
57
|
+
|
|
58
|
+
## How it works
|
|
59
|
+
|
|
60
|
+
`PostToolUse` hooks capture skill-tool calls and file edits. On Codex, a `UserPromptSubmit` hook
|
|
61
|
+
also captures explicit `$skill-name` references, and `apply_patch` payloads are expanded into one
|
|
62
|
+
file event per path. On opencode a plugin does the same job through the `tool.execute.after` hook.
|
|
63
|
+
Events are appended as NDJSON to `~/.claude/skill-audit/<session_id>.ndjson`; override the location
|
|
64
|
+
with `SKILL_AUDIT_DIR`.
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
Claude Code / Codex ──hooks───▶ logger.sh ──┐
|
|
68
|
+
├─▶ ~/.claude/skill-audit/<sid>.ndjson
|
|
69
|
+
opencode ──tool.execute.after──▶ plugin ────┘ │
|
|
70
|
+
├─▶ skill-audit status/report/watch/list
|
|
71
|
+
└─▶ opencode sidebar (live)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The log is the only contract between the writers and the viewers, so the CLI reads opencode
|
|
75
|
+
sessions and the opencode sidebar reads Claude Code sessions.
|
|
76
|
+
|
|
77
|
+
Subagent hook calls use the parent session ID, so delegated edits appear in the same audit.
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
### Claude Code
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
/plugin marketplace add DepickereSven/skill-audit
|
|
85
|
+
/plugin install skill-audit@depickeresven-skill-audit
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Restart Claude Code so the hooks load.
|
|
89
|
+
|
|
90
|
+
> **Migrating from a manual setup?** Remove any `logger.sh` entries from the `hooks` block of
|
|
91
|
+
> `~/.claude/settings.json` first, or every event is logged twice.
|
|
92
|
+
|
|
93
|
+
### Codex
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
codex plugin marketplace add DepickereSven/skill-audit
|
|
97
|
+
codex plugin add skill-audit@depickeresven-skill-audit
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Start a new Codex session after installation. Codex asks you to review and trust plugin-bundled
|
|
101
|
+
hooks before they run. Plugins are available in Codex CLI and the ChatGPT desktop app's Codex
|
|
102
|
+
surface, but not in the IDE extension. See the official
|
|
103
|
+
[plugin](https://developers.openai.com/codex/plugins/build) and
|
|
104
|
+
[hook](https://developers.openai.com/codex/hooks) documentation.
|
|
105
|
+
|
|
106
|
+
Invoke the bundled Codex skill with `$skill-audit`.
|
|
107
|
+
|
|
108
|
+
### opencode
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
opencode plugin opencode-skill-audit --global
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Restart opencode so the plugin loads. It registers two things: a server hook that logs
|
|
115
|
+
`skill`, `edit`, `write` and `apply_patch` tool calls, and a sidebar section that renders the
|
|
116
|
+
current session's timeline live.
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+

|
|
120
|
+
|
|
121
|
+
Click the header to collapse the section, or a skill row to fold its files away.
|
|
122
|
+
|
|
123
|
+
opencode discovers skills natively, including from `.claude/skills/` and `.agents/skills/`, so
|
|
124
|
+
skills you already use are logged without moving them. To get the in-session report as well, link
|
|
125
|
+
the bundled skill into a directory opencode scans:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
ln -sf "$PWD/skills/skill-audit" ~/.config/opencode/skills/skill-audit
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### CLI on your PATH (recommended)
|
|
132
|
+
|
|
133
|
+
The viewer works from any terminal. Link the installed script somewhere on your PATH.
|
|
134
|
+
|
|
135
|
+
Claude Code:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
ln -sf ~/.claude/plugins/cache/*/skill-audit/*/scripts/skill-audit ~/.local/bin/skill-audit
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Codex:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
ln -sf ~/.codex/plugins/cache/*/skill-audit/*/scripts/skill-audit ~/.local/bin/skill-audit
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
You can also clone this repository and link `scripts/skill-audit` directly.
|
|
148
|
+
|
|
149
|
+
`~/.local/bin` is not on every system's `PATH`. Check with `command -v skill-audit`; if it prints
|
|
150
|
+
nothing, add the directory in your shell profile:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Claude Code statusline segment (optional)
|
|
157
|
+
|
|
158
|
+
Plugins cannot modify your statusline. Add this to your own statusline script to get a live
|
|
159
|
+
`⚡2 ✎5 tdd` segment:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
sid=$(echo "$input" | jq -r '.session_id // empty')
|
|
163
|
+
audit_log="$HOME/.claude/skill-audit/$sid.ndjson"
|
|
164
|
+
if [ -n "$sid" ] && [ -s "$audit_log" ]; then
|
|
165
|
+
audit=$(jq -rs '
|
|
166
|
+
[ .[] | select(.kind=="skill") ] as $s
|
|
167
|
+
| ([ .[] | select(.kind=="file") | .path ] | unique | length) as $f
|
|
168
|
+
| "⚡\($s|length) ✎\($f)"
|
|
169
|
+
+ (if ($s|length) > 0 then " " + ($s[-1].name | split(":") | last) else "" end)
|
|
170
|
+
' "$audit_log" 2>/dev/null)
|
|
171
|
+
[ -n "$audit" ] && parts="$parts | $audit"
|
|
172
|
+
fi
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Verify the install
|
|
176
|
+
|
|
177
|
+
Hooks that never fire look exactly like a session with no skill usage, so confirm once after
|
|
178
|
+
installing.
|
|
179
|
+
|
|
180
|
+
1. In a **new** session on the host you installed, invoke any skill and edit one file. Invoking
|
|
181
|
+
this plugin's own skill is enough:
|
|
182
|
+
- Claude Code: `/skill-audit`
|
|
183
|
+
- Codex: `$skill-audit`
|
|
184
|
+
- opencode: watch the sidebar section appear
|
|
185
|
+
2. Check that a log exists and is growing:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
ls -la ~/.claude/skill-audit/
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
3. Read it back from any terminal:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
skill-audit list # sessions, newest first
|
|
195
|
+
skill-audit status # counts + recent timeline for the newest session
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
If `list` prints `no session logs in ...`, nothing was written — go to
|
|
199
|
+
[Troubleshooting](#troubleshooting).
|
|
200
|
+
|
|
201
|
+
## Usage
|
|
202
|
+
|
|
203
|
+
| Command | What | Tokens |
|
|
204
|
+
|----------------------------|------------------------------------------------------------------|-----------:|
|
|
205
|
+
| `skill-audit status [sid]` | Compact counts and recent timeline | 0 |
|
|
206
|
+
| `skill-audit report [sid]` | Full timeline | 0 |
|
|
207
|
+
| `skill-audit watch [sid]` | Live view, refreshed every two seconds; `q` quits | 0 |
|
|
208
|
+
| `skill-audit list` | Recent sessions | 0 |
|
|
209
|
+
| `skill-audit --help` | Usage summary | 0 |
|
|
210
|
+
| `! skill-audit status` | Run inside a Claude Code session; queues while the model is busy | 0 |
|
|
211
|
+
| opencode sidebar | Live timeline beside the conversation; no command to run | 0 |
|
|
212
|
+
| `/skill-audit` | Show the report inside Claude Code | Model turn |
|
|
213
|
+
| `$skill-audit` | Show the report inside Codex | Model turn |
|
|
214
|
+
|
|
215
|
+
`status`, `report` and `watch` all take an optional session ID. Without one they use the most
|
|
216
|
+
recently modified log, which is the wrong session if you run several at once — get the ID from
|
|
217
|
+
`skill-audit list` and pass it explicitly.
|
|
218
|
+
|
|
219
|
+
The `⚠ edits outside skill context` counter is the compliance red flag: files changed while no
|
|
220
|
+
observable skill was active.
|
|
221
|
+
|
|
222
|
+
## Data format
|
|
223
|
+
|
|
224
|
+
One NDJSON file per session, one event per line, appended in chronological order:
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{"ts":"2026-07-10T14:05:11Z","kind":"skill","name":"superpowers:test-driven-development","args":"","cwd":"/Users/me/proj","source":"tool"}
|
|
228
|
+
{"ts":"2026-07-10T14:06:40Z","kind":"file","tool":"apply_patch","path":"/Users/me/proj/src/auth/token.ts","cwd":"/Users/me/proj"}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
| Field | On | Meaning |
|
|
232
|
+
|-----------|---------|-------------------------------------------------------------------------------|
|
|
233
|
+
| `ts` | both | UTC timestamp, `YYYY-MM-DDThh:mm:ssZ` |
|
|
234
|
+
| `kind` | both | `skill` or `file` — the only two event kinds |
|
|
235
|
+
| `cwd` | both | Session working directory as reported by the host |
|
|
236
|
+
| `name` | `skill` | Skill identifier, e.g. `superpowers:test-driven-development` |
|
|
237
|
+
| `args` | `skill` | Arguments passed to the skill tool; empty string when there were none |
|
|
238
|
+
| `source` | `skill` | `tool` for an observed skill tool call, `prompt` for a Codex `$skill-name` |
|
|
239
|
+
| `turn_id` | `skill` | Codex turn identifier; present only when the host supplies one |
|
|
240
|
+
| `tool` | `file` | Tool that made the edit: `Edit`, `Write`, `NotebookEdit`, `apply_patch`… |
|
|
241
|
+
| `path` | `file` | Absolute path of the changed file (relative paths are resolved against `cwd`) |
|
|
242
|
+
|
|
243
|
+
Within a Codex turn, a repeated `(turn_id, name)` skill pair is written once, so a skill named
|
|
244
|
+
several times in one prompt does not inflate the counts.
|
|
245
|
+
|
|
246
|
+
The format is open, so you can build other viewers on top. Prune old logs with:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
find ~/.claude/skill-audit -name '*.ndjson' -mtime +30 -delete
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Troubleshooting
|
|
253
|
+
|
|
254
|
+
**Nothing is logged / `no session logs in ...`**
|
|
255
|
+
|
|
256
|
+
- Did you restart the host after installing? Hooks and plugins load at startup, and an existing
|
|
257
|
+
session keeps running without them.
|
|
258
|
+
- Confirm the plugin is installed: `claude plugin list`, `codex plugin list`, or check the
|
|
259
|
+
`plugin` array in `~/.config/opencode/opencode.json`.
|
|
260
|
+
- On Codex, hooks only run after you review and trust them — accept the prompt.
|
|
261
|
+
- Is `jq` installed? `logger.sh` exits silently without it, by design: the hook must never block a
|
|
262
|
+
session. Check with `command -v jq`.
|
|
263
|
+
|
|
264
|
+
**Every event appears twice.** A manual `logger.sh` entry is still in the `hooks` block of
|
|
265
|
+
`~/.claude/settings.json` alongside the plugin's. Remove the manual one.
|
|
266
|
+
|
|
267
|
+
**The CLI shows an empty or unrelated session.** Without an argument the viewer picks the most
|
|
268
|
+
recently modified log, which is the wrong one when sessions run in parallel. Run `skill-audit list`
|
|
269
|
+
and pass the ID: `skill-audit report <sid>`.
|
|
270
|
+
|
|
271
|
+
**The CLI finds nothing but the logs exist.** Writer and viewer disagree about the directory. If
|
|
272
|
+
you set `SKILL_AUDIT_DIR` for the host, export it for your shell too — otherwise the CLI looks in
|
|
273
|
+
`~/.claude/skill-audit`.
|
|
274
|
+
|
|
275
|
+
**`skill-audit: command not found`.** The symlink is missing or its directory is not on `PATH`;
|
|
276
|
+
see [CLI on your PATH](#cli-on-your-path-recommended).
|
|
277
|
+
|
|
278
|
+
**A skill ran but is missing from the timeline.** Expected in some cases — see
|
|
279
|
+
[Honest limitations](#honest-limitations).
|
|
280
|
+
|
|
281
|
+
## Honest limitations
|
|
282
|
+
|
|
283
|
+
- **Invocation is not compliance.** The log proves that a skill was explicitly selected or exposed
|
|
284
|
+
as a tool event, not that the result followed every instruction.
|
|
285
|
+
- **Codex automatic skill loading is not a hook event today.** Explicit `$skill-name` references
|
|
286
|
+
are captured; skills that Codex chooses automatically are not. No transcript parsing is used to
|
|
287
|
+
fill that gap.
|
|
288
|
+
- **Prompt-sourced entries are syntax-level evidence.** Codex supplies plain prompt text to the
|
|
289
|
+
hook, so a lower-case dollar-prefixed token can be logged even if it does not resolve to an
|
|
290
|
+
installed skill. The NDJSON `source: "prompt"` field distinguishes these entries.
|
|
291
|
+
- **Only observable file tools are captured.** Claude `Edit`/`Write`/`NotebookEdit`, Codex
|
|
292
|
+
`apply_patch`, and opencode `edit`/`write`/`apply_patch` edits are logged. Files created
|
|
293
|
+
indirectly by shell commands are not visible as separate file events.
|
|
294
|
+
- **opencode agents and subagents are not logged.** They have no equivalent on the other two
|
|
295
|
+
hosts, so logging them would add an event kind only one host can emit. The audit keeps one data
|
|
296
|
+
model across all three.
|
|
297
|
+
- **Session-start injected skills** are context, not skill tool calls, and do not appear.
|
|
298
|
+
- **Concurrent sessions:** the newest-log default can pick the wrong session; pass the session ID
|
|
299
|
+
explicitly after using `skill-audit list`.
|
|
300
|
+
|
|
301
|
+
## Uninstall
|
|
302
|
+
|
|
303
|
+
Claude Code:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
claude plugin uninstall skill-audit@depickeresven-skill-audit
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Codex:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
codex plugin remove skill-audit@depickeresven-skill-audit
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
opencode has no removal subcommand — delete the `"opencode-skill-audit"` entry from the `plugin`
|
|
316
|
+
array in `~/.config/opencode/opencode.json` (or the project-local `opencode.json`), and remove the
|
|
317
|
+
skill symlink if you made one:
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
rm -f ~/.config/opencode/skills/skill-audit
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Then restart the host. Finally, clean up the CLI symlink and the logs, which no uninstall touches:
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
rm -f ~/.local/bin/skill-audit
|
|
327
|
+
rm -rf ~/.claude/skill-audit
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Requirements
|
|
331
|
+
|
|
332
|
+
- macOS or Linux
|
|
333
|
+
- `bash` and `jq` for the CLI viewer and the Claude Code / Codex hooks
|
|
334
|
+
- opencode `>= 1.14` for the plugin and its sidebar
|
|
335
|
+
|
|
336
|
+
## Development
|
|
337
|
+
|
|
338
|
+
The hooks and the CLI viewer are plain bash (`scripts/`) with no build step. The opencode plugin
|
|
339
|
+
and sidebar are TypeScript (`src/`) built with [Bun](https://bun.sh).
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
bun install
|
|
343
|
+
bun run check # format:check + lint + typecheck (src and test) + tests
|
|
344
|
+
bun run build # dist/index.js (plugin) and dist/tui.js (sidebar)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
| Script | What |
|
|
348
|
+
|--------------------------|----------------------------------------------------------|
|
|
349
|
+
| `bun run format` | Prettier, write mode |
|
|
350
|
+
| `bun run lint` | ESLint over `src` and `test` |
|
|
351
|
+
| `bun run typecheck` | `tsc --noEmit` for `src` |
|
|
352
|
+
| `bun test` | Bun test suite in `test/` |
|
|
353
|
+
| `bun run check` | Everything CI runs |
|
|
354
|
+
| `bun run check:versions` | Assert `package.json` and both `plugin.json` files agree |
|
|
355
|
+
| `bun run sync:versions` | Rewrite the plugin manifests from `package.json` |
|
|
356
|
+
|
|
357
|
+
Tests live in `test/`. `test/format-contract.sh` pins the rendered CLI output against fixtures, so
|
|
358
|
+
a change to the timeline format has to be updated there deliberately — that output is the contract
|
|
359
|
+
the sidebar and any third-party viewer rely on.
|
|
360
|
+
|
|
361
|
+
Version bumps go through `npm version`, which runs `scripts/sync-versions.mjs` and stages
|
|
362
|
+
`.claude-plugin/plugin.json` and `.codex-plugin/plugin.json` alongside it. CI (`.github/workflows/ci.yml`)
|
|
363
|
+
runs `bun run check` plus a manifest-version job on every push and pull request.
|
|
364
|
+
|
|
365
|
+
## License
|
|
366
|
+
|
|
367
|
+
[MIT](LICENSE) © Sven Depickere
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
/**
|
|
3
|
+
* Server plugin: turns completed tool calls into audit events.
|
|
4
|
+
*
|
|
5
|
+
* The equivalent of the PostToolUse hooks the plugin registers on Claude Code
|
|
6
|
+
* and Codex, writing the same NDJSON log those hosts write.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SkillAuditServer: Plugin;
|
|
9
|
+
declare const _default: {
|
|
10
|
+
id: string;
|
|
11
|
+
server: Plugin;
|
|
12
|
+
};
|
|
13
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/log.ts
|
|
3
|
+
import { appendFileSync, mkdirSync, readFileSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { isAbsolute, join } from "path";
|
|
6
|
+
function logDir() {
|
|
7
|
+
return process.env.SKILL_AUDIT_DIR || join(homedir(), ".claude", "skill-audit");
|
|
8
|
+
}
|
|
9
|
+
function logPath(sessionID) {
|
|
10
|
+
return join(logDir(), `${sessionID}.ndjson`);
|
|
11
|
+
}
|
|
12
|
+
function append(sessionID, event) {
|
|
13
|
+
try {
|
|
14
|
+
mkdirSync(logDir(), { recursive: true });
|
|
15
|
+
appendFileSync(logPath(sessionID), `${JSON.stringify(event)}
|
|
16
|
+
`);
|
|
17
|
+
} catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function appendSkill(sessionID, input) {
|
|
22
|
+
if (!input.name)
|
|
23
|
+
return;
|
|
24
|
+
append(sessionID, {
|
|
25
|
+
ts: input.ts,
|
|
26
|
+
kind: "skill",
|
|
27
|
+
name: input.name,
|
|
28
|
+
args: "",
|
|
29
|
+
cwd: input.cwd,
|
|
30
|
+
source: "tool"
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function appendFile(sessionID, input) {
|
|
34
|
+
if (!input.path || input.path === "/dev/null") {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const path = isAbsolute(input.path) || !input.cwd ? input.path : join(input.cwd, input.path);
|
|
38
|
+
append(sessionID, {
|
|
39
|
+
ts: input.ts,
|
|
40
|
+
kind: "file",
|
|
41
|
+
tool: input.tool,
|
|
42
|
+
path,
|
|
43
|
+
cwd: input.cwd
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function parsePatch(args) {
|
|
47
|
+
const patchText = typeof args === "string" ? args : typeof args?.patchText === "string" ? args.patchText : typeof args?.patch === "string" ? args.patch : "";
|
|
48
|
+
const paths = [];
|
|
49
|
+
for (const line of patchText.split(`
|
|
50
|
+
`)) {
|
|
51
|
+
const match = /^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/.exec(line.trim());
|
|
52
|
+
const path = match?.[1]?.trim();
|
|
53
|
+
if (path && !paths.includes(path)) {
|
|
54
|
+
paths.push(path);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return paths;
|
|
58
|
+
}
|
|
59
|
+
function nowTs(date = new Date) {
|
|
60
|
+
return `${date.toISOString().slice(0, 19)}Z`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/record.ts
|
|
64
|
+
var FILE_TOOLS = new Set(["edit", "write", "multiedit"]);
|
|
65
|
+
var PATCH_TOOLS = new Set(["apply_patch", "patch"]);
|
|
66
|
+
function stringField(args, key) {
|
|
67
|
+
const value = args?.[key];
|
|
68
|
+
return typeof value === "string" ? value : "";
|
|
69
|
+
}
|
|
70
|
+
function record(call, cwd) {
|
|
71
|
+
try {
|
|
72
|
+
const { tool, sessionID, args } = call;
|
|
73
|
+
const ts = nowTs();
|
|
74
|
+
if (tool === "skill") {
|
|
75
|
+
appendSkill(sessionID, {
|
|
76
|
+
ts,
|
|
77
|
+
name: stringField(args, "name") || stringField(args, "skill"),
|
|
78
|
+
cwd
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (FILE_TOOLS.has(tool)) {
|
|
83
|
+
appendFile(sessionID, {
|
|
84
|
+
ts,
|
|
85
|
+
tool,
|
|
86
|
+
path: stringField(args, "filePath"),
|
|
87
|
+
cwd
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (PATCH_TOOLS.has(tool)) {
|
|
92
|
+
for (const path of parsePatch(args)) {
|
|
93
|
+
appendFile(sessionID, {
|
|
94
|
+
ts,
|
|
95
|
+
tool,
|
|
96
|
+
path,
|
|
97
|
+
cwd
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/index.ts
|
|
107
|
+
var SkillAuditServer = async ({ directory, worktree }) => {
|
|
108
|
+
const cwd = directory || worktree || process.cwd();
|
|
109
|
+
return {
|
|
110
|
+
"tool.execute.after": async ({ tool, sessionID, args }) => {
|
|
111
|
+
record({
|
|
112
|
+
tool,
|
|
113
|
+
sessionID,
|
|
114
|
+
args
|
|
115
|
+
}, cwd);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
var src_default = {
|
|
120
|
+
id: "skill-audit",
|
|
121
|
+
server: SkillAuditServer
|
|
122
|
+
};
|
|
123
|
+
export {
|
|
124
|
+
SkillAuditServer,
|
|
125
|
+
src_default as default
|
|
126
|
+
};
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Directory holding the per-session NDJSON logs, shared with the bash logger. */
|
|
2
|
+
export declare function logDir(): string;
|
|
3
|
+
export declare function logPath(sessionID: string): string;
|
|
4
|
+
export type SkillEvent = {
|
|
5
|
+
ts: string;
|
|
6
|
+
kind: "skill";
|
|
7
|
+
name: string;
|
|
8
|
+
args?: string;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
source?: string;
|
|
11
|
+
};
|
|
12
|
+
export type FileEvent = {
|
|
13
|
+
ts: string;
|
|
14
|
+
kind: "file";
|
|
15
|
+
tool: string;
|
|
16
|
+
path: string;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
};
|
|
19
|
+
export type AuditEvent = SkillEvent | FileEvent;
|
|
20
|
+
/**
|
|
21
|
+
* Parse NDJSON log text. Malformed lines are dropped rather than thrown on: the
|
|
22
|
+
* sidebar reads the log while the logger is appending to it, so a truncated
|
|
23
|
+
* trailing line is expected, not exceptional.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parse(text: string): AuditEvent[];
|
|
26
|
+
export declare const NO_SKILL = "(no skill active)";
|
|
27
|
+
export type TimelineFile = {
|
|
28
|
+
ts: string;
|
|
29
|
+
tool: string;
|
|
30
|
+
path: string;
|
|
31
|
+
};
|
|
32
|
+
export type SkillRun = {
|
|
33
|
+
skill: string;
|
|
34
|
+
ts: string;
|
|
35
|
+
files: TimelineFile[];
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Group a flat event list into skill runs, each carrying the files edited after
|
|
39
|
+
* it. Port of the jq reduce in scripts/skill-audit; the two must agree.
|
|
40
|
+
*/
|
|
41
|
+
export declare function group(events: AuditEvent[]): SkillRun[];
|
|
42
|
+
export type Summary = {
|
|
43
|
+
runs: number;
|
|
44
|
+
distinct: number;
|
|
45
|
+
files: number;
|
|
46
|
+
orphan: number;
|
|
47
|
+
};
|
|
48
|
+
export declare function summarize(events: AuditEvent[]): Summary;
|
|
49
|
+
export declare function appendSkill(sessionID: string, input: {
|
|
50
|
+
ts: string;
|
|
51
|
+
name: string;
|
|
52
|
+
cwd: string;
|
|
53
|
+
}): void;
|
|
54
|
+
export declare function appendFile(sessionID: string, input: {
|
|
55
|
+
ts: string;
|
|
56
|
+
tool: string;
|
|
57
|
+
path: string;
|
|
58
|
+
cwd: string;
|
|
59
|
+
}): void;
|
|
60
|
+
/**
|
|
61
|
+
* Extract touched paths from an apply_patch payload. opencode uses the same
|
|
62
|
+
* `*** Begin Patch` envelope as Codex, so this mirrors the sed parsing in
|
|
63
|
+
* logger.sh. Duplicates are collapsed, order of first appearance kept.
|
|
64
|
+
*/
|
|
65
|
+
export declare function parsePatch(args: unknown): string[];
|
|
66
|
+
/** Second-precision UTC timestamp, matching `date -u +%FT%TZ` in logger.sh. */
|
|
67
|
+
export declare function nowTs(date?: Date): string;
|
|
68
|
+
export type SessionView = {
|
|
69
|
+
runs: SkillRun[];
|
|
70
|
+
summary: Summary;
|
|
71
|
+
cwd: string;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Read one session's log. A missing or unreadable file is an empty session, not
|
|
75
|
+
* an error: the sidebar renders before the first event is ever written.
|
|
76
|
+
*/
|
|
77
|
+
export declare function readSession(sessionID: string): SessionView;
|
package/dist/record.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type ToolCall = {
|
|
2
|
+
tool: string;
|
|
3
|
+
sessionID: string;
|
|
4
|
+
args: unknown;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Map one completed opencode tool call onto audit events. Never throws: a hook
|
|
8
|
+
* that disturbs the session is worse than a missing log line.
|
|
9
|
+
*/
|
|
10
|
+
export declare function record(call: ToolCall, cwd: string): void;
|
package/dist/tui.d.ts
ADDED
package/dist/tui.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/tui.ts
|
|
3
|
+
import { createElement, insert, setProp } from "@opentui/solid";
|
|
4
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
5
|
+
|
|
6
|
+
// src/log.ts
|
|
7
|
+
import { appendFileSync, mkdirSync, readFileSync } from "fs";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { isAbsolute, join } from "path";
|
|
10
|
+
function logDir() {
|
|
11
|
+
return process.env.SKILL_AUDIT_DIR || join(homedir(), ".claude", "skill-audit");
|
|
12
|
+
}
|
|
13
|
+
function logPath(sessionID) {
|
|
14
|
+
return join(logDir(), `${sessionID}.ndjson`);
|
|
15
|
+
}
|
|
16
|
+
function parse(text) {
|
|
17
|
+
const events = [];
|
|
18
|
+
for (const line of text.split(`
|
|
19
|
+
`)) {
|
|
20
|
+
if (!line.trim()) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const event = JSON.parse(line);
|
|
25
|
+
if (event?.kind === "skill" || event?.kind === "file") {
|
|
26
|
+
events.push(event);
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
return events;
|
|
31
|
+
}
|
|
32
|
+
var NO_SKILL = "(no skill active)";
|
|
33
|
+
function group(events) {
|
|
34
|
+
const runs = [];
|
|
35
|
+
for (const event of events) {
|
|
36
|
+
if (event.kind === "skill") {
|
|
37
|
+
runs.push({
|
|
38
|
+
skill: event.name,
|
|
39
|
+
ts: event.ts,
|
|
40
|
+
files: []
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (runs.length === 0) {
|
|
45
|
+
runs.push({
|
|
46
|
+
skill: NO_SKILL,
|
|
47
|
+
ts: event.ts,
|
|
48
|
+
files: []
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
runs[runs.length - 1].files.push({
|
|
52
|
+
ts: event.ts,
|
|
53
|
+
tool: event.tool,
|
|
54
|
+
path: event.path
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return runs;
|
|
58
|
+
}
|
|
59
|
+
function summarize(events) {
|
|
60
|
+
const names = [];
|
|
61
|
+
const paths = new Set;
|
|
62
|
+
for (const event of events) {
|
|
63
|
+
if (event.kind === "skill") {
|
|
64
|
+
names.push(event.name);
|
|
65
|
+
} else {
|
|
66
|
+
paths.add(event.path);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const orphan = group(events).filter((run) => run.skill === NO_SKILL).reduce((total, run) => total + run.files.length, 0);
|
|
70
|
+
return {
|
|
71
|
+
runs: names.length,
|
|
72
|
+
distinct: new Set(names).size,
|
|
73
|
+
files: paths.size,
|
|
74
|
+
orphan
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
var EMPTY = {
|
|
78
|
+
runs: [],
|
|
79
|
+
summary: {
|
|
80
|
+
runs: 0,
|
|
81
|
+
distinct: 0,
|
|
82
|
+
files: 0,
|
|
83
|
+
orphan: 0
|
|
84
|
+
},
|
|
85
|
+
cwd: ""
|
|
86
|
+
};
|
|
87
|
+
function readSession(sessionID) {
|
|
88
|
+
let text;
|
|
89
|
+
try {
|
|
90
|
+
text = readFileSync(logPath(sessionID), "utf8");
|
|
91
|
+
} catch {
|
|
92
|
+
return EMPTY;
|
|
93
|
+
}
|
|
94
|
+
const events = parse(text);
|
|
95
|
+
const cwd = events.find((event) => event.cwd)?.cwd ?? "";
|
|
96
|
+
return {
|
|
97
|
+
runs: group(events),
|
|
98
|
+
summary: summarize(events),
|
|
99
|
+
cwd
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/view.ts
|
|
104
|
+
import { basename, relative } from "path";
|
|
105
|
+
function hhmm(ts) {
|
|
106
|
+
return ts.slice(11, 16);
|
|
107
|
+
}
|
|
108
|
+
function shortName(name) {
|
|
109
|
+
const parts = name.split(":");
|
|
110
|
+
return parts[parts.length - 1] || name;
|
|
111
|
+
}
|
|
112
|
+
function headerLine(summary) {
|
|
113
|
+
const counts = `\u26A1${summary.runs} \u270E${summary.files}${summary.orphan > 0 ? ` \u26A0${summary.orphan}` : ""}`;
|
|
114
|
+
return `Skill audit ${counts}`;
|
|
115
|
+
}
|
|
116
|
+
function runTitle(run, collapsed) {
|
|
117
|
+
const time = hhmm(run.ts);
|
|
118
|
+
if (run.skill === NO_SKILL) {
|
|
119
|
+
return `\u26A0 ${time} no skill`;
|
|
120
|
+
}
|
|
121
|
+
const name = shortName(run.skill);
|
|
122
|
+
if (run.files.length === 0) {
|
|
123
|
+
return ` ${time} ${name}`;
|
|
124
|
+
}
|
|
125
|
+
return collapsed ? `\u25B6 ${time} ${name} (${run.files.length})` : `\u25BC ${time} ${name}`;
|
|
126
|
+
}
|
|
127
|
+
function displayPath(path, cwd, width) {
|
|
128
|
+
const rel = cwd && path.startsWith(`${cwd}/`) ? relative(cwd, path) : path;
|
|
129
|
+
if (rel.length <= width) {
|
|
130
|
+
return rel;
|
|
131
|
+
}
|
|
132
|
+
const base = basename(rel);
|
|
133
|
+
if (base.length <= width) {
|
|
134
|
+
return base;
|
|
135
|
+
}
|
|
136
|
+
return `${base.slice(0, Math.max(0, width - 1))}\u2026`;
|
|
137
|
+
}
|
|
138
|
+
var FILE_INDENT = " \u270E ";
|
|
139
|
+
function sidebarLines(view, options) {
|
|
140
|
+
const marker = options.sectionOpen ? "\u25BC" : "\u25B6";
|
|
141
|
+
const lines = [
|
|
142
|
+
{
|
|
143
|
+
text: `${marker} ${headerLine(view.summary)}`,
|
|
144
|
+
tone: "text"
|
|
145
|
+
}
|
|
146
|
+
];
|
|
147
|
+
if (!options.sectionOpen) {
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
if (view.runs.length === 0) {
|
|
151
|
+
lines.push({
|
|
152
|
+
text: " no events yet",
|
|
153
|
+
tone: "muted"
|
|
154
|
+
});
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
view.runs.forEach((run, index) => {
|
|
158
|
+
const collapsed = options.collapsed.has(index);
|
|
159
|
+
lines.push({
|
|
160
|
+
text: runTitle(run, collapsed),
|
|
161
|
+
tone: run.skill === NO_SKILL ? "warning" : "accent",
|
|
162
|
+
runIndex: index
|
|
163
|
+
});
|
|
164
|
+
if (collapsed) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
for (const file of run.files) {
|
|
168
|
+
lines.push({
|
|
169
|
+
text: `${FILE_INDENT}${displayPath(file.path, view.cwd, options.width - FILE_INDENT.length)}`,
|
|
170
|
+
tone: "muted"
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
return lines;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// src/watch.ts
|
|
178
|
+
import { statSync, watch } from "fs";
|
|
179
|
+
function mtime(path) {
|
|
180
|
+
try {
|
|
181
|
+
return statSync(path).mtimeMs;
|
|
182
|
+
} catch {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function watchSession(sessionID, onChange, options = {}) {
|
|
187
|
+
const debounceMs = options.debounceMs ?? 100;
|
|
188
|
+
const pollMs = options.pollMs ?? 2000;
|
|
189
|
+
const path = logPath(sessionID);
|
|
190
|
+
let last = mtime(path);
|
|
191
|
+
let timer;
|
|
192
|
+
let stopped = false;
|
|
193
|
+
const fire = () => {
|
|
194
|
+
if (stopped) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const current = mtime(path);
|
|
198
|
+
if (current === last) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
last = current;
|
|
202
|
+
onChange();
|
|
203
|
+
};
|
|
204
|
+
const schedule = () => {
|
|
205
|
+
if (stopped) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
timer = setTimeout(fire, debounceMs);
|
|
210
|
+
};
|
|
211
|
+
let watcher;
|
|
212
|
+
try {
|
|
213
|
+
watcher = watch(logDir(), schedule);
|
|
214
|
+
} catch {
|
|
215
|
+
watcher = undefined;
|
|
216
|
+
}
|
|
217
|
+
const poll = setInterval(fire, pollMs);
|
|
218
|
+
return () => {
|
|
219
|
+
stopped = true;
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
clearInterval(poll);
|
|
222
|
+
watcher?.close();
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/tui.ts
|
|
227
|
+
var ORDER = 800;
|
|
228
|
+
var DEFAULT_WIDTH = 30;
|
|
229
|
+
function element(tag, props, children = []) {
|
|
230
|
+
const node = createElement(tag);
|
|
231
|
+
for (const [key, value] of Object.entries(props)) {
|
|
232
|
+
if (value !== undefined) {
|
|
233
|
+
setProp(node, key, value);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const child of children) {
|
|
237
|
+
if (child !== null && child !== undefined && child !== false) {
|
|
238
|
+
insert(node, child);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return node;
|
|
242
|
+
}
|
|
243
|
+
function toneColor(theme, tone) {
|
|
244
|
+
if (tone === "accent") {
|
|
245
|
+
return theme.accent;
|
|
246
|
+
}
|
|
247
|
+
if (tone === "warning") {
|
|
248
|
+
return theme.warning;
|
|
249
|
+
}
|
|
250
|
+
if (tone === "muted") {
|
|
251
|
+
return theme.textMuted;
|
|
252
|
+
}
|
|
253
|
+
return theme.text;
|
|
254
|
+
}
|
|
255
|
+
function toggle(set, index) {
|
|
256
|
+
const next = new Set(set);
|
|
257
|
+
if (!next.delete(index)) {
|
|
258
|
+
next.add(index);
|
|
259
|
+
}
|
|
260
|
+
return next;
|
|
261
|
+
}
|
|
262
|
+
function Section(api, sessionID, width) {
|
|
263
|
+
const [view, setView] = createSignal(readSession(sessionID));
|
|
264
|
+
const [sectionOpen, setSectionOpen] = createSignal(true);
|
|
265
|
+
const [collapsed, setCollapsed] = createSignal(new Set);
|
|
266
|
+
const redraw = () => api.renderer.requestRender();
|
|
267
|
+
onCleanup(watchSession(sessionID, () => {
|
|
268
|
+
setView(readSession(sessionID));
|
|
269
|
+
redraw();
|
|
270
|
+
}));
|
|
271
|
+
const rows = () => sidebarLines(view(), {
|
|
272
|
+
sectionOpen: sectionOpen(),
|
|
273
|
+
collapsed: new Set(collapsed()),
|
|
274
|
+
width
|
|
275
|
+
}).map((line, index) => {
|
|
276
|
+
const onMouseDown = index === 0 ? () => {
|
|
277
|
+
setSectionOpen((open) => !open);
|
|
278
|
+
redraw();
|
|
279
|
+
} : line.runIndex !== undefined ? () => {
|
|
280
|
+
setCollapsed((current) => toggle(current, line.runIndex));
|
|
281
|
+
redraw();
|
|
282
|
+
} : undefined;
|
|
283
|
+
return element("text", {
|
|
284
|
+
fg: toneColor(api.theme.current, line.tone),
|
|
285
|
+
onMouseDown
|
|
286
|
+
}, [line.text]);
|
|
287
|
+
});
|
|
288
|
+
return element("box", {
|
|
289
|
+
width: "100%",
|
|
290
|
+
flexDirection: "column"
|
|
291
|
+
}, [rows]);
|
|
292
|
+
}
|
|
293
|
+
var tui = async (api, options) => {
|
|
294
|
+
const width = typeof options?.["width"] === "number" ? options["width"] : DEFAULT_WIDTH;
|
|
295
|
+
api.slots.register({
|
|
296
|
+
order: ORDER,
|
|
297
|
+
slots: { sidebar_content: (_ctx, props) => Section(api, props.session_id, width) }
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
var tui_default = {
|
|
301
|
+
id: "skill-audit",
|
|
302
|
+
tui
|
|
303
|
+
};
|
|
304
|
+
export {
|
|
305
|
+
tui_default as default,
|
|
306
|
+
tui
|
|
307
|
+
};
|
package/dist/view.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type SessionView, type SkillRun, type Summary } from "./log";
|
|
2
|
+
export declare function hhmm(ts: string): string;
|
|
3
|
+
/** `superpowers:brainstorming` -> `brainstorming`, the way the statusline snippet does. */
|
|
4
|
+
export declare function shortName(name: string): string;
|
|
5
|
+
export declare function headerLine(summary: Summary): string;
|
|
6
|
+
export declare function runTitle(run: SkillRun, collapsed: boolean): string;
|
|
7
|
+
/**
|
|
8
|
+
* Fit a path into the sidebar: relative to the session directory, then the
|
|
9
|
+
* basename, then a truncated basename.
|
|
10
|
+
*/
|
|
11
|
+
export declare function displayPath(path: string, cwd: string, width: number): string;
|
|
12
|
+
export type Tone = "text" | "muted" | "accent" | "warning";
|
|
13
|
+
export type Line = {
|
|
14
|
+
text: string;
|
|
15
|
+
tone: Tone;
|
|
16
|
+
runIndex?: number;
|
|
17
|
+
};
|
|
18
|
+
export type SidebarOptions = {
|
|
19
|
+
/** Whether the whole section is expanded. */
|
|
20
|
+
sectionOpen: boolean;
|
|
21
|
+
/** Indices of runs whose files are hidden. */
|
|
22
|
+
collapsed: Set<number>;
|
|
23
|
+
/** Usable sidebar width, in columns. */
|
|
24
|
+
width: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The entire sidebar section as plain lines. Keeping layout here rather than in
|
|
28
|
+
* the OpenTUI glue means it can be tested without a terminal.
|
|
29
|
+
*/
|
|
30
|
+
export declare function sidebarLines(view: SessionView, options: SidebarOptions): Line[];
|
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type WatchOptions = {
|
|
2
|
+
debounceMs?: number;
|
|
3
|
+
pollMs?: number;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Call `onChange` when a session's log changes.
|
|
7
|
+
*
|
|
8
|
+
* Watches the log directory rather than the file, because the file does not
|
|
9
|
+
* exist until the session's first event. The interval is a fallback: fs.watch
|
|
10
|
+
* misses changes on some network and container filesystems.
|
|
11
|
+
*/
|
|
12
|
+
export declare function watchSession(sessionID: string, onChange: () => void, options?: WatchOptions): () => void;
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-skill-audit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Audit trail of skill invocations and file changes, with a live timeline in the opencode sidebar",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Sven Depickere",
|
|
9
|
+
"url": "https://github.com/DepickereSven"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/DepickereSven/skill-audit",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/DepickereSven/skill-audit.git"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"opencode-plugin",
|
|
19
|
+
"audit",
|
|
20
|
+
"skills",
|
|
21
|
+
"sidebar",
|
|
22
|
+
"tui"
|
|
23
|
+
],
|
|
24
|
+
"oc-plugin": [
|
|
25
|
+
"tui"
|
|
26
|
+
],
|
|
27
|
+
"main": "dist/index.js",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts"
|
|
32
|
+
},
|
|
33
|
+
"./tui": {
|
|
34
|
+
"import": "./dist/tui.js",
|
|
35
|
+
"types": "./dist/tui.d.ts"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"clean": "rm -rf dist",
|
|
43
|
+
"format": "prettier --write src test package.json tsconfig.json tsconfig.test.json eslint.config.js .github/workflows/ci.yml",
|
|
44
|
+
"format:check": "prettier --check src test package.json tsconfig.json tsconfig.test.json eslint.config.js .github/workflows/ci.yml",
|
|
45
|
+
"lint": "eslint src test",
|
|
46
|
+
"build:types": "tsc --emitDeclarationOnly",
|
|
47
|
+
"build:index": "bun build src/index.ts --outfile dist/index.js --target bun --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk",
|
|
48
|
+
"build:tui": "bun build src/tui.ts --outfile dist/tui.js --target bun --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk --external @opentui/core --external @opentui/solid --external solid-js",
|
|
49
|
+
"build": "bun run clean && bun run build:types && bun run build:index && bun run build:tui",
|
|
50
|
+
"test": "bun test && ./test/format-contract.sh",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"typecheck:test": "tsc -p tsconfig.test.json",
|
|
53
|
+
"check": "bun run format:check && bun run lint && bun run typecheck && bun run typecheck:test && bun test && ./test/format-contract.sh",
|
|
54
|
+
"check:versions": "node scripts/sync-versions.mjs --check",
|
|
55
|
+
"sync:versions": "node scripts/sync-versions.mjs",
|
|
56
|
+
"version": "node scripts/sync-versions.mjs && git add .claude-plugin/plugin.json .codex-plugin/plugin.json",
|
|
57
|
+
"prepublishOnly": "bun run check:versions && bun run check && bun run build"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@opencode-ai/plugin": ">=1.14.0",
|
|
61
|
+
"@opentui/core": ">=0.4.5",
|
|
62
|
+
"@opentui/solid": ">=0.4.5",
|
|
63
|
+
"solid-js": ">=1.9.0"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@eslint/js": "^10.0.1",
|
|
67
|
+
"@opencode-ai/plugin": "1.18.23",
|
|
68
|
+
"@opentui/core": "0.4.5",
|
|
69
|
+
"@opentui/solid": "0.4.5",
|
|
70
|
+
"@types/bun": "latest",
|
|
71
|
+
"eslint": "^10.9.1",
|
|
72
|
+
"eslint-plugin-simple-import-sort": "^14.0.0",
|
|
73
|
+
"prettier": "^3.9.6",
|
|
74
|
+
"solid-js": "1.9.9",
|
|
75
|
+
"typescript": "^5.8.2",
|
|
76
|
+
"typescript-eslint": "^8.69.0"
|
|
77
|
+
},
|
|
78
|
+
"engines": {
|
|
79
|
+
"bun": ">=1.2.0"
|
|
80
|
+
}
|
|
81
|
+
}
|