mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3098342331 | ||
|
|
11ddc0c981 | ||
|
|
14d4f2e21a | ||
|
|
ec83e5bace | ||
|
|
7066cc8154 | ||
|
|
fcf7663366 | ||
|
|
e07431f127 | ||
|
|
52bbbcdd86 | ||
|
|
6566ec23d3 | ||
|
|
ed1fbb7f4c | ||
|
|
dcd51f16fe | ||
|
|
d833f4adab | ||
|
|
7693a046ee | ||
|
|
710173f965 | ||
|
|
0d95a81d35 | ||
|
|
e2c09c9a7e | ||
|
|
dc95e915c5 | ||
|
|
686c0cce64 | ||
|
|
e9cb8435d6 | ||
|
|
a8846964b5 | ||
|
|
6919dc2c4f | ||
|
|
e6cce23ed6 | ||
|
|
ec4f664910 | ||
|
|
d28be46e50 | ||
|
|
335ab56dea | ||
|
|
8a5ab60ef0 | ||
|
|
bceaa0cf6d | ||
|
|
19f7b5a0c0 | ||
|
|
5f079ab7d8 | ||
|
|
51e1990340 | ||
|
|
15465d30b5 | ||
|
|
daa8ec3732 | ||
|
|
e7ee55f936 | ||
|
|
bd36cdc953 | ||
|
|
452055463b | ||
|
|
a2cb2f95e8 | ||
|
|
c1e1ffdd47 | ||
|
|
14eea5d5ee | ||
|
|
cdb0d0c613 | ||
|
|
9663344b79 | ||
|
|
d900443b84 | ||
|
|
5b875b8e2e | ||
|
|
79a14ee79f | ||
|
|
c0e3c1e140 | ||
|
|
79285a4c00 | ||
|
|
7dc87d0a32 | ||
|
|
516f917619 | ||
|
|
ae02d2a560 | ||
|
|
2fb7c91183 | ||
|
|
704a460ef8 | ||
|
|
4dad1afe84 | ||
|
|
70ea40dced | ||
|
|
959b943ad4 | ||
|
|
6d71b9ec6f | ||
|
|
e0bb39f267 | ||
|
|
93d9a439b4 | ||
|
|
e8139f86e3 | ||
|
|
6ebdb375c4 | ||
|
|
62446eb525 | ||
|
|
62e77e66ff | ||
|
|
f60a45a98d | ||
|
|
c1ac0664c9 | ||
|
|
a325c64dd4 | ||
|
|
8753bf90d5 | ||
|
|
25d22f864a | ||
|
|
32f37af81a | ||
|
|
ddf212173a | ||
|
|
22e59bf067 | ||
|
|
efd490a5fc | ||
|
|
eb447dad2e | ||
|
|
baf10366f6 | ||
|
|
b5e8cdaf1f | ||
|
|
bdcba4c6ef | ||
|
|
22f75e3de6 | ||
|
|
f0dd780305 | ||
|
|
cd4009effa | ||
|
|
6ce47d4445 | ||
|
|
46de578a7e | ||
|
|
f68111acc3 | ||
|
|
f06348cbd3 | ||
|
|
e8eae0ff28 | ||
|
|
655b7d9c54 | ||
|
|
2422bbe59c | ||
|
|
18e45320a0 | ||
|
|
63a91ecadb | ||
|
|
e8b69797a8 | ||
|
|
279310971a | ||
|
|
21b15183ed |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "caveman-repo",
|
||||
"interface": {
|
||||
"displayName": "Caveman Repo"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "caveman",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/caveman"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
name: cavecrew
|
||||
description: >
|
||||
Decision guide for delegating to caveman-style subagents. Tells the main
|
||||
thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder`
|
||||
(1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the
|
||||
work inline or using vanilla `Explore`. Subagent output is caveman-compressed
|
||||
so the tool-result injected back into main context is ~60% smaller — main
|
||||
context lasts longer across long sessions.
|
||||
Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer",
|
||||
"save context", "compressed agent output".
|
||||
---
|
||||
|
||||
Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation.
|
||||
|
||||
## When to use cavecrew vs alternatives
|
||||
|
||||
| Task | Use |
|
||||
|---|---|
|
||||
| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` |
|
||||
| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) |
|
||||
| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` |
|
||||
| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` |
|
||||
| Review diff, branch, or file for bugs | `cavecrew-reviewer` |
|
||||
| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) |
|
||||
| One-line answer you already know | Main thread, no subagent |
|
||||
|
||||
Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.**
|
||||
|
||||
## Why this exists (the real win)
|
||||
|
||||
Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task.
|
||||
|
||||
## Output contracts
|
||||
|
||||
What main thread can rely on per agent:
|
||||
|
||||
**`cavecrew-investigator`**
|
||||
```
|
||||
<Header>:
|
||||
- path:line — `symbol` — short note
|
||||
totals: <counts>.
|
||||
```
|
||||
Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`.
|
||||
|
||||
**`cavecrew-builder`**
|
||||
```
|
||||
<path:line-range> — <change ≤10 words>.
|
||||
verified: <re-read OK | mismatch @ path:line>.
|
||||
```
|
||||
Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token).
|
||||
|
||||
**`cavecrew-reviewer`**
|
||||
```
|
||||
path:line: <emoji> <severity>: <problem>. <fix>.
|
||||
totals: N🔴 N🟡 N🔵 N❓
|
||||
```
|
||||
Or `No issues.` Findings sorted file → line ascending.
|
||||
|
||||
## Chaining patterns
|
||||
|
||||
**Locate → fix → verify** (most common):
|
||||
1. `cavecrew-investigator` returns site list.
|
||||
2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`.
|
||||
3. `cavecrew-reviewer` audits the diff.
|
||||
|
||||
**Parallel scout** (when investigation is broad):
|
||||
Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread.
|
||||
|
||||
**Single-shot edit** (when site is already known):
|
||||
Skip investigator. Hand exact path:line to `cavecrew-builder` directly.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context.
|
||||
- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn.
|
||||
- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that.
|
||||
- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase.
|
||||
|
||||
## Auto-clarity (inherited)
|
||||
|
||||
Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "caveman",
|
||||
"description": "Ultra-compressed communication mode for Claude Code. Cuts ~75% of tokens while keeping full technical accuracy.",
|
||||
"description": "Ultra-compressed communication mode for Claude Code. Cuts 65% of output tokens (measured) while keeping full technical accuracy.",
|
||||
"owner": {
|
||||
"name": "Julius Brussee",
|
||||
"url": "https://github.com/JuliusBrussee"
|
||||
@@ -9,7 +9,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "caveman",
|
||||
"description": "Talk like caveman. Cut ~75% tokens. Keep all technical accuracy.",
|
||||
"description": "Talk like caveman. Cut 65% output tokens (measured). Keep all technical accuracy.",
|
||||
"source": "./",
|
||||
"category": "productivity"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "caveman",
|
||||
"description": "Ultra-compressed communication mode. Cuts ~75% of tokens while keeping full technical accuracy by speaking like a caveman.",
|
||||
"description": "Ultra-compressed communication mode. Cuts 65% of output tokens (measured) while keeping full technical accuracy by speaking like a caveman.",
|
||||
"author": {
|
||||
"name": "Julius Brussee",
|
||||
"url": "https://github.com/JuliusBrussee"
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
[features]
|
||||
# Both keys: codex-cli renamed codex_hooks → hooks; old versions (<=0.120.0)
|
||||
# silently ignore unknown keys, so shipping both activates on either (#617).
|
||||
hooks = true
|
||||
codex_hooks = true
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
@@ -0,0 +1,46 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
node:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18, 20, 22]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Installer test suite
|
||||
run: npm test
|
||||
- name: Standalone hook/tool tests
|
||||
run: |
|
||||
for f in tests/test_*.js; do
|
||||
echo "== $f"
|
||||
node "$f"
|
||||
done
|
||||
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
# Several python tests shell out to node for hook checks — don't rely
|
||||
# on the runner image happening to ship it.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Python test suite
|
||||
run: python -m unittest discover -s tests -v
|
||||
+3
-1
@@ -3,7 +3,6 @@ __pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env.local
|
||||
caveman-compress.md
|
||||
**/.DS_Store
|
||||
.claude/worktrees/
|
||||
evals/snapshots/*.html
|
||||
@@ -13,3 +12,6 @@ context/refs/research-brief-caveman-code-efficiency.md
|
||||
# Build artifacts
|
||||
dist/*
|
||||
!dist/caveman.skill
|
||||
|
||||
# Local star-history chart scratch (not part of the product)
|
||||
tmp-starcharts/
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
name: cavecrew
|
||||
description: >
|
||||
Decision guide for delegating to caveman-style subagents. Tells the main
|
||||
thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder`
|
||||
(1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the
|
||||
work inline or using vanilla `Explore`. Subagent output is caveman-compressed
|
||||
so the tool-result injected back into main context is ~60% smaller — main
|
||||
context lasts longer across long sessions.
|
||||
Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer",
|
||||
"save context", "compressed agent output".
|
||||
---
|
||||
|
||||
Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation.
|
||||
|
||||
## When to use cavecrew vs alternatives
|
||||
|
||||
| Task | Use |
|
||||
|---|---|
|
||||
| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` |
|
||||
| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) |
|
||||
| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` |
|
||||
| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` |
|
||||
| Review diff, branch, or file for bugs | `cavecrew-reviewer` |
|
||||
| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) |
|
||||
| One-line answer you already know | Main thread, no subagent |
|
||||
|
||||
Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.**
|
||||
|
||||
## Why this exists (the real win)
|
||||
|
||||
Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task.
|
||||
|
||||
## Output contracts
|
||||
|
||||
What main thread can rely on per agent:
|
||||
|
||||
**`cavecrew-investigator`**
|
||||
```
|
||||
<Header>:
|
||||
- path:line — `symbol` — short note
|
||||
totals: <counts>.
|
||||
```
|
||||
Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`.
|
||||
|
||||
**`cavecrew-builder`**
|
||||
```
|
||||
<path:line-range> — <change ≤10 words>.
|
||||
verified: <re-read OK | mismatch @ path:line>.
|
||||
```
|
||||
Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token).
|
||||
|
||||
**`cavecrew-reviewer`**
|
||||
```
|
||||
path:line: <emoji> <severity>: <problem>. <fix>.
|
||||
totals: N🔴 N🟡 N🔵 N❓
|
||||
```
|
||||
Or `No issues.` Findings sorted file → line ascending.
|
||||
|
||||
## Chaining patterns
|
||||
|
||||
**Locate → fix → verify** (most common):
|
||||
1. `cavecrew-investigator` returns site list.
|
||||
2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`.
|
||||
3. `cavecrew-reviewer` audits the diff.
|
||||
|
||||
**Parallel scout** (when investigation is broad):
|
||||
Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread.
|
||||
|
||||
**Single-shot edit** (when site is already known):
|
||||
Skip investigator. Hand exact path:line to `cavecrew-builder` directly.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context.
|
||||
- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn.
|
||||
- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that.
|
||||
- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase.
|
||||
|
||||
## Auto-clarity (inherited)
|
||||
|
||||
Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after.
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
name: cavecrew
|
||||
description: >
|
||||
Decision guide for delegating to caveman-style subagents. Tells the main
|
||||
thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder`
|
||||
(1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the
|
||||
work inline or using vanilla `Explore`. Subagent output is caveman-compressed
|
||||
so the tool-result injected back into main context is ~60% smaller — main
|
||||
context lasts longer across long sessions.
|
||||
Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer",
|
||||
"save context", "compressed agent output".
|
||||
---
|
||||
|
||||
Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation.
|
||||
|
||||
## When to use cavecrew vs alternatives
|
||||
|
||||
| Task | Use |
|
||||
|---|---|
|
||||
| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` |
|
||||
| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) |
|
||||
| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` |
|
||||
| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` |
|
||||
| Review diff, branch, or file for bugs | `cavecrew-reviewer` |
|
||||
| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) |
|
||||
| One-line answer you already know | Main thread, no subagent |
|
||||
|
||||
Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.**
|
||||
|
||||
## Why this exists (the real win)
|
||||
|
||||
Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task.
|
||||
|
||||
## Output contracts
|
||||
|
||||
What main thread can rely on per agent:
|
||||
|
||||
**`cavecrew-investigator`**
|
||||
```
|
||||
<Header>:
|
||||
- path:line — `symbol` — short note
|
||||
totals: <counts>.
|
||||
```
|
||||
Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`.
|
||||
|
||||
**`cavecrew-builder`**
|
||||
```
|
||||
<path:line-range> — <change ≤10 words>.
|
||||
verified: <re-read OK | mismatch @ path:line>.
|
||||
```
|
||||
Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token).
|
||||
|
||||
**`cavecrew-reviewer`**
|
||||
```
|
||||
path:line: <emoji> <severity>: <problem>. <fix>.
|
||||
totals: N🔴 N🟡 N🔵 N❓
|
||||
```
|
||||
Or `No issues.` Findings sorted file → line ascending.
|
||||
|
||||
## Chaining patterns
|
||||
|
||||
**Locate → fix → verify** (most common):
|
||||
1. `cavecrew-investigator` returns site list.
|
||||
2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`.
|
||||
3. `cavecrew-reviewer` audits the diff.
|
||||
|
||||
**Parallel scout** (when investigation is broad):
|
||||
Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread.
|
||||
|
||||
**Single-shot edit** (when site is already known):
|
||||
Skip investigator. Hand exact path:line to `cavecrew-builder` directly.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context.
|
||||
- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn.
|
||||
- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that.
|
||||
- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase.
|
||||
|
||||
## Auto-clarity (inherited)
|
||||
|
||||
Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after.
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
name: cavecrew
|
||||
description: >
|
||||
Decision guide for delegating to caveman-style subagents. Tells the main
|
||||
thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder`
|
||||
(1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the
|
||||
work inline or using vanilla `Explore`. Subagent output is caveman-compressed
|
||||
so the tool-result injected back into main context is ~60% smaller — main
|
||||
context lasts longer across long sessions.
|
||||
Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer",
|
||||
"save context", "compressed agent output".
|
||||
---
|
||||
|
||||
Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation.
|
||||
|
||||
## When to use cavecrew vs alternatives
|
||||
|
||||
| Task | Use |
|
||||
|---|---|
|
||||
| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` |
|
||||
| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) |
|
||||
| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` |
|
||||
| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` |
|
||||
| Review diff, branch, or file for bugs | `cavecrew-reviewer` |
|
||||
| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) |
|
||||
| One-line answer you already know | Main thread, no subagent |
|
||||
|
||||
Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.**
|
||||
|
||||
## Why this exists (the real win)
|
||||
|
||||
Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task.
|
||||
|
||||
## Output contracts
|
||||
|
||||
What main thread can rely on per agent:
|
||||
|
||||
**`cavecrew-investigator`**
|
||||
```
|
||||
<Header>:
|
||||
- path:line — `symbol` — short note
|
||||
totals: <counts>.
|
||||
```
|
||||
Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`.
|
||||
|
||||
**`cavecrew-builder`**
|
||||
```
|
||||
<path:line-range> — <change ≤10 words>.
|
||||
verified: <re-read OK | mismatch @ path:line>.
|
||||
```
|
||||
Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token).
|
||||
|
||||
**`cavecrew-reviewer`**
|
||||
```
|
||||
path:line: <emoji> <severity>: <problem>. <fix>.
|
||||
totals: N🔴 N🟡 N🔵 N❓
|
||||
```
|
||||
Or `No issues.` Findings sorted file → line ascending.
|
||||
|
||||
## Chaining patterns
|
||||
|
||||
**Locate → fix → verify** (most common):
|
||||
1. `cavecrew-investigator` returns site list.
|
||||
2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`.
|
||||
3. `cavecrew-reviewer` audits the diff.
|
||||
|
||||
**Parallel scout** (when investigation is broad):
|
||||
Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread.
|
||||
|
||||
**Single-shot edit** (when site is already known):
|
||||
Skip investigator. Hand exact path:line to `cavecrew-builder` directly.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context.
|
||||
- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn.
|
||||
- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that.
|
||||
- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase.
|
||||
|
||||
## Auto-clarity (inherited)
|
||||
|
||||
Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after.
|
||||
@@ -19,7 +19,7 @@ README = product front door. Non-technical people read it to decide if caveman w
|
||||
|
||||
## Project overview
|
||||
|
||||
Caveman makes AI coding agents respond in compressed caveman-style prose — cuts ~65-75% output tokens, full technical accuracy. Ships as Claude Code plugin, Codex plugin, Gemini CLI extension, agent rule files for Cursor, Windsurf, Cline, Copilot, 40+ others via `npx skills`.
|
||||
Caveman makes AI coding agents respond in compressed caveman-style prose — cuts 65% output tokens (measured), full technical accuracy. Ships as Claude Code plugin, Codex plugin, Gemini CLI extension, agent rule files for Cursor, Windsurf, Cline, Copilot, 40+ others via `npx skills`.
|
||||
|
||||
---
|
||||
|
||||
@@ -35,9 +35,9 @@ caveman/
|
||||
├── CLAUDE.md # This file (maintainer instructions)
|
||||
├── AGENTS.md / GEMINI.md # Autodiscovery files (must stay at root)
|
||||
│
|
||||
├── install.sh / install.ps1 # 30-line shims → bin/install.js
|
||||
├── install.sh / install.ps1 # 30-line shims → cli/install.js
|
||||
│
|
||||
├── bin/ # Unified installer
|
||||
├── cli/ # Unified installer
|
||||
│ ├── install.js # Single source for all 30+ agents (PROVIDERS array)
|
||||
│ └── lib/settings.js # JSONC-tolerant settings.json reader/writer
|
||||
│
|
||||
@@ -84,8 +84,8 @@ caveman/
|
||||
|------|-----------------|
|
||||
| `skills/caveman/SKILL.md` | Caveman behavior: intensity levels, rules, wenyan mode, auto-clarity, persistence. Only file to edit for behavior changes. |
|
||||
| `src/rules/caveman-activate.md` | Always-on auto-activation rule body. Consumed by `src/tools/caveman-init.js` when a user runs `npx caveman --with-init` (per-repo IDE rule files). Edit here, not in any per-agent rule copy. |
|
||||
| `src/rules/caveman-openclaw-bootstrap.md` | Marker-fenced bootstrap snippet appended to `~/.openclaw/workspace/SOUL.md` by `bin/lib/openclaw.js`. Drives always-on caveman through the OpenClaw gateway. Must include the SENTINEL `Respond terse like smart caveman` and stay well under OpenClaw's 12K-per-bootstrap-file cap. |
|
||||
| `bin/lib/openclaw.js` | OpenClaw install/uninstall helper. Frontmatter merge (`version`, `always: true`), SOUL.md marker append/strip, idempotent. Shared by `bin/install.js` and `src/tools/caveman-init.js`. |
|
||||
| `src/rules/caveman-openclaw-bootstrap.md` | Marker-fenced bootstrap snippet appended to `~/.openclaw/workspace/SOUL.md` by `cli/lib/openclaw.js`. Drives always-on caveman through the OpenClaw gateway. Must include the SENTINEL `Respond terse like smart caveman` and stay well under OpenClaw's 12K-per-bootstrap-file cap. |
|
||||
| `cli/lib/openclaw.js` | OpenClaw install/uninstall helper. Frontmatter merge (`version`, `always: true`), SOUL.md marker append/strip, idempotent. Shared by `cli/install.js` and `src/tools/caveman-init.js`. |
|
||||
| `skills/caveman-commit/SKILL.md` | Caveman commit message behavior. Fully independent skill. |
|
||||
| `skills/caveman-review/SKILL.md` | Caveman code review behavior. Fully independent skill. |
|
||||
| `skills/caveman-help/SKILL.md` | Quick-reference card. One-shot display, not a persistent mode. |
|
||||
@@ -154,15 +154,16 @@ All hooks honor `CLAUDE_CONFIG_DIR` for non-default Claude Code config locations
|
||||
### `src/hooks/caveman-config.js` — shared module
|
||||
|
||||
Exports:
|
||||
- `getDefaultMode()` — resolves default mode from `CAVEMAN_DEFAULT_MODE` env var, then `$XDG_CONFIG_HOME/caveman/config.json` / `~/.config/caveman/config.json` / `%APPDATA%\caveman\config.json`, then `'full'`
|
||||
- `getDefaultMode()` — resolves default mode in order: `CAVEMAN_DEFAULT_MODE` env var → repo-local config (`<cwd>/.caveman/config.json` or `<cwd>/.caveman.json`, walking up to the filesystem root) → user config (`$XDG_CONFIG_HOME/caveman/config.json` / `~/.config/caveman/config.json` / `%APPDATA%\caveman\config.json`) → `'full'`. The env var short-circuits before any cwd walk. Repo-local config lets a team check in a per-project default without polluting every contributor's env or user config.
|
||||
- `findRepoConfigPath(start)` — walks up from `start` (default `process.cwd()`) looking for the first `.caveman/config.json` or `.caveman.json`. Bounded to 64 ancestors. Refuses symlinked files (symmetric with `safeWriteFlag` / `readFlag`).
|
||||
- `safeWriteFlag(flagPath, content)` — symlink-safe flag write. Refuses if flag target or its immediate parent is a symlink. Opens with `O_NOFOLLOW` where supported. Atomic temp + rename. Creates with `0600`. Protects against local attackers replacing the predictable flag path with a symlink to clobber files writable by the user. Used by both write hooks. Silent-fails on all filesystem errors.
|
||||
|
||||
### `src/hooks/caveman-activate.js` — SessionStart hook
|
||||
|
||||
Runs once per Claude Code session start. Three things:
|
||||
1. Writes the active mode to `$CLAUDE_CONFIG_DIR/.caveman-active` via `safeWriteFlag` (creates if missing)
|
||||
1. Writes the active mode to `$CLAUDE_CONFIG_DIR/.caveman-active` via `safeWriteFlag` (creates if missing). Branches on the hook payload's `source` field (#691): `startup` resets to the configured default; `resume`/`clear`/`compact` re-fires preserve a valid existing flag so mid-session `/caveman <level>` switches survive.
|
||||
2. Emits caveman ruleset as hidden stdout — Claude Code injects SessionStart hook stdout as system context, invisible to user
|
||||
3. Checks `settings.json` for statusline config; if missing, appends nudge to offer setup on first interaction
|
||||
3. Checks `settings.json` for statusline config; if missing, appends nudge to offer setup — once per install, gated by a `.caveman-nudge-shown` marker file (#661)
|
||||
|
||||
Silent-fails on all filesystem errors — never blocks session start.
|
||||
|
||||
@@ -199,11 +200,11 @@ Configured in `settings.json` under `statusLine.command`. PowerShell counterpart
|
||||
|
||||
**Plugin install** — hooks wired automatically by plugin system.
|
||||
|
||||
**Standalone install** — `bin/install.js` (the unified Node installer) copies hook files into `$CLAUDE_CONFIG_DIR/hooks/` and merges SessionStart + UserPromptSubmit + statusline into `settings.json`. Uses the JSONC-tolerant helpers in `bin/lib/settings.js` so a commented `settings.json` no longer crashes the merge. Defensive `validateHookFields` runs before every write to prevent a single malformed hook from poisoning the entire file (Claude Code Zod silently discards the whole `settings.json` on schema mismatch).
|
||||
**Standalone install** — `cli/install.js` (the unified Node installer) copies hook files into `$CLAUDE_CONFIG_DIR/hooks/` and merges SessionStart + UserPromptSubmit + statusline into `settings.json`. Uses the JSONC-tolerant helpers in `cli/lib/settings.js` so a commented `settings.json` no longer crashes the merge. Defensive `validateHookFields` runs before every write to prevent a single malformed hook from poisoning the entire file (Claude Code Zod silently discards the whole `settings.json` on schema mismatch).
|
||||
|
||||
The `install.sh` / `install.ps1` shims at the repo root delegate to `bin/install.js` via `node` (local clone) or `npx -y github:JuliusBrussee/caveman` (curl|bash). No legacy fallback path remains — earlier `install.sh.legacy` / `install.ps1.legacy` files were removed.
|
||||
The `install.sh` / `install.ps1` shims at the repo root delegate to `cli/install.js` via `node` (local clone) or `npx -y github:JuliusBrussee/caveman` (curl|bash). No legacy fallback path remains — earlier `install.sh.legacy` / `install.ps1.legacy` files were removed.
|
||||
|
||||
**Uninstall** — `npx -y github:JuliusBrussee/caveman -- --uninstall` (or `node bin/install.js --uninstall` from a clone). Strips caveman hook entries from `settings.json` via substring marker `caveman`, deletes hook files, and removes the Claude plugin / Gemini extension. Skill installs done via `npx skills add` must be removed via the IDE's skill manager (we don't track them).
|
||||
**Uninstall** — `npx -y github:JuliusBrussee/caveman -- --uninstall` (or `node cli/install.js --uninstall` from a clone). Strips caveman hook entries from `settings.json` via substring marker `caveman`, deletes hook files, and removes the Claude plugin / Gemini extension. Also removes state files from `$CLAUDE_CONFIG_DIR` (`.caveman-active`, `.caveman-active.prev`, `.caveman-mode-log.jsonl`, `.caveman-statusline-suffix`, `.caveman-nudge-shown`); keeps `.caveman-history.jsonl` (lifetime savings data) with a printed note (#635). Skill installs done via `npx skills add` must be removed via the IDE's skill manager (we don't track them).
|
||||
|
||||
---
|
||||
|
||||
@@ -243,7 +244,7 @@ How caveman reaches each agent type:
|
||||
| Codex | Plugin in `plugins/caveman/` plus repo `.codex/hooks.json` and `.codex/config.toml` | Yes on macOS/Linux — SessionStart hook |
|
||||
| Gemini CLI | Extension with `GEMINI.md` context file | Yes — context file loads every session |
|
||||
| opencode | Native plugin (`src/plugins/opencode/`) copied into `~/.config/opencode/plugins/caveman/` + `AGENTS.md` ruleset + skills/agents/commands directories. Plugin uses `session.created` and `tui.prompt.append` lifecycle hooks. No statusline (opencode TUI exposes no plugin-writable badge). | Yes — `session.created` writes flag, `AGENTS.md` carries always-on ruleset |
|
||||
| OpenClaw | Workspace skill at `~/.openclaw/workspace/skills/caveman/SKILL.md` (frontmatter merged with `version` + `always: true`) plus a marker-fenced bootstrap block in `~/.openclaw/workspace/SOUL.md`. Both writes go through `bin/lib/openclaw.js`; workspace path is overridable via `OPENCLAW_WORKSPACE`. | Yes — SOUL.md is auto-injected each turn under "Project Context" (subject to OpenClaw's 12K-per-file / 60K-total bootstrap caps) |
|
||||
| OpenClaw | Workspace skill at `~/.openclaw/workspace/skills/caveman/SKILL.md` (frontmatter merged with `version` + `always: true`) plus a marker-fenced bootstrap block in `~/.openclaw/workspace/SOUL.md`. Both writes go through `cli/lib/openclaw.js`; workspace path is overridable via `OPENCLAW_WORKSPACE`. | Yes — SOUL.md is auto-injected each turn under "Project Context" (subject to OpenClaw's 12K-per-file / 60K-total bootstrap caps) |
|
||||
| Cursor | `npx skills add ... -a cursor` (default via `--only cursor`) writes the upstream skill profile; per-repo `.cursor/rules/caveman.mdc` via `--with-init` (calls `src/tools/caveman-init.js`) | Yes — always-on rule |
|
||||
| Windsurf | `npx skills add ... -a windsurf` (default via `--only windsurf`); per-repo `.windsurf/rules/caveman.md` via `--with-init` | Yes — always-on rule |
|
||||
| Cline | `npx skills add ... -a cline` (default via `--only cline`); per-repo `.clinerules/caveman.md` via `--with-init` | Yes — Cline auto-discovers `.clinerules/` |
|
||||
@@ -254,10 +255,10 @@ opencode reaches Tier 1 minus the statusline (opencode's TUI has no plugin-writa
|
||||
|
||||
For agents without hook systems, the always-on snippet lives in `INSTALL.md`'s "Want it always on?" section — keep current with `src/rules/caveman-activate.md`.
|
||||
|
||||
**Adding a new agent.** Edit the `PROVIDERS` array in `bin/install.js` — single source of truth, no more bash/PS1 dual-source drift. Each entry has `id`, `label`, `mech`, `detect` (clause spec like `command:foo||dir:$HOME/x`), optional `profile` (vercel-labs/skills slug), optional `soft: true` (config-dir-only detection).
|
||||
**Adding a new agent.** Edit the `PROVIDERS` array in `cli/install.js` — single source of truth, no more bash/PS1 dual-source drift. Each entry has `id`, `label`, `mech`, `detect` (clause spec like `command:foo||dir:$HOME/x`), optional `profile` (vercel-labs/skills slug), optional `soft: true` (config-dir-only detection).
|
||||
|
||||
1. The profile slug must exist in upstream [vercel-labs/skills](https://github.com/vercel-labs/skills). Verify against the README before merging — wrong slugs cause `npx skills add` to fail at runtime, not at install-script load.
|
||||
2. Run `node bin/install.js --list` to confirm the new row renders correctly.
|
||||
2. Run `node cli/install.js --list` to confirm the new row renders correctly.
|
||||
3. Soft probes (config-dir-only) are fine but tag them with `soft: true`. They render with `(soft)` in `--list` so users know detection is best-effort.
|
||||
|
||||
---
|
||||
@@ -291,7 +292,7 @@ To reproduce: `uv run python benchmarks/run.py` (needs `ANTHROPIC_API_KEY` in `.
|
||||
|
||||
- Edit `skills/<name>/SKILL.md` for behavior changes. Never edit synced copies under `plugins/caveman/skills/`.
|
||||
- Edit `src/rules/caveman-activate.md` for auto-activation rule changes. Never edit any per-agent rule copy a user has on their machine.
|
||||
- Edit `src/rules/caveman-openclaw-bootstrap.md` for the OpenClaw SOUL.md bootstrap snippet. Keep the `<!-- caveman-begin -->` / `<!-- caveman-end -->` markers and the `Respond terse like smart caveman` sentinel — `bin/lib/openclaw.js` keys idempotency off both. If you change the embedded fallback in `bin/lib/openclaw.js`, keep it byte-equivalent to the file.
|
||||
- Edit `src/rules/caveman-openclaw-bootstrap.md` for the OpenClaw SOUL.md bootstrap snippet. Keep the `<!-- caveman-begin -->` / `<!-- caveman-end -->` markers and the `Respond terse like smart caveman` sentinel — `cli/lib/openclaw.js` keys idempotency off both. If you change the embedded fallback in `cli/lib/openclaw.js`, keep it byte-equivalent to the file.
|
||||
- Per-skill human docs live in `skills/<name>/README.md`. The LLM-facing body is in `SKILL.md`. Don't merge them — different audiences.
|
||||
- Build artifacts go in `dist/`. Never check files into `dist/` manually — CI rebuilds them on push, and `dist/` is gitignored.
|
||||
- README most important file for user-facing impact. Optimize for non-technical readers. Preserve caveman voice.
|
||||
@@ -300,6 +301,6 @@ To reproduce: `uv run python benchmarks/run.py` (needs `ANTHROPIC_API_KEY` in `.
|
||||
- CI workflow commits back to main after merge. Account for when checking branch state.
|
||||
- Hook files must silent-fail on all filesystem errors. Never let hook crash block session start.
|
||||
- Any new flag file write must go through `safeWriteFlag()` in `caveman-config.js`. Direct `fs.writeFileSync` on predictable user-owned paths reopens the symlink-clobber attack surface.
|
||||
- Hooks must respect `CLAUDE_CONFIG_DIR` env var, not hardcode `~/.claude`. Same for `bin/install.js` / statusline scripts.
|
||||
- `bin/install.js` is the only installer source. `install.sh` / `install.ps1` at repo root are 30-line shims that delegate to it. Never re-add per-OS install logic to the shims — that's how we got the Windows quoting bug (#249).
|
||||
- Any settings.json read in installer or hooks must go through `bin/lib/settings.js` `readSettings()` so JSONC comments don't crash the merge. Any settings.json write must run through `validateHookFields()` first.
|
||||
- Hooks must respect `CLAUDE_CONFIG_DIR` env var, not hardcode `~/.claude`. Same for `cli/install.js` / statusline scripts.
|
||||
- `cli/install.js` is the only installer source. `install.sh` / `install.ps1` at repo root are 30-line shims that delegate to it. Never re-add per-OS install logic to the shims — that's how we got the Windows quoting bug (#249).
|
||||
- Any settings.json read in installer or hooks must go through `cli/lib/settings.js` `readSettings()` so JSONC comments don't crash the merge. Any settings.json write must run through `validateHookFields()` first.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best for the overall community, not just for us as
|
||||
individuals
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
|
||||
## Reporting & Contact
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the repository owners. All complaints will be reviewed and investigated
|
||||
promptly and fairly.
|
||||
+11
-11
@@ -18,7 +18,7 @@ The repo distributes one skill (caveman) plus a handful of sub-skills
|
||||
(caveman-commit, caveman-review, caveman-compress, cavecrew-*) to many
|
||||
agents through different distribution mechanisms (Claude Code plugin, Codex
|
||||
plugin, Gemini extension, Cursor/Windsurf/Cline rule files, `npx skills` for
|
||||
the long tail). A single Node installer at `bin/install.js` detects which
|
||||
the long tail). A single Node installer at `cli/install.js` detects which
|
||||
agents are on the user's machine and installs the right thing for each.
|
||||
|
||||
Sources of truth live at the **top level** of the repo. Agent-specific
|
||||
@@ -39,10 +39,10 @@ copies live under `plugins/caveman/` and similar mirror dirs — those are
|
||||
| Cavecrew decision guide (when to delegate to subagents) | `skills/cavecrew/SKILL.md` |
|
||||
| cavecrew subagent definitions | `agents/cavecrew-investigator.md`, `agents/cavecrew-builder.md`, `agents/cavecrew-reviewer.md` |
|
||||
| Auto-activation rule body (Cursor/Windsurf/Cline/Copilot) | `src/rules/caveman-activate.md` |
|
||||
| Add support for a new agent | `bin/install.js` (PROVIDERS array) |
|
||||
| Add support for a new agent | `cli/install.js` (PROVIDERS array) |
|
||||
| Per-repo init script (drops rule files into a user's repo) | `src/tools/caveman-init.js` |
|
||||
| Claude Code hooks | `src/hooks/caveman-activate.js`, `src/hooks/caveman-mode-tracker.js`, `src/hooks/caveman-config.js`, `src/hooks/caveman-statusline.sh`, `src/hooks/caveman-statusline.ps1` |
|
||||
| Settings.json read/write helpers | `bin/lib/settings.js` |
|
||||
| Settings.json read/write helpers | `cli/lib/settings.js` |
|
||||
| MCP shrink server | `src/mcp-servers/caveman-shrink/` |
|
||||
|
||||
That's it. Every other markdown file with `SKILL.md` in the path is a copy.
|
||||
@@ -72,7 +72,7 @@ dotdir mirror, it's a build artifact. Edit the top-level source instead.
|
||||
|
||||
## Adding a new agent
|
||||
|
||||
The unified Node installer at `bin/install.js` is the **single source of
|
||||
The unified Node installer at `cli/install.js` is the **single source of
|
||||
truth** for the supported-agent list. The README and `INSTALL.md` install
|
||||
tables mirror it by hand — bash and PowerShell shims at the repo root just
|
||||
delegate to it.
|
||||
@@ -80,16 +80,16 @@ delegate to it.
|
||||
1. Confirm the agent has a distribution path. Either:
|
||||
- it has a profile slug in upstream [vercel-labs/skills](https://github.com/vercel-labs/skills) (most common), or
|
||||
- it has a native plugin / extension / rule-file mechanism we can target.
|
||||
2. Append a row to the `PROVIDERS` array in `bin/install.js`. Each row needs:
|
||||
2. Append a row to the `PROVIDERS` array in `cli/install.js`. Each row needs:
|
||||
- `id` — short kebab-case identifier (e.g. `windsurf`)
|
||||
- `label` — human display name (e.g. `Windsurf`)
|
||||
- `mech` — distribution mechanism (`plugin`, `extension`, `rules-file`, `skills-cli`, …)
|
||||
- `detect` — clause spec like `command:foo||dir:$HOME/x` describing how to detect the agent
|
||||
- `profile` — the vercel-labs/skills slug, if applicable
|
||||
- `soft: true` — set when detection is config-dir-only (best-effort)
|
||||
3. Run `node bin/install.js --list` and confirm the new row renders correctly. Soft probes should show as `(soft)`.
|
||||
3. Run `node cli/install.js --list` and confirm the new row renders correctly. Soft probes should show as `(soft)`.
|
||||
4. Add a row to the install tables in `README.md` and `INSTALL.md`.
|
||||
5. No CI changes needed — the workflow re-reads `bin/install.js` automatically.
|
||||
5. No CI changes needed — the workflow re-reads `cli/install.js` automatically.
|
||||
|
||||
Bad slug? `npx skills add` fails at install **runtime**, not at install-script
|
||||
load. Always verify the slug against the vercel-labs/skills README before
|
||||
@@ -174,18 +174,18 @@ PR descriptions don't need to be long. Caveman style fine. Just say what change,
|
||||
A handful of invariants that have bitten us before. Keep them.
|
||||
|
||||
- **Hooks must silent-fail on filesystem errors.** A `try/catch` that swallows the error is correct here. A hook that throws blocks Claude Code session start — that's user-facing breakage. See existing patterns in `src/hooks/caveman-activate.js`.
|
||||
- **Settings.json reads and writes go through `bin/lib/settings.js`.** It tolerates JSONC comments. Direct `JSON.parse` on a user's `settings.json` will crash on a single `// comment`.
|
||||
- **Validate hook entries before writing.** Use `validateHookFields()` in `bin/lib/settings.js`. Claude Code's Zod schema silently discards the **entire** `settings.json` on a single bad hook entry — one malformed write poisons the user's whole config.
|
||||
- **Settings.json reads and writes go through `cli/lib/settings.js`.** It tolerates JSONC comments. Direct `JSON.parse` on a user's `settings.json` will crash on a single `// comment`.
|
||||
- **Validate hook entries before writing.** Use `validateHookFields()` in `cli/lib/settings.js`. Claude Code's Zod schema silently discards the **entire** `settings.json` on a single bad hook entry — one malformed write poisons the user's whole config.
|
||||
- **Symlink-safe flag writes via `safeWriteFlag()`** in `src/hooks/caveman-config.js`. The flag file lives at a predictable path under `$CLAUDE_CONFIG_DIR/`; without `O_NOFOLLOW` and a parent-symlink check, a local attacker can clobber any file the user can write.
|
||||
- **Honor `CLAUDE_CONFIG_DIR`.** Hooks, the installer, and the statusline scripts must respect it — never hardcode `~/.claude`.
|
||||
- **`install.sh` and `install.ps1` at the repo root are 30-line shims** that delegate to `bin/install.js`. Don't re-add per-OS install logic to them. Quoting bugs that way lie.
|
||||
- **`install.sh` and `install.ps1` at the repo root are 30-line shims** that delegate to `cli/install.js`. Don't re-add per-OS install logic to them. Quoting bugs that way lie.
|
||||
|
||||
---
|
||||
|
||||
## Ideas
|
||||
|
||||
See [issues labeled `good first issue`](../../issues?q=label%3A%22good+first+issue%22)
|
||||
for starter tasks. Or grep `TODO` / `FIXME` in `src/hooks/`, `bin/`, `src/tools/` —
|
||||
for starter tasks. Or grep `TODO` / `FIXME` in `src/hooks/`, `cli/`, `src/tools/` —
|
||||
each one is a real lead.
|
||||
|
||||
Caveman like contribution. You bring rock, caveman put rock in pile. Pile
|
||||
|
||||
+31
-17
@@ -18,11 +18,13 @@ curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.
|
||||
irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex
|
||||
```
|
||||
|
||||
> Piping a script straight into a shell runs it sight-unseen. If you'd rather read it first, download then run: `curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh -o install.sh` (review it) `&& bash install.sh`. The installer downloads hook files from a pinned release tag and verifies them against a committed SHA-256 manifest before writing.
|
||||
|
||||
What it does:
|
||||
|
||||
- Auto-detects every supported agent installed on your machine (Claude Code, Cursor, Codex, etc.).
|
||||
- For each one, runs that agent's native install path (plugin / extension / rule file / `npx skills add`).
|
||||
- Wires Claude Code hooks, statusline badge, and the `caveman-shrink` MCP middleware on top.
|
||||
- Wires Claude Code hooks and statusline badge on top. (`caveman-shrink` MCP middleware is opt-in via `--with-mcp-shrink` — see flag table below.)
|
||||
- Skips anything you don't have. Safe to re-run. ~30 seconds end-to-end.
|
||||
|
||||
Want to preview before installing? Use `--dry-run`:
|
||||
@@ -38,9 +40,10 @@ If you want to install for one agent (or want to know exactly what command runs
|
||||
| Agent | Install command | Auto-activates? |
|
||||
|---|---|:-:|
|
||||
| **Claude Code** | `claude plugin marketplace add JuliusBrussee/caveman && claude plugin install caveman@caveman` | Yes |
|
||||
| **Gemini CLI** | `gemini extensions install https://github.com/JuliusBrussee/caveman` | Yes |
|
||||
| **opencode** | `node bin/install.js --only opencode` *(or `npx -y github:JuliusBrussee/caveman -- --only opencode`)* | Yes (plugin + AGENTS.md) |
|
||||
| **Gemini CLI** | `gemini extensions install https://github.com/JuliusBrussee/caveman --consent` | Yes |
|
||||
| **opencode** | `node cli/install.js --only opencode` *(or `npx -y github:JuliusBrussee/caveman -- --only opencode`)* | Yes (plugin + AGENTS.md) |
|
||||
| **OpenClaw** | `npx -y github:JuliusBrussee/caveman -- --only openclaw` | Yes (workspace skill + SOUL.md) |
|
||||
| **Hermes Agent** | `npx -y github:JuliusBrussee/caveman -- --only hermes` *(or `node cli/install.js --only hermes` from a clone)* | Yes (native skills, enabled on load) |
|
||||
| **Codex CLI** | `npx skills add JuliusBrussee/caveman -a codex` | Per-session: `/caveman` |
|
||||
| **Cursor** | `npx skills add JuliusBrussee/caveman -a cursor` | Per-session by default; `--with-init` for an always-on rule file |
|
||||
| **Windsurf** | `npx skills add JuliusBrussee/caveman -a windsurf` | Per-session by default; `--with-init` for an always-on rule file |
|
||||
@@ -76,7 +79,18 @@ If you want to install for one agent (or want to know exactly what command runs
|
||||
|
||||
For "auto-activates? No" agents, type `/caveman` once per session (or use natural-language triggers like "talk like caveman", "caveman mode").
|
||||
|
||||
Full agent matrix (with detection rules) is in `bin/install.js` under the `PROVIDERS` array.
|
||||
**Finding a profile slug for `npx skills add ... -a <profile>`?** Either read the table above, or print the live matrix from the installer:
|
||||
|
||||
```bash
|
||||
# Either of these works (install.sh / install.ps1 are thin shims that
|
||||
# forward all flags to cli/install.js):
|
||||
bash install.sh --list # macOS / Linux / WSL, from a local clone
|
||||
pwsh install.ps1 --list # Windows / PowerShell, from a local clone
|
||||
node cli/install.js --list # any platform, from a local clone
|
||||
npx -y github:JuliusBrussee/caveman -- --list # no clone needed
|
||||
```
|
||||
|
||||
Each row prints the agent id, profile slug (where applicable), and whether it was auto-detected on your machine. Full agent matrix (with detection rules) is also defined in `cli/install.js` under the `PROVIDERS` array.
|
||||
|
||||
## Manual install (no `curl | bash`)
|
||||
|
||||
@@ -88,26 +102,26 @@ git clone https://github.com/JuliusBrussee/caveman.git
|
||||
cd caveman
|
||||
|
||||
# Preview every command the installer would run
|
||||
node bin/install.js --dry-run --all
|
||||
node cli/install.js --dry-run --all
|
||||
|
||||
# Inspect the agent matrix
|
||||
node bin/install.js --list
|
||||
node cli/install.js --list
|
||||
|
||||
# Install for everything detected
|
||||
node bin/install.js --all
|
||||
node cli/install.js --all
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
| Flag | What |
|
||||
|---|---|
|
||||
| `--all` | Plugin + hooks + statusline + MCP shrink + per-repo rule files in `$PWD`. The full ride. |
|
||||
| `--all` | Plugin + hooks + statusline + per-repo rule files in `$PWD`. (MCP shrink is opt-in — see `--with-mcp-shrink` below.) |
|
||||
| `--minimal` | Plugin / extension only. No hooks, no MCP shrink, no per-repo rules. |
|
||||
| `--only <id>` | One agent only. Repeatable: `--only claude --only cursor`. |
|
||||
| `--dry-run` | Print every command. Write nothing. |
|
||||
| `--with-init` | Drop always-on rule files into the current repo (`.cursor/`, `.windsurf/`, `.clinerules/`, `.github/copilot-instructions.md`, `.opencode/AGENTS.md`, `AGENTS.md`) and, if OpenClaw is on the box, append the bootstrap block to `~/.openclaw/workspace/SOUL.md`. |
|
||||
| `--with-mcp-shrink` | Register `caveman-shrink` MCP proxy. **On by default.** |
|
||||
| `--no-mcp-shrink` | Skip MCP-shrink registration. |
|
||||
| `--with-mcp-shrink="<upstream cmd>"` | Register `caveman-shrink` MCP proxy wrapping the given upstream MCP server. **Off by default.** A value is required — caveman-shrink is a proxy and exits immediately without one. Example: `--with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /tmp"`. The value is split on whitespace; for paths-with-spaces, install via `node cli/install.js` from a clone or edit `~/.claude.json` after a stub install. |
|
||||
| `--no-mcp-shrink` | Skip MCP-shrink registration. (Default.) |
|
||||
| `--with-hooks` / `--no-hooks` | Force-on or force-off the Claude Code hook installer. (Default: on.) |
|
||||
| `--skip-skills` | Don't run the npx-skills auto-detect fallback when nothing else matched. |
|
||||
| `--config-dir <path>` | Claude Code config dir for hook files + `settings.json`. **Does NOT scope** `claude plugin install`, `gemini extensions install`, opencode (`XDG_CONFIG_HOME`), or openclaw (`OPENCLAW_WORKSPACE`) — those use their own paths. Default: `$CLAUDE_CONFIG_DIR` or `~/.claude`. `~` is expanded. |
|
||||
@@ -123,7 +137,7 @@ For agents without a hook system (Cursor, Windsurf, Cline, Copilot, and friends)
|
||||
|
||||
```bash
|
||||
# Drop rule files into the current repo
|
||||
node bin/install.js --with-init
|
||||
node cli/install.js --with-init
|
||||
|
||||
# Or pull the rule body straight in (manual)
|
||||
curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/rules/caveman-activate.md \
|
||||
@@ -139,7 +153,7 @@ After install, three quick checks:
|
||||
**1. See what got installed.**
|
||||
|
||||
```bash
|
||||
node bin/install.js --list
|
||||
node cli/install.js --list
|
||||
```
|
||||
|
||||
You should see ~30 rows. Detected agents are marked. Anything you wanted but isn't marked → not detected (likely the binary isn't on `PATH`).
|
||||
@@ -193,7 +207,7 @@ Still broken? [Open an issue](https://github.com/JuliusBrussee/caveman/issues).
|
||||
|
||||
**"I ran the installer but Claude Code isn't talking caveman."**
|
||||
|
||||
1. Run `node bin/install.js --list` — confirm `claude` is on the detected list. If not, `claude` isn't on `PATH`. Fix that first.
|
||||
1. Run `node cli/install.js --list` — confirm `claude` is on the detected list. If not, `claude` isn't on `PATH`. Fix that first.
|
||||
2. Open `$CLAUDE_CONFIG_DIR/settings.json` (default `~/.claude/settings.json`) and look for `"hooks"` containing `caveman-activate.js` and `caveman-mode-tracker.js`. If missing, re-run with `--force`.
|
||||
3. Check `$CLAUDE_CONFIG_DIR/.caveman-active` exists with content `full`. If not, the SessionStart hook silent-failed — check `$CLAUDE_CONFIG_DIR/hooks/` for the JS files and try `node $CLAUDE_CONFIG_DIR/hooks/caveman-activate.js < /dev/null` to see if it errors.
|
||||
4. Restart Claude Code. The SessionStart hook only fires on session start, not mid-session.
|
||||
@@ -207,7 +221,7 @@ Still broken? [Open an issue](https://github.com/JuliusBrussee/caveman/issues).
|
||||
|
||||
**"My `settings.json` got mangled."**
|
||||
|
||||
The installer uses a JSONC-tolerant parser (`bin/lib/settings.js`) so comments and trailing commas don't crash the merge. It also runs `validateHookFields()` before every write so a malformed hook can't poison the file. If something still went wrong:
|
||||
The installer uses a JSONC-tolerant parser (`cli/lib/settings.js`) so comments and trailing commas don't crash the merge. It also runs `validateHookFields()` before every write so a malformed hook can't poison the file. If something still went wrong:
|
||||
|
||||
1. Check for a backup at `$CLAUDE_CONFIG_DIR/settings.json.bak` (installer writes one before any merge).
|
||||
2. If no backup, restore from your shell history or version control.
|
||||
@@ -219,10 +233,10 @@ Use the rule-file-only path. Hooks are Claude Code-specific; everything else wor
|
||||
|
||||
```bash
|
||||
# Just install for one agent, no Claude hooks
|
||||
node bin/install.js --only cursor
|
||||
node cli/install.js --only cursor
|
||||
|
||||
# Or write rule files into the current repo only (no global state)
|
||||
node bin/install.js --with-init --only cursor --only windsurf
|
||||
node cli/install.js --with-init --only cursor --only windsurf
|
||||
```
|
||||
|
||||
This drops `.cursor/rules/caveman.mdc` (and friends) into your repo. No hooks, no global config, nothing outside the repo.
|
||||
@@ -240,7 +254,7 @@ The installer doesn't phone home. It writes to:
|
||||
- Your current working directory (only with `--with-init`) — repo-local rule files.
|
||||
- `~/.openclaw/workspace/` (only with `--only openclaw` or `--with-init` when OpenClaw is detected) — the one `--with-init` side-effect outside the cwd.
|
||||
|
||||
No telemetry. No analytics. The installer's own code makes no network calls. Network requests do happen indirectly through the per-agent CLIs it shells out to — `claude plugin marketplace add`, `claude plugin install`, `gemini extensions install`, `npm view caveman-shrink`, and `npx -y skills add`. Each fetches from its own registry (Anthropic / GitHub / npm). Source: [`bin/install.js`](bin/install.js).
|
||||
No telemetry. No analytics. Run from a clone or via npx, the installer's own code makes no network calls — files are copied locally. One exception: run detached from any checkout (the rare curl-fallback path), it downloads the hook files from raw.githubusercontent.com pinned to an immutable release tag and verifies each against a SHA-256 manifest before wiring anything. Network requests also happen indirectly through the per-agent CLIs it shells out to — `claude plugin marketplace add`, `claude plugin install`, `gemini extensions install`, `npm view caveman-shrink`, and `npx -y skills add`. Each fetches from its own registry (Anthropic / GitHub / npm). Source: [`cli/install.js`](cli/install.js). After install: zero network calls, ever — full statement in [SECURITY.md](./SECURITY.md#privacy--telemetry).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,119 +1,160 @@
|
||||
<p align="center">
|
||||
<img src="https://em-content.zobj.net/source/apple/391/rock_1faa8.png" width="120" />
|
||||
<img src="docs/assets/caveman-logo-banner.png" alt="Caveman" width="720">
|
||||
</p>
|
||||
|
||||
<h1 align="center">caveman</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>why use many token when few do trick</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/JuliusBrussee/caveman/stargazers"><img src="https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow" alt="Stars"></a>
|
||||
<a href="https://github.com/JuliusBrussee/caveman/commits/main"><img src="https://img.shields.io/github/last-commit/JuliusBrussee/caveman?style=flat" alt="Last Commit"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/JuliusBrussee/caveman?style=flat" alt="License"></a>
|
||||
Make your AI coding agent talk like a caveman.<br>
|
||||
Same answers. <strong>65% fewer output tokens</strong> on prose,<br>
|
||||
<strong>8.5%</strong> on <a href="#independently-measured-jetbrains-86-tasks">long-horizon agentic coding runs</a>. Brain still big. Mouth small.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#before--after">Before/After</a> •
|
||||
<a href="#install">Install</a> •
|
||||
<a href="#what-you-get">What You Get</a> •
|
||||
<a href="#benchmarks">Benchmarks</a> •
|
||||
<a href="./INSTALL.md">Full install guide</a>
|
||||
<a href="https://trendshift.io/repositories/25391?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-25391" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/25391" alt="JuliusBrussee%2Fcaveman | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/JuliusBrussee/caveman/stargazers"><img src="https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow" alt="Stars"></a>
|
||||
<a href="./INSTALL.md"><img src="https://img.shields.io/badge/works_with-30%2B_agents-orange?style=flat" alt="30+ agents"></a>
|
||||
<a href="https://github.com/JuliusBrussee/caveman/commits/main"><img src="https://img.shields.io/github/last-commit/JuliusBrussee/caveman?style=flat" alt="Last commit"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/JuliusBrussee/caveman?style=flat" alt="License"></a>
|
||||
<a href="https://skills.sh/JuliusBrussee/caveman"><img src="https://skills.sh/b/JuliusBrussee/caveman"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#before--after">See it</a> ·
|
||||
<a href="#install">Install</a> ·
|
||||
<a href="#pick-your-grunt">Levels</a> ·
|
||||
<a href="#what-you-get">What you get</a> ·
|
||||
<a href="#benchmarks">Benchmarks</a> ·
|
||||
<a href="#the-whole-cave">Ecosystem</a> ·
|
||||
<a href="#caveman-2">Caveman 2</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) skill/plugin (also Codex, Gemini, Cursor, Windsurf, Cline, Copilot, 30+ more) that makes agent talk like caveman — cuts **~75% of output tokens**, keeps full technical accuracy. Brain still big. Mouth small.
|
||||
Caveman is a skill/plugin for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Codex, Gemini, Cursor, Windsurf, Cline, Copilot, and 30+ other agents. Install once. Agent drops the filler and answers in tight caveman-speak, keeping code, commands, and errors byte-for-byte exact. You save output tokens on every reply, forever.
|
||||
|
||||
## Before / After
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<th width="50%">🗣️ Normal agent — 69 tokens</th>
|
||||
<th width="50%"><img src="docs/assets/dancing-rock.svg" width="18" height="18" alt=""> Caveman agent — 19 tokens</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
### 🗣️ Normal Claude (69 tokens)
|
||||
|
||||
> "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
|
||||
> The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object.
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<td valign="top">
|
||||
|
||||
### 🪨 Caveman Claude (19 tokens)
|
||||
|
||||
> "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<td valign="top">
|
||||
|
||||
### 🗣️ Normal Claude
|
||||
|
||||
> "Sure! I'd be happy to help you with that. The issue you're experiencing is most likely caused by your authentication middleware not properly validating the token expiry. Let me take a look and suggest a fix."
|
||||
> Sure! I'd be happy to help you with that. The issue you're experiencing is most likely caused by your authentication middleware not properly validating the token expiry. Let me take a look and suggest a fix.
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<td valign="top">
|
||||
|
||||
### 🪨 Caveman Claude
|
||||
|
||||
> "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Same fix. 75% less word. Brain still big.**
|
||||
Same fix. Third of the words. Nothing technical lost.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ TOKENS SAVED ████████ 75% │
|
||||
│ TECHNICAL ACCURACY ████████ 100%│
|
||||
│ SPEED INCREASE ████████ ~3x │
|
||||
│ VIBES ████████ OOG │
|
||||
└─────────────────────────────────────┘
|
||||
┌────────────────────────────────────────────┐
|
||||
│ output tokens saved █████████ 65% │
|
||||
│ input tokens saved ░░░░░░░░░ 0% │
|
||||
│ technical accuracy █████████ 100% │
|
||||
│ vibes █████████ OOG │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Pick your level of grunt — `lite` (drop filler), `full` (default caveman), `ultra` (telegraphic), or `wenyan` (classical Chinese, even shorter). One command switch. Cost go down forever.
|
||||
Caveman no make brain smaller. Caveman make *mouth* smaller. Shrinks what the agent **says**, not what it knows.
|
||||
|
||||
That 65% is the prose number, measured on replies like the ones above. On a full agentic coding run, where most of the output is code and tool calls, it's [8.5%](#independently-measured-jetbrains-86-tasks). Same skill, different workload — mechanism below.
|
||||
|
||||
## Install
|
||||
|
||||
One line. Find every agent. Install for each.
|
||||
**One command. Finds every agent on your machine. Installs for each.**
|
||||
|
||||
```bash
|
||||
# macOS / Linux / WSL / Git Bash
|
||||
# macOS · Linux · WSL · Git Bash
|
||||
curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash
|
||||
```
|
||||
|
||||
# Windows (PowerShell 5.1+)
|
||||
```powershell
|
||||
# Windows · PowerShell 5.1+
|
||||
irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex
|
||||
```
|
||||
|
||||
~30 seconds. Needs Node ≥18. Skip agent you no have. Safe to re-run.
|
||||
~30 seconds. Needs Node ≥18. Skips agents you no have. Safe to re-run.
|
||||
|
||||
**Trigger:** type `/caveman` or say "talk like caveman". Stop with "normal mode".
|
||||
Prefer one agent at a time? Each has its own path:
|
||||
|
||||
One agent only, manual command, or any of 30+ other agents → [**INSTALL.md**](./INSTALL.md).
|
||||
Install break? Open agent, say *"Read CLAUDE.md and INSTALL.md, install caveman for me."* Agent fix own brain.
|
||||
```bash
|
||||
# Claude Code plugin
|
||||
claude plugin marketplace add JuliusBrussee/caveman && claude plugin install caveman@caveman
|
||||
|
||||
## What You Get
|
||||
# Gemini CLI extension
|
||||
gemini extensions install https://github.com/JuliusBrussee/caveman --consent
|
||||
|
||||
| Skill | What |
|
||||
# Cursor / Windsurf / Cline / Codex / 30+ more, via the skills registry
|
||||
npx skills add JuliusBrussee/caveman -a cursor
|
||||
```
|
||||
|
||||
The full per-agent matrix, all flags, dry-run, and uninstall live in **[INSTALL.md](./INSTALL.md)**.
|
||||
|
||||
> [!TIP]
|
||||
> **Turn it on:** type `/caveman` or say *"talk like caveman"*. **Turn it off:** say *"normal mode"*. On Claude Code, Codex, and Gemini it's already on from message one. No command needed.
|
||||
|
||||
**Install broke?** Open your agent in this repo and say: *"Read CLAUDE.md and INSTALL.md, install caveman for me."* Agent read repo, agent fix own brain. Snake eat tail.
|
||||
|
||||
## Pick your grunt
|
||||
|
||||
Six levels. Switch anytime with `/caveman <level>`. Level sticks until you change it or the session ends.
|
||||
|
||||
| Level | Same sentence, shrunk |
|
||||
|---|---|
|
||||
| `/caveman [lite\|full\|ultra\|wenyan]` | Compress every reply. Levels stick until session end. |
|
||||
| `/caveman-commit` | Conventional Commit messages, ≤50 char subject. Why over what. |
|
||||
| *normal agent* | You should wrap the object in `useMemo`, since a new reference is created on every render. |
|
||||
| `lite` | Wrap object in `useMemo`. New ref created every render. |
|
||||
| `full` *(default)* | New ref each render. Wrap object in `useMemo`. |
|
||||
| `ultra` | New ref/render. `useMemo` it. |
|
||||
| `wenyan` | New ref every render, so wrap in `useMemo` — rendered in classical Chinese, shorter still. |
|
||||
|
||||
> [!NOTE]
|
||||
> **Speak your tongue.** Caveman keeps your language. Write Portuguese, caveman grunt Portuguese. Spanish, French, same. It compresses the *style*, never translates. `wenyan` mode is the exception on purpose: classical Chinese packs the most meaning per token.
|
||||
|
||||
## What you get
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `/caveman [lite\|full\|ultra\|wenyan]` | Compress every reply. Level sticks for the session. |
|
||||
| `/caveman-commit` | Conventional Commit messages, ≤50-char subject. Why over what. |
|
||||
| `/caveman-review` | One-line PR comments: `L42: 🔴 bug: user null. Add guard.` |
|
||||
| `/caveman-stats` | Real session token usage + lifetime savings + USD. Tweetable line via `--share`. |
|
||||
| `/caveman-compress <file>` | Rewrite memory file (e.g. `CLAUDE.md`) into caveman-speak. Cuts ~46% input tokens every session. Code/URLs/paths byte-preserved. |
|
||||
| `caveman-shrink` | MCP middleware. Wraps any MCP server, compresses tool descriptions. [npm](https://www.npmjs.com/package/caveman-shrink). |
|
||||
| `cavecrew-*` | Caveman subagents (investigator/builder/reviewer). ~60% fewer tokens than vanilla, main context lasts longer. |
|
||||
| `/caveman-stats` | Real session token usage, lifetime savings, USD. Tweetable line with `--share`. |
|
||||
| `/caveman-compress <file>` | Rewrite a memory file (like `CLAUDE.md`) into caveman-speak. Cuts ~46% input tokens **every session after**. Code, URLs, paths byte-preserved. |
|
||||
| `caveman-shrink` | MCP middleware. Wraps any MCP server, compresses its tool descriptions. [npm](https://www.npmjs.com/package/caveman-shrink). |
|
||||
| `cavecrew-*` | Caveman subagents (investigator, builder, reviewer). ~60% fewer tokens than vanilla, so main context lasts longer. |
|
||||
|
||||
**Statusline badge** — Claude Code shows `[CAVEMAN] ⛏ 12.4k` (lifetime tokens saved). Updates every `/caveman-stats` run. Set `CAVEMAN_STATUSLINE_SAVINGS=0` to silence.
|
||||
|
||||
Auto-activate every session: Claude Code, Codex, Gemini (built-in). Cursor / Windsurf / Cline / Copilot get always-on rule files via `--with-init`. Other agents trigger with `/caveman` per session. Full feature matrix in [INSTALL.md](./INSTALL.md#what-you-get).
|
||||
> [!TIP]
|
||||
> On Claude Code the statusline shows `[CAVEMAN] ⛏ 12.4k` — that's your lifetime tokens saved, updated on every `/caveman-stats`. Silence it with `CAVEMAN_STATUSLINE_SAVINGS=0`.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Real token counts from the Claude API. Average **65% output reduction** across 10 prompts (range 22-87%).
|
||||
Real token counts from the Claude API. Average **65% output reduction** across 10 **chat-style prompts** (range 22–87%), measured against default verbose replies. Output tokens only, committed and reproducible in [`benchmarks/`](./benchmarks/) and [`evals/`](./evals/). This is one-question-one-answer, not a full agentic coding run — for that number, see [JetBrains](#independently-measured-jetbrains-86-tasks) below.
|
||||
|
||||
<!-- BENCHMARK-TABLE-START -->
|
||||
| Task | Normal | Caveman | Saved |
|
||||
@@ -131,9 +172,38 @@ Real token counts from the Claude API. Average **65% output reduction** across 1
|
||||
| **Average** | **1214** | **294** | **65%** |
|
||||
<!-- BENCHMARK-TABLE-END -->
|
||||
|
||||
Raw data and reproduction script: [`benchmarks/`](./benchmarks/). Three-arm eval harness (baseline / terse / skill) lives in [`evals/`](./evals/) — caveman compared against `Answer concisely.` not against verbose default, so the delta is honest.
|
||||
> [!IMPORTANT]
|
||||
> **Honest number warning.** Caveman only shrinks **output** tokens. Input and reasoning tokens are untouched, and the skill itself adds ~1–1.5k input tokens per turn. So whole-session savings run smaller than the output number, and on already-terse workloads they can go net-negative. The real win is **readability and speed**. Cost savings are the bonus. When caveman wins, when it loses, and how to measure it yourself: **[docs/HONEST-NUMBERS.md](./docs/HONEST-NUMBERS.md)**.
|
||||
|
||||
**caveman-compress receipts** (real memory files):
|
||||
### Independently measured: JetBrains, 86 tasks
|
||||
|
||||
JetBrains ran the skill against [86 tasks from SkillsBench](https://blog.jetbrains.com/ai/2026/07/speak-to-ai-agents-like-cavemen-tosave-tokens/) in July 2026 — real coding work, auto-graded by each task's own tests, Claude Code on `claude-sonnet-5`, skill forced on for every reply.
|
||||
|
||||
| Workload | Output tokens saved | Measured by |
|
||||
|---|---:|---|
|
||||
| Chat-style prose | **65%** | us, table above |
|
||||
| Agentic coding run | **8.5%** | JetBrains, 86 tasks |
|
||||
|
||||
Both numbers are real. They measure different workloads, and the gap is mechanical: caveman compresses narration and leaves code, diffs, tool calls, and error strings byte-exact. In a chat answer, narration is the whole reply. In an agentic run it's the thin layer between tool calls, so that's all there is to squeeze. An output-only skill has a low ceiling on work that is mostly not prose.
|
||||
|
||||
Pick the number that matches your workload:
|
||||
|
||||
- **Agent writes you prose** — explanations, review, docs, debugging walkthroughs → 65% territory.
|
||||
- **Agent works a repo unattended** → single digits. Not zero, not 65%.
|
||||
|
||||
Quality was unaffected: across 86 auto-graded tasks the two arms were statistically indistinguishable. Small mouth, same brain — checked by someone who didn't ship it.
|
||||
|
||||
Two things follow:
|
||||
|
||||
- **Agentic bills are mostly input tokens**, which an output-only skill cannot touch by construction. `/caveman-compress` and `caveman-shrink` chip at that side; the skill alone never will.
|
||||
- **The right number is your number.** JetBrains had to run a full paid benchmark to find out what caveman does on their stack. That's the job [Caveman 2](#caveman-2) exists to do — for yours, continuously.
|
||||
|
||||
Turns out short isn't just cheaper. A March 2026 paper, [*Brevity Constraints Reverse Performance Hierarchies in Language Models*](https://arxiv.org/abs/2604.00025), tested 31 models and found that constraining large models to brief answers **improved accuracy by ~26 points** on some benchmarks. Sometimes less word = more correct.
|
||||
|
||||
<details>
|
||||
<summary><strong>caveman-compress receipts</strong> — real memory files, cutting input tokens forever</summary>
|
||||
|
||||
<br>
|
||||
|
||||
| File | Original | Compressed | Saved |
|
||||
|---|---:|---:|---:|
|
||||
@@ -144,81 +214,139 @@ Raw data and reproduction script: [`benchmarks/`](./benchmarks/). Three-arm eval
|
||||
| `mixed-with-code.md` | 888 | 560 | **36.9%** |
|
||||
| **Average** | **898** | **481** | **46%** |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Caveman only affects output tokens — thinking/reasoning tokens untouched. Caveman no make brain smaller. Caveman make *mouth* smaller. Biggest win is **readability and speed**, cost savings a bonus.
|
||||
Every session after, that file loads ~46% smaller. Input tokens saved forever, not just one reply.
|
||||
|
||||
A March 2026 paper ["Brevity Constraints Reverse Performance Hierarchies in Language Models"](https://arxiv.org/abs/2604.00025) found that constraining large models to brief responses **improved accuracy by 26 points** on certain benchmarks. Verbose not always better. Sometimes less word = more correct.
|
||||
</details>
|
||||
|
||||
## How It Work
|
||||
## The whole cave
|
||||
|
||||
1. Install drop skill file in agent.
|
||||
2. Skill tell agent: drop filler, keep substance, use fragments.
|
||||
3. For Claude Code, hook also write tiny flag file each session — agent see flag, talk caveman from message one. No need say `/caveman`.
|
||||
4. Stats command read Claude Code session log, count tokens saved, write number to statusline.
|
||||
5. Caveman-compress sub-skill rewrite memory files (CLAUDE.md, project notes) so each session start with smaller context. Save tokens forever, not just one reply.
|
||||
<table>
|
||||
<tr><td>
|
||||
|
||||
Maintainer detail (hook architecture, file ownership, CI sync) live in [CLAUDE.md](./CLAUDE.md).
|
||||
### <img src="docs/assets/dancing-rock.svg" width="20" height="20" alt=""> Want the whole agent, not just its mouth? → caveman-code
|
||||
|
||||
## Lobster, Meet Rock 🦞🪨
|
||||
|
||||
[**OpenClaw**](https://openclaw.ai) the self-host gateway. One box, many agent inside (Claude Code, Codex, Pi, OpenCode), wired to your Slack / Discord / iMessage / Telegram / whatever. Tagline: *"The lobster way."* Lobster strong. Lobster smart. Lobster also talk a lot.
|
||||
|
||||
Caveman teach lobster brevity — same canonical installer, scoped to one agent:
|
||||
This skill shrinks what an agent **says**. **[caveman-code](https://github.com/JuliusBrussee/caveman-code)** shrinks **everything** — a full terminal coding agent, caveman top to bottom. **~2× fewer tokens than Codex** on identical tasks. 20+ providers, plan mode, autopilot goal loop, MIT.
|
||||
|
||||
```bash
|
||||
# macOS / Linux / WSL
|
||||
curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash -s -- --only openclaw
|
||||
|
||||
# Windows (PowerShell): no Node? install Node ≥18 first, then
|
||||
npx -y github:JuliusBrussee/caveman -- --only openclaw
|
||||
npm install -g @juliusbrussee/caveman-code
|
||||
```
|
||||
|
||||
Two thing happen, no more:
|
||||
[**▶ Try caveman-code →**](https://github.com/JuliusBrussee/caveman-code)
|
||||
|
||||
1. **Skill drop** at `~/.openclaw/workspace/skills/caveman/SKILL.md` — spec-correct frontmatter (`version`, `always: true`), discoverable by `openclaw skills list`. Skill not auto-inject (OpenClaw load skill on demand) — that why we also do step 2.
|
||||
2. **SOUL.md nudge.** Tiny marker-fenced block appended to `~/.openclaw/workspace/SOUL.md`. OpenClaw inject SOUL.md into *every* turn under "Project Context" (12K-per-file, 60K total — block well under). Lobster terse from message one. No `/caveman` per session. No nag.
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
```
|
||||
~/.openclaw/workspace/
|
||||
├── skills/caveman/SKILL.md ← full ruleset, on-demand load
|
||||
└── SOUL.md ← <!-- caveman-begin --> ... <!-- caveman-end -->
|
||||
↑ auto-inject every turn
|
||||
```
|
||||
Five tools, one idea: **agent do more with less.**
|
||||
|
||||
Custom workspace path? `OPENCLAW_WORKSPACE=/your/path` before the command. Uninstall: same one-liner with `--uninstall` — skill folder gone, SOUL.md block ripped out cleanly, your other workspace content stay untouched. Idempotent re-runs (frontmatter not double-prepended, marker block not duplicated).
|
||||
|
||||
Lobster claw still sharp. Lobster mouth now small. Brain still big.
|
||||
|
||||
## Caveman Ecosystem
|
||||
|
||||
Three tools. One philosophy: **agent do more with less**.
|
||||
|
||||
| Repo | What |
|
||||
| Repo | What it shrinks |
|
||||
|------|------|
|
||||
| [**caveman**](https://github.com/JuliusBrussee/caveman) *(you here)* | Output compression — *why use many token when few do trick* |
|
||||
| [**cavemem**](https://github.com/JuliusBrussee/cavemem) | Cross-agent memory — *why agent forget when agent can remember* |
|
||||
| [**cavekit**](https://github.com/JuliusBrussee/cavekit) | Spec-driven build loop — *why agent guess when agent can know* |
|
||||
| [**caveman**](https://github.com/JuliusBrussee/caveman) *(you here)* | What the agent **says** |
|
||||
| [**caveman-code**](https://github.com/JuliusBrussee/caveman-code) | The **whole agent**, end to end |
|
||||
| [**cavemem**](https://github.com/JuliusBrussee/cavemem) | What the agent **remembers**, across sessions |
|
||||
| [**cavekit**](https://github.com/JuliusBrussee/cavekit) | The **build loop** — spec-driven, no guessing |
|
||||
| [**cavegemma**](https://github.com/JuliusBrussee/finetune-caveman) | The compression **baked into weights** (Gemma fine-tune) |
|
||||
|
||||
Compose: cavekit drive build, caveman compress what agent *say*, cavemem compress what agent *remember*. One rock. Two rock. Three rock. That it.
|
||||
<details>
|
||||
<summary><strong>Also: five sibling skills, one install</strong></summary>
|
||||
|
||||
## Links
|
||||
<br>
|
||||
|
||||
- [INSTALL.md](./INSTALL.md) — full install matrix, all flags, per-agent detail
|
||||
- [CONTRIBUTING.md](./CONTRIBUTING.md) — how to send patch
|
||||
- [CLAUDE.md](./CLAUDE.md) — maintainer guide (file ownership, hook architecture, CI)
|
||||
- [docs/](./docs/) — extra guides (Windows install, etc.)
|
||||
- [Issues](https://github.com/JuliusBrussee/caveman/issues) — bug, feature, weird behavior
|
||||
[**JuliusBrussee/skills**](https://github.com/JuliusBrussee/skills) — works in Claude Code, Cursor, Gemini, Cline, Copilot, 40+ agents:
|
||||
|
||||
## Star This Repo
|
||||
| Skill | What |
|
||||
|------|------|
|
||||
| [**caveman**](https://github.com/JuliusBrussee/skills/tree/main/skills/caveman) | This one. Speak less, say more. |
|
||||
| [**grill-me**](https://github.com/JuliusBrussee/skills/tree/main/skills/grill-me) | Agent grills your plan *before* you build the wrong thing. |
|
||||
| [**interface-kit**](https://github.com/JuliusBrussee/skills/tree/main/skills/interface-kit) | Build UI that looks good, loads fast, works for everyone. |
|
||||
| [**junior-to-senior**](https://github.com/JuliusBrussee/skills/tree/main/skills/junior-to-senior) | Adversarial review pass. Junior output in, senior output out. |
|
||||
| [**loop-factory**](https://github.com/JuliusBrussee/skills/tree/main/skills/loop-factory) | Spec-driven task loop — inbox → active → archive. |
|
||||
|
||||
```bash
|
||||
npx skills@latest add JuliusBrussee/skills
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🦞 Teach the lobster brevity — OpenClaw integration</strong></summary>
|
||||
|
||||
<br>
|
||||
|
||||
[**OpenClaw**](https://openclaw.ai) is a self-host gateway: one box, many agents inside, wired to Slack / Discord / iMessage / Telegram. Lobster strong. Lobster smart. Lobster also talk a lot.
|
||||
|
||||
Same installer, scoped to one agent:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash -s -- --only openclaw
|
||||
```
|
||||
|
||||
Two things happen, no more: a caveman skill lands in the workspace, and a tiny marker-fenced block is appended to `SOUL.md` (OpenClaw injects it every turn, so the lobster is terse from message one — no `/caveman` per session). Custom path? `OPENCLAW_WORKSPACE=/your/path`. Uninstall with the same line plus `--uninstall`; your other workspace content stays untouched. Lobster claw still sharp. Lobster mouth now small.
|
||||
|
||||
</details>
|
||||
|
||||
## Caveman 2
|
||||
|
||||
**Caveman make token small. Caveman 2 make it _provable_.**
|
||||
|
||||
Today's savings numbers (including `/caveman-stats`) are local estimates. Caveman 2 measures and verifies them across a whole team — real receipts, real dashboard, real proof the tokens went down. Building it now.
|
||||
|
||||
[The JetBrains result](#independently-measured-jetbrains-86-tasks) is the argument for it. 65% and 8.5% are both correct, and neither one is *your* number — one harness, one model, one task set, and your stack is none of those. The fix is not a better README claim, ours or anyone's. It's a baseline on your own traffic and a receipt at the end of the month.
|
||||
|
||||
[**Join the waitlist → caveman.so**](https://caveman.so)
|
||||
|
||||
## How it works
|
||||
|
||||
1. Install drops a skill file into your agent.
|
||||
2. Skill tells agent: drop filler, keep substance, use fragments — but never touch code, commands, or errors.
|
||||
3. On Claude Code, a hook writes a tiny flag file each session, so the agent talks caveman from message one without `/caveman`.
|
||||
4. `/caveman-stats` reads your session log, counts tokens saved, writes the number to your statusline.
|
||||
5. `/caveman-compress` rewrites memory files (like `CLAUDE.md`) so every future session starts with a smaller context. Save tokens forever, not just once.
|
||||
|
||||
Hook architecture, file ownership, and CI sync are documented for maintainers in [CLAUDE.md](./CLAUDE.md).
|
||||
|
||||
## Privacy
|
||||
|
||||
Caveman no phone home. No telemetry, no analytics, no accounts, no backend. After install, zero network calls — the skill is a prompt, the hooks are local scripts, and `/caveman-stats` reads a log already on your disk. Install-time fetches (GitHub plus your agents' own registries) are spelled out in [SECURITY.md](./SECURITY.md#privacy--telemetry).
|
||||
|
||||
## Sponsors
|
||||
|
||||
Caveman free forever. Sponsors keep the rock sharp.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.atlascloud.ai">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/atlas-cloud-dark.svg">
|
||||
<img src="docs/assets/atlas-cloud.svg" alt="Atlas Cloud" height="32">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.atlascloud.ai"><strong>Atlas Cloud</strong></a> — full-modal AI inference platform, one API.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sponsors/JuliusBrussee"><strong>Want your rock here? → Sponsor caveman</strong></a>
|
||||
</p>
|
||||
|
||||
## Star this repo
|
||||
|
||||
Caveman save you token, save you money. Star cost zero. Fair trade. ⭐
|
||||
|
||||
[](https://star-history.com/#JuliusBrussee/caveman&Date)
|
||||
[](https://star-history.com/#JuliusBrussee/caveman&Date)
|
||||
|
||||
## Also by Julius Brussee
|
||||
|
||||
- **[Revu](https://github.com/JuliusBrussee/revu-swift)** — local-first macOS study app with FSRS spaced repetition. [revu.cards](https://revu.cards)
|
||||
|
||||
## License
|
||||
---
|
||||
|
||||
<sub>
|
||||
<strong>Docs:</strong>
|
||||
<a href="./INSTALL.md">Install matrix</a> ·
|
||||
<a href="./docs/HONEST-NUMBERS.md">Honest numbers</a> ·
|
||||
<a href="./CONTRIBUTING.md">Contributing</a> ·
|
||||
<a href="./CLAUDE.md">Maintainer guide</a> ·
|
||||
<a href="https://github.com/JuliusBrussee/caveman/issues">Issues</a>
|
||||
<br>
|
||||
<strong>Also by Julius Brussee:</strong>
|
||||
<a href="https://github.com/JuliusBrussee/revu-swift">Revu</a> — local-first macOS study app with FSRS spaced repetition (<a href="https://revu.cards">revu.cards</a>)
|
||||
<br><br>
|
||||
MIT — free like mass mammoth on open plain.
|
||||
</sub>
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest stable release builds are supported with security patches.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you identify a security vulnerability in caveman (such as arbitrary shell execution, workspace folder escapes, token/credentials hijack via prompts, or malicious JSON parsing flaws in extension settings), please do **not** open a public issue.
|
||||
|
||||
Please report vulnerabilities privately by emailing the maintainers or using [GitHub's private vulnerability reporting](https://github.com/JuliusBrussee/caveman/security/advisories/new).
|
||||
|
||||
## Privacy & Telemetry
|
||||
|
||||
**Caveman has no telemetry. Zero.** No analytics, no crash reporting, no phone-home, no accounts, no API keys collected. There is no caveman backend — nothing to send data to.
|
||||
|
||||
### After install: zero network calls
|
||||
|
||||
Once installed, nothing in caveman touches the network. Verified against the code (audit it yourself — every file is in this repo):
|
||||
|
||||
- **The skill itself** (`skills/caveman/SKILL.md`) is a markdown prompt. It contains no code.
|
||||
- **The hooks** (`src/hooks/*.js`, statusline scripts) are local Node/shell scripts. They read and write local files only (flag file, session log, statusline savings file). No `http`/`https`/`fetch` anywhere in them.
|
||||
- **`/caveman-stats`** reads Claude Code's session JSONL from your local disk and prints counts. USD figures come from pricing constants hardcoded in the script. Nothing leaves your machine.
|
||||
- **`caveman-shrink`** (MCP middleware) spawns the MCP server *you* configure, locally, and compresses its output in-process. It makes no network calls of its own; any network activity belongs to the server you wrapped.
|
||||
- **`/caveman-compress`** rewrites a local file you name and saves a `.original.md` backup next to it. Local file I/O only.
|
||||
|
||||
### At install time: exactly these network requests, nothing else
|
||||
|
||||
- `curl … install.sh | bash` (or `irm … install.ps1 | iex`) fetches the shim from raw.githubusercontent.com, which delegates to `npx -y github:JuliusBrussee/caveman` — npm fetches this repo from GitHub.
|
||||
- The installer shells out to per-agent CLIs which fetch from their own registries: `claude plugin marketplace add` / `claude plugin install` (Anthropic/GitHub), `gemini extensions install`, `npm view caveman-shrink`, `npx -y skills add` (npm).
|
||||
- **Rare fallback:** if the installer runs detached from a repo checkout, it downloads the hook files from raw.githubusercontent.com **pinned to an immutable release tag** and verifies each against a published SHA-256 manifest before wiring anything (a mismatch aborts). From a normal clone or npx run, files are copied locally — offline installs work.
|
||||
|
||||
Nothing is uploaded in any of these steps. Details and the full list of paths written: [INSTALL.md → Privacy](./INSTALL.md#privacy).
|
||||
|
||||
### What stays on your machine
|
||||
|
||||
Everything. Skill/rule files in your agents' config dirs, the mode flag file and merged `settings.json` under `~/.claude/` (or `$CLAUDE_CONFIG_DIR`), the lifetime-savings statusline file, and `.original.md` backups from `/caveman-compress`. Uninstall removes what the installer wrote: `npx -y github:JuliusBrussee/caveman -- --uninstall`.
|
||||
|
||||
### Enterprise / air-gapped use
|
||||
|
||||
Caveman is self-contained after install and fully functional offline. There is no license server, no external backend, and no data flow to audit beyond the install-time fetches above. For air-gapped environments, clone the repo internally and run the installer from the clone — no network needed.
|
||||
|
||||
## About scanner warnings
|
||||
|
||||
- **Windows Defender / SmartScreen on `install.ps1` (#383):** piping a script from the internet into `iex` and writing into agent config directories matches generic dropper heuristics, so AV tools may warn. The script is short and readable in this repo; the hook files it installs are SHA-256-verified against the pinned release manifest. If you'd rather not pipe-to-shell, clone the repo and run `node cli/install.js` — same result, fully inspectable first.
|
||||
- **Snyk "High Risk" on `caveman-compress` (#28):** the compress skill instructs the agent to read a file you name, rewrite it in place, and save a backup. In-place file rewriting is exactly what generic risk scoring flags. It is a real capability, not hidden — but there is no network access, no shell execution beyond what's documented in [`skills/caveman-compress/`](./skills/caveman-compress/), and it never touches files you didn't name.
|
||||
+14
-5
@@ -13,14 +13,23 @@ from pathlib import Path
|
||||
|
||||
import anthropic
|
||||
|
||||
# Load .env.local from repo root if it exists
|
||||
# The only env var this benchmark needs: the anthropic SDK reads it in
|
||||
# anthropic.Anthropic(). Read it — and ONLY it — from repo-root .env.local.
|
||||
# Deliberately narrow (issue #528): the old loader setdefault'ed EVERY key in
|
||||
# .env.local into os.environ, which security scanners rightly flag as an
|
||||
# exfiltration surface. Nothing else from the file is ever read or exported.
|
||||
_API_KEY_VAR = "ANTHROPIC_API_KEY"
|
||||
|
||||
_env_file = Path(__file__).parent.parent / ".env.local"
|
||||
if _env_file.exists():
|
||||
if _API_KEY_VAR not in os.environ and _env_file.exists():
|
||||
for line in _env_file.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
if line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
if key.strip() == _API_KEY_VAR:
|
||||
os.environ.setdefault(_API_KEY_VAR, value.strip())
|
||||
break
|
||||
|
||||
SCRIPT_VERSION = "1.0.0"
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
|
||||
+455
-67
@@ -7,7 +7,7 @@
|
||||
// that previously broke the JSON merge step (issue #249).
|
||||
//
|
||||
// Distribution:
|
||||
// Local clone: node bin/install.js [flags]
|
||||
// Local clone: node cli/install.js [flags]
|
||||
// curl|bash: delegated from install.sh shim → npx -y github:JuliusBrussee/caveman -- [flags]
|
||||
// Windows: pwsh install.ps1 [flags] → same npx delegation
|
||||
//
|
||||
@@ -20,12 +20,25 @@ const os = require('os');
|
||||
const path = require('path');
|
||||
const child_process = require('child_process');
|
||||
const readline = require('readline');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const SETTINGS = require('./lib/settings');
|
||||
const OPENCLAW = require('./lib/openclaw');
|
||||
const { stripOpencodeAgentTools } = require('./lib/opencode-agent');
|
||||
|
||||
const REPO = 'JuliusBrussee/caveman';
|
||||
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main`;
|
||||
// Pin remote fetches to an immutable release tag, not the moving `main`
|
||||
// branch (issue #261). A push to main must never silently change what a
|
||||
// curl|bash / detached-script install downloads and executes. Bump this to
|
||||
// the new tag on every release (CI release step) AFTER regenerating
|
||||
// src/hooks/checksums.sha256 so the integrity manifest matches the ref.
|
||||
// Overridable via CAVEMAN_REF for testing against a branch.
|
||||
const PINNED_REF = process.env.CAVEMAN_REF || 'v1.10.0';
|
||||
// OpenClaw skill frontmatter wants a bare semver, not a `v`-prefixed tag —
|
||||
// derive it from PINNED_REF so the two never drift (was hardcoded separately
|
||||
// in cli/lib/openclaw.js as '1.0.0').
|
||||
const OPENCLAW_SKILL_VERSION = PINNED_REF.replace(/^v/, '');
|
||||
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${PINNED_REF}`;
|
||||
const HOOKS_REMOTE = `${RAW_BASE}/src/hooks`;
|
||||
const INIT_SCRIPT_URL = `${RAW_BASE}/src/tools/caveman-init.js`;
|
||||
const MCP_SHRINK_PKG = 'caveman-shrink';
|
||||
@@ -35,24 +48,40 @@ const MCP_SHRINK_PKG = 'caveman-shrink';
|
||||
const HOOK_FILES = [
|
||||
'package.json',
|
||||
'caveman-config.js',
|
||||
'caveman-parse.js',
|
||||
'caveman-activate.js',
|
||||
'caveman-mode-tracker.js',
|
||||
'caveman-stats.js',
|
||||
'caveman-statusline.sh',
|
||||
'caveman-statusline.ps1',
|
||||
'cavecrew-model-overrides.js',
|
||||
];
|
||||
|
||||
// ── Argv ───────────────────────────────────────────────────────────────────
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
dryRun: false, force: false, skipSkills: false,
|
||||
withHooks: 'auto', withInit: false, withMcpShrink: 'auto',
|
||||
withHooks: 'auto', withInit: false, withMcpShrink: false,
|
||||
all: false, minimal: false, listOnly: false, noColor: false,
|
||||
only: [], uninstall: false, nonInteractive: false,
|
||||
configDir: null, help: false,
|
||||
configDir: null, help: false, always: true,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
// --with-mcp-shrink=<upstream cmd> (handled before the switch so the
|
||||
// GNU-style =value form is recognized). Bare --with-mcp-shrink falls
|
||||
// through to the switch and is rejected — caveman-shrink is a proxy
|
||||
// and a stub registration just lands the user in a broken-MCP loop (#474).
|
||||
if (a.startsWith('--with-mcp-shrink=')) {
|
||||
const raw = a.slice('--with-mcp-shrink='.length);
|
||||
const tokens = raw.trim().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length === 0) {
|
||||
die('error: --with-mcp-shrink requires an upstream command\n' +
|
||||
' example: --with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /path"');
|
||||
}
|
||||
opts.withMcpShrink = tokens;
|
||||
continue;
|
||||
}
|
||||
switch (a) {
|
||||
case '--dry-run': opts.dryRun = true; break;
|
||||
case '--force': opts.force = true; break;
|
||||
@@ -60,7 +89,27 @@ function parseArgs(argv) {
|
||||
case '--with-hooks': opts.withHooks = true; break;
|
||||
case '--no-hooks': opts.withHooks = false; break;
|
||||
case '--with-init': opts.withInit = true; break;
|
||||
case '--with-mcp-shrink': opts.withMcpShrink = true; break;
|
||||
// OpenClaw only. Skips `always: true` frontmatter + the SOUL.md
|
||||
// bootstrap append — the skill installs load-on-demand instead of
|
||||
// always-on. Default (no flag) behavior is unchanged.
|
||||
case '--no-always': opts.always = false; break;
|
||||
case '--with-mcp-shrink': {
|
||||
const v = argv[i + 1];
|
||||
if (v && !v.startsWith('--')) {
|
||||
i++;
|
||||
const tokens = v.trim().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length === 0) {
|
||||
die('error: --with-mcp-shrink requires an upstream command\n' +
|
||||
' example: --with-mcp-shrink "npx @modelcontextprotocol/server-filesystem /path"');
|
||||
}
|
||||
opts.withMcpShrink = tokens;
|
||||
} else {
|
||||
die('error: --with-mcp-shrink requires an upstream command — caveman-shrink\n' +
|
||||
' is a proxy and exits immediately without one. Pass the upstream:\n' +
|
||||
' --with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /path"');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '--no-mcp-shrink': opts.withMcpShrink = false; break;
|
||||
case '--all': opts.all = true; break;
|
||||
case '--minimal': opts.minimal = true; break;
|
||||
@@ -90,10 +139,15 @@ function parseArgs(argv) {
|
||||
}
|
||||
}
|
||||
if (opts.all && opts.minimal) die('error: --all and --minimal are mutually exclusive');
|
||||
if (opts.all) { opts.withHooks = true; opts.withInit = true; opts.withMcpShrink = true; }
|
||||
// --all turns on per-repo init only. It deliberately does NOT force:
|
||||
// • withHooks — left at 'auto' so installClaude() can skip standalone
|
||||
// settings.json wiring when the plugin manifest already wires the hooks
|
||||
// (duplicate registration fires both per event — issue #392).
|
||||
// • withMcpShrink — caveman-shrink is a proxy that needs an upstream
|
||||
// command, so there's no sensible "everything on" default (issue #474).
|
||||
// Opt in explicitly with --with-mcp-shrink="<upstream cmd>".
|
||||
if (opts.all) { opts.withInit = true; }
|
||||
if (opts.minimal) { opts.withHooks = false; opts.withInit = false; opts.withMcpShrink = false; }
|
||||
if (opts.withHooks === 'auto') opts.withHooks = true;
|
||||
if (opts.withMcpShrink === 'auto') opts.withMcpShrink = true;
|
||||
// Validate --only ids against the provider matrix. PROVIDERS is defined later
|
||||
// in the file but is in scope by the time this function runs.
|
||||
if (opts.only.length) {
|
||||
@@ -176,14 +230,15 @@ const PROVIDERS = [
|
||||
{ id: 'roo', label: 'Roo Code', mech: 'npx skills add (roo)', detect: 'vscode-ext:roo||vscode-ext:rooveterinaryinc.roo-cline||cursor-ext:roo', profile: 'roo' },
|
||||
{ id: 'augment', label: 'Augment Code', mech: 'npx skills add (augment)', detect: 'vscode-ext:augment||jetbrains-plugin:augment', profile: 'augment' },
|
||||
|
||||
// GitHub Copilot — `gh` (GitHub CLI) is on most dev machines but isn't
|
||||
// Copilot. There's no reliable always-on Copilot probe (subscription state
|
||||
// is auth-gated). Mark soft → opt-in via --only copilot.
|
||||
{ id: 'copilot', label: 'GitHub Copilot', mech: 'npx skills add (github-copilot)', detect: 'command:copilot', profile: 'github-copilot', soft: true },
|
||||
// GitHub Copilot: detected via VS Code / Cursor extension dirs (no `gh` CLI
|
||||
// needed). The old `command:copilot` soft probe never fired for most users
|
||||
// because Copilot ships as an editor extension, not a CLI (issue #336).
|
||||
{ id: 'copilot', label: 'GitHub Copilot', mech: 'npx skills add (github-copilot)', detect: 'vscode-ext:github.copilot||vscode-ext:github.copilot-chat||cursor-ext:github.copilot', profile: 'github-copilot' },
|
||||
|
||||
// CLI agents — require the binary. The `||dir:~/.foo` fallbacks were the
|
||||
// main source of false positives (warp, kiro, junie etc. leave config dirs
|
||||
// behind on uninstall).
|
||||
{ id: 'hermes', label: 'Hermes Agent', mech: 'native hermes skills copy', detect: 'command:hermes' },
|
||||
{ id: 'aider-desk', label: 'Aider Desk', mech: 'npx skills add (aider-desk)', detect: 'command:aider', profile: 'aider-desk' },
|
||||
{ id: 'amp', label: 'Sourcegraph Amp', mech: 'npx skills add (amp)', detect: 'command:amp', profile: 'amp' },
|
||||
{ id: 'bob', label: 'IBM Bob', mech: 'npx skills add (bob)', detect: 'command:bob', profile: 'bob' },
|
||||
@@ -326,7 +381,7 @@ function safeStat(p, method) {
|
||||
|
||||
// ── Repo root resolution ───────────────────────────────────────────────────
|
||||
function detectRepoRoot() {
|
||||
// bin/install.js sits at <repo>/bin/install.js. Walk up one.
|
||||
// cli/install.js sits at <repo>/cli/install.js. Walk up one.
|
||||
const here = path.dirname(__filename);
|
||||
const root = path.resolve(here, '..');
|
||||
if (fs.existsSync(path.join(root, 'src', 'hooks')) &&
|
||||
@@ -346,15 +401,28 @@ function detectRepoRoot() {
|
||||
// args with spaces need quoting; we quote them defensively below.
|
||||
const IS_WIN = process.platform === 'win32';
|
||||
|
||||
function quoteWinArg(a) {
|
||||
if (!IS_WIN) return a;
|
||||
if (a === '' || /[\s"]/.test(a)) {
|
||||
// Standard CommandLineToArgvW escaping
|
||||
// Trigger on whitespace/quote (CommandLineToArgvW escaping needed) OR any
|
||||
// cmd.exe metacharacter (& | ^ < > % ( )) — spawnXplat runs the assembled
|
||||
// string through `shell: true`, so an unquoted metacharacter in an
|
||||
// attacker-influenced arg (e.g. --with-mcp-shrink value, --with-init cwd)
|
||||
// reaches cmd.exe and can chain a second command. Split out from quoteWinArg
|
||||
// (which is IS_WIN-gated) and exported unconditionally so tests/installer/
|
||||
// can exercise the quoting decision on any host platform, not just Windows.
|
||||
function winQuoteIfNeeded(a) {
|
||||
if (a === '' || /[\s"&|^<>%()]/.test(a)) {
|
||||
// Standard CommandLineToArgvW escaping. Residual: cmd.exe still expands
|
||||
// %VAR% (and ! under delayed expansion) inside double quotes — env
|
||||
// expansion, not command chaining; accepted for these install-time args.
|
||||
return '"' + String(a).replace(/\\(?=\\*"|$)/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function quoteWinArg(a) {
|
||||
if (!IS_WIN) return a;
|
||||
return winQuoteIfNeeded(a);
|
||||
}
|
||||
|
||||
function spawnXplat(cmd, args, opts) {
|
||||
if (IS_WIN) {
|
||||
const quoted = args.map(quoteWinArg).join(' ');
|
||||
@@ -369,18 +437,42 @@ function runSpawn(cmd, args, opts, dry) {
|
||||
return spawnXplat(cmd, args, Object.assign({ stdio: 'inherit' }, opts || {}));
|
||||
}
|
||||
|
||||
// Create env with TMPDIR pointing to a temp dir inside configDir.
|
||||
// Workaround for Claude Code plugin install EXDEV bug: it tries to rename
|
||||
// from ~/.claude/plugins/cache/ to /tmp/ which fails when /tmp is on a
|
||||
// different filesystem (common on Linux). Setting TMPDIR to a directory
|
||||
// on the same filesystem as ~/.claude/ avoids the cross-device link error.
|
||||
function sameFilesystemTmpEnv(configDir) {
|
||||
const tmpDir = path.join(configDir, 'tmp');
|
||||
try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (_) {}
|
||||
return Object.assign({}, process.env, {
|
||||
TMPDIR: tmpDir, // Unix
|
||||
TEMP: tmpDir, // Windows
|
||||
TMP: tmpDir, // Windows alternate
|
||||
});
|
||||
}
|
||||
|
||||
function captureSpawn(cmd, args) {
|
||||
try { return spawnXplat(cmd, args, { encoding: 'utf8' }); }
|
||||
catch (_) { return { status: 1, stdout: '', stderr: '' }; }
|
||||
}
|
||||
|
||||
// spawnSync reports a missing binary as { status: null, error }, so the old
|
||||
// `(r.status || 0) === 0` checks read ENOENT as success — a machine without
|
||||
// the `claude` CLI got "installed: claude" with nothing installed and the
|
||||
// standalone-hook fallback skipped (issue #592). Every spawn result must pass
|
||||
// through here before being treated as "it worked".
|
||||
function spawnOk(r) {
|
||||
return !!r && !r.error && r.status === 0;
|
||||
}
|
||||
|
||||
function absoluteNodePath() {
|
||||
return process.execPath;
|
||||
}
|
||||
|
||||
// ── Per-provider installers ────────────────────────────────────────────────
|
||||
async function installClaude(ctx) {
|
||||
const { say, note, warn, ok, opts, results } = ctx;
|
||||
const { say, note, warn, ok, opts, results, configDir } = ctx;
|
||||
results.detected++;
|
||||
say('→ Claude Code detected');
|
||||
|
||||
@@ -390,18 +482,80 @@ async function installClaude(ctx) {
|
||||
const r = captureSpawn('claude', ['plugin', 'list']);
|
||||
if (r.status === 0 && /caveman/i.test(r.stdout || '')) alreadyInstalled = true;
|
||||
}
|
||||
let pluginInstallSucceeded = false;
|
||||
if (alreadyInstalled) {
|
||||
note(' caveman plugin already installed (use --force to reinstall)');
|
||||
results.skipped.push(['claude', 'plugin already installed']);
|
||||
pluginInstallSucceeded = true;
|
||||
} else {
|
||||
const r1 = runSpawn('claude', ['plugin', 'marketplace', 'add', REPO], null, opts.dryRun);
|
||||
const r2 = runSpawn('claude', ['plugin', 'install', 'caveman@caveman'], null, opts.dryRun);
|
||||
if ((r1.status || 0) === 0 && (r2.status || 0) === 0) results.installed.push('claude');
|
||||
else results.failed.push(['claude', 'claude plugin install failed']);
|
||||
// Use a temp dir on the same filesystem as configDir to avoid EXDEV errors
|
||||
// when Claude Code's plugin installer tries to rename across filesystems (#585).
|
||||
const pluginEnv = sameFilesystemTmpEnv(configDir);
|
||||
const r1 = runSpawn('claude', ['plugin', 'marketplace', 'add', REPO], { env: pluginEnv }, opts.dryRun);
|
||||
const r2 = runSpawn('claude', ['plugin', 'install', 'caveman@caveman'], { env: pluginEnv }, opts.dryRun);
|
||||
if (spawnOk(r1) && spawnOk(r2)) {
|
||||
results.installed.push('claude');
|
||||
pluginInstallSucceeded = true;
|
||||
} else {
|
||||
if (r1.error || r2.error) {
|
||||
warn(' claude CLI not found on PATH (or could not be spawned)');
|
||||
}
|
||||
results.failed.push(['claude', 'claude plugin install failed']);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.withHooks) {
|
||||
say(' → installing hooks (--with-hooks)');
|
||||
// Self-heal: drop managed settings.json hook/statusLine entries whose target
|
||||
// script no longer exists (issue #471). Migrating an old manual install to
|
||||
// the plugin leaves settings.json pointing at removed ~/.claude/hooks/
|
||||
// caveman-*.js scripts, so Claude Code crashes every SessionStart /
|
||||
// UserPromptSubmit with `loader:1478 — Cannot find module …`. Runs
|
||||
// unconditionally so it repairs an already-dirty config even when we then
|
||||
// skip standalone wiring because the plugin manifest handles hooks.
|
||||
{
|
||||
const settingsPath = path.join(configDir, 'settings.json');
|
||||
const settings = SETTINGS.readSettings(settingsPath);
|
||||
if (settings) {
|
||||
const pruned = SETTINGS.pruneOrphanedManagedHooks(settings, configDir);
|
||||
if (pruned > 0) {
|
||||
note(` removed ${pruned} orphaned caveman hook entr${pruned === 1 ? 'y' : 'ies'} from settings.json (target script missing)`);
|
||||
if (!opts.dryRun) {
|
||||
SETTINGS.validateHookFields(settings);
|
||||
SETTINGS.writeSettings(settingsPath, settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hook wiring decision matrix (issue #392 — avoid double-firing):
|
||||
// --no-hooks → skip
|
||||
// --with-hooks → wire (warn if the plugin manifest also wires them)
|
||||
// default / --all → wire only if the plugin install did NOT succeed.
|
||||
// The plugin manifest already wires SessionStart + UserPromptSubmit when the
|
||||
// plugin install succeeds; wiring them again in settings.json fires both per
|
||||
// event (two CAVEMAN MODE blocks, two reinforcement lines).
|
||||
let shouldWireHooks;
|
||||
if (opts.withHooks === false) {
|
||||
shouldWireHooks = false;
|
||||
} else if (opts.withHooks === true) {
|
||||
shouldWireHooks = true;
|
||||
if (pluginInstallSucceeded) {
|
||||
warn(' --with-hooks wires hooks in settings.json alongside the plugin manifest.');
|
||||
warn(' Both will fire on every event. Pass --no-hooks to keep only the plugin path.');
|
||||
}
|
||||
} else {
|
||||
// 'auto'
|
||||
shouldWireHooks = !pluginInstallSucceeded;
|
||||
if (!shouldWireHooks) {
|
||||
note(' hooks: plugin manifest handles SessionStart + UserPromptSubmit');
|
||||
note(' (pass --with-hooks to also wire standalone hooks in settings.json)');
|
||||
results.skipped.push(['claude-hooks', 'plugin manifest handles hooks']);
|
||||
} else {
|
||||
note(' hooks: plugin install did not succeed; falling back to standalone wiring');
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldWireHooks) {
|
||||
say(' → installing hooks');
|
||||
const r = await installHooks(ctx);
|
||||
if (r === 'ok') results.installed.push('claude-hooks');
|
||||
else if (r === 'skip') results.skipped.push(['claude-hooks', 'already wired']);
|
||||
@@ -433,8 +587,11 @@ function installGemini(ctx) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const r = runSpawn('gemini', ['extensions', 'install', `https://github.com/${REPO}`], null, opts.dryRun);
|
||||
if ((r.status || 0) === 0) results.installed.push('gemini');
|
||||
// --consent: without it, `gemini extensions install` prints a security
|
||||
// confirmation prompt and blocks forever on a piped/non-interactive install
|
||||
// (issue #676) — there's no stdin to answer it from a curl|bash run.
|
||||
const r = runSpawn('gemini', ['extensions', 'install', `https://github.com/${REPO}`, '--consent'], null, opts.dryRun);
|
||||
if (spawnOk(r)) results.installed.push('gemini');
|
||||
else results.failed.push(['gemini', 'gemini extensions install failed']);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
@@ -443,12 +600,80 @@ function installViaSkills(ctx, prov) {
|
||||
const { say, opts, results } = ctx;
|
||||
results.detected++;
|
||||
say(`→ ${prov.label} detected`);
|
||||
const r = runSpawn('npx', ['-y', 'skills', 'add', REPO, '-a', prov.profile], null, opts.dryRun);
|
||||
if ((r.status || 0) === 0) results.installed.push(prov.id);
|
||||
// --skill '*' --yes: skip the upstream skill-selection TUI and confirmation
|
||||
// prompts. Without --skill, `curl|bash` (no TTY on stdin) renders an empty
|
||||
// checkbox list the user can't interact with, then exits 0 with zero skills
|
||||
// installed — and our installer happily reports success. See issue #370.
|
||||
//
|
||||
// We pass `--skill '*'` rather than `--all` because the upstream `skills` CLI
|
||||
// interprets `--all` as "all skills from the source to *all* agents", which
|
||||
// ignores the `-a prov.profile` selection and writes every skill through
|
||||
// every agent adapter (see issue #389). `--skill '*' -a <agent>` is the
|
||||
// documented form for "install every skill into a specific agent".
|
||||
const args = ['-y', 'skills', 'add', REPO, '--skill', '*', '-a', prov.profile, '--yes'];
|
||||
const r = runSpawn('npx', args, null, opts.dryRun);
|
||||
if (spawnOk(r)) results.installed.push(prov.id);
|
||||
else results.failed.push([prov.id, `npx skills add (${prov.profile}) failed`]);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
|
||||
// ── hermes native install ──────────────────────────────────────────────────
|
||||
// Drops the caveman skills into ~/.hermes/skills/productivity/ (or HERMES_HOME if set).
|
||||
const HERMES_SKILL_DIRS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew'];
|
||||
|
||||
function hermesConfigDir() {
|
||||
// Hermes uses ~/.hermes by default, or HERMES_HOME env var.
|
||||
if (process.env.HERMES_HOME) return path.join(process.env.HERMES_HOME, 'skills');
|
||||
return path.join(os.homedir(), '.hermes', 'skills');
|
||||
}
|
||||
|
||||
function installHermes(ctx) {
|
||||
const { say, note, warn, opts, repoRoot, results } = ctx;
|
||||
results.detected++;
|
||||
say('→ Hermes Agent detected');
|
||||
|
||||
if (!repoRoot) {
|
||||
warn(' Hermes native install requires a local clone of the caveman repo.');
|
||||
note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node cli/install.js --only hermes');
|
||||
results.failed.push(['hermes', 'native install requires local repo clone']);
|
||||
process.stdout.write('\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const skillsRoot = path.join(hermesConfigDir(), 'productivity');
|
||||
|
||||
if (opts.dryRun) {
|
||||
note(` would mkdir ${skillsRoot}/`);
|
||||
note(` would copy ${HERMES_SKILL_DIRS.length} skill dirs into ${skillsRoot}/`);
|
||||
results.installed.push('hermes');
|
||||
process.stdout.write('\n');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(skillsRoot, { recursive: true });
|
||||
|
||||
for (const skillDir of HERMES_SKILL_DIRS) {
|
||||
const srcDir = path.join(repoRoot, 'skills', skillDir);
|
||||
const destDir = path.join(skillsRoot, skillDir);
|
||||
if (fs.existsSync(srcDir)) {
|
||||
// Remove existing to ensure clean copy
|
||||
if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyDirRecursive(srcDir, destDir);
|
||||
note(` copied ${skillDir} → ${destDir}`);
|
||||
} else {
|
||||
warn(` skill dir not found: ${srcDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
results.installed.push('hermes');
|
||||
} catch (err) {
|
||||
results.failed.push(['hermes', 'copy failed: ' + err.message]);
|
||||
}
|
||||
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
|
||||
// ── opencode native install ───────────────────────────────────────────────
|
||||
// Drops the in-repo plugin (src/plugins/opencode/) plus skills, agents,
|
||||
// commands, and an AGENTS.md ruleset into ~/.config/opencode/. Patches
|
||||
@@ -461,14 +686,15 @@ const OPENCODE_COMMAND_FILES = ['caveman.md', 'caveman-commit.md', 'caveman-revi
|
||||
const OPENCODE_PLUGIN_REL = './plugins/caveman/plugin.js';
|
||||
const OPENCODE_AGENTS_MD_SENTINEL = 'Respond terse like smart caveman';
|
||||
// Marker fence for the opencode AGENTS.md ruleset block. Same convention as
|
||||
// bin/lib/openclaw.js for SOUL.md — lets us strip our block cleanly even when
|
||||
// cli/lib/openclaw.js for SOUL.md — lets us strip our block cleanly even when
|
||||
// the user has authored content above AND below it.
|
||||
const OPENCODE_AGENTS_MD_BEGIN = '<!-- caveman-begin -->';
|
||||
const OPENCODE_AGENTS_MD_END = '<!-- caveman-end -->';
|
||||
|
||||
function opencodeConfigDir() {
|
||||
// opencode uses ~/.config/opencode on every platform (on Windows that's
|
||||
// %USERPROFILE%\.config\opencode via os.homedir()), NOT %APPDATA% (#376).
|
||||
if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, 'opencode');
|
||||
if (IS_WIN) return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'opencode');
|
||||
return path.join(os.homedir(), '.config', 'opencode');
|
||||
}
|
||||
|
||||
@@ -489,7 +715,7 @@ function installOpencode(ctx) {
|
||||
|
||||
if (!repoRoot) {
|
||||
warn(' opencode native install requires a local clone of the caveman repo.');
|
||||
note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node bin/install.js --only opencode');
|
||||
note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node cli/install.js --only opencode');
|
||||
results.failed.push(['opencode', 'native install requires local repo clone']);
|
||||
process.stdout.write('\n');
|
||||
return;
|
||||
@@ -505,7 +731,7 @@ function installOpencode(ctx) {
|
||||
|
||||
if (opts.dryRun) {
|
||||
note(` would mkdir ${pluginDir}/, ${commandsDir}/, ${agentsDir}/, ${skillsDir}/`);
|
||||
note(` would copy plugin.js + package.json + caveman-config.cjs into ${pluginDir}/`);
|
||||
note(` would copy plugin.js + package.json + caveman-config.cjs + caveman-parse.cjs into ${pluginDir}/`);
|
||||
note(` would copy ${OPENCODE_COMMAND_FILES.length} command files into ${commandsDir}/`);
|
||||
note(` would copy ${OPENCODE_AGENT_FILES.length} cavecrew agents into ${agentsDir}/`);
|
||||
note(` would copy ${OPENCODE_SKILL_DIRS.length} skill dirs into ${skillsDir}/`);
|
||||
@@ -529,6 +755,12 @@ function installOpencode(ctx) {
|
||||
// sibling would be loaded as ESM and break the plugin's require() bridge.
|
||||
[path.join(repoRoot, 'src', 'hooks', 'caveman-config.js'),
|
||||
path.join(pluginDir, 'caveman-config.cjs')],
|
||||
// Shared mode-change parser consumed by both caveman-mode-tracker.js and
|
||||
// plugin.js's own tui.prompt.append handler. Same .cjs rename reason —
|
||||
// and caveman-parse.js's own require('./caveman-config') resolves fine
|
||||
// since both land as siblings in pluginDir.
|
||||
[path.join(repoRoot, 'src', 'hooks', 'caveman-parse.js'),
|
||||
path.join(pluginDir, 'caveman-parse.cjs')],
|
||||
];
|
||||
for (const [src, dest] of pluginPayload) {
|
||||
if (fs.existsSync(dest) && !opts.force) {
|
||||
@@ -545,12 +777,17 @@ function installOpencode(ctx) {
|
||||
for (const f of OPENCODE_COMMAND_FILES) {
|
||||
const src = path.join(cmdSrcDir, f);
|
||||
const dest = path.join(commandsDir, f);
|
||||
if (!fs.existsSync(src)) continue; // defense-in-depth: skip a missing command file rather than crash (#434)
|
||||
if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest} (exists; --force to overwrite)`); continue; }
|
||||
fs.copyFileSync(src, dest);
|
||||
process.stdout.write(` installed: ${dest}\n`);
|
||||
}
|
||||
|
||||
// 3. Subagents.
|
||||
// 3. Subagents. Source files target Claude Code's schema (`tools: [...]`
|
||||
// YAML array); opencode rejects that form and refuses to boot until the
|
||||
// file is removed. Strip the `tools:` line on copy — opencode falls back
|
||||
// to its default tool set, and subagent prompts already self-restrict in
|
||||
// the body. Issue #386.
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
const agentSrcDir = path.join(repoRoot, 'agents');
|
||||
for (const f of OPENCODE_AGENT_FILES) {
|
||||
@@ -558,7 +795,7 @@ function installOpencode(ctx) {
|
||||
const dest = path.join(agentsDir, f);
|
||||
if (!fs.existsSync(src)) continue;
|
||||
if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest} (exists; --force to overwrite)`); continue; }
|
||||
fs.copyFileSync(src, dest);
|
||||
fs.writeFileSync(dest, stripOpencodeAgentTools(fs.readFileSync(src, 'utf8')));
|
||||
process.stdout.write(` installed: ${dest}\n`);
|
||||
}
|
||||
|
||||
@@ -588,14 +825,36 @@ function installOpencode(ctx) {
|
||||
if (alreadyFenced) {
|
||||
note(` ${agentsMd} already contains caveman ruleset`);
|
||||
} else if (alreadyByLegacySentinel) {
|
||||
note(` ${agentsMd} contains a legacy (un-fenced) caveman block — leaving as-is`);
|
||||
note(' re-run with --force to replace it with a fenced block');
|
||||
if (!opts.force) {
|
||||
note(` ${agentsMd} contains a legacy (un-fenced) caveman block — leaving as-is`);
|
||||
note(' re-run with --force to migrate it to a fenced block');
|
||||
}
|
||||
if (opts.force) {
|
||||
// Replace the entire file with a clean fenced version. The legacy
|
||||
// path didn't fence, so we can't isolate the block — full rewrite is
|
||||
// the only safe option under --force.
|
||||
fs.writeFileSync(agentsMd, fencedBlock, { mode: 0o644 });
|
||||
process.stdout.write(` rewrote ${agentsMd} with fenced caveman block\n`);
|
||||
// Migrate, don't wipe (issue #594): the old code replaced the whole
|
||||
// file, destroying any user-authored content around the legacy
|
||||
// block. Back up once, then remove only the legacy block: exact
|
||||
// match of the current rule body when possible, otherwise cut from
|
||||
// the sentinel's paragraph start to EOF (the legacy path APPENDED
|
||||
// the block, so user content precedes it; anything after lives on
|
||||
// in the backup).
|
||||
const agentsBak = agentsMd + '.bak';
|
||||
if (!fs.existsSync(agentsBak)) {
|
||||
try { fs.copyFileSync(agentsMd, agentsBak); } catch (_) {}
|
||||
}
|
||||
const bodyTrim = ruleBody.trimEnd();
|
||||
let userPart;
|
||||
const exact = existing.indexOf(bodyTrim);
|
||||
if (exact !== -1) {
|
||||
userPart = (existing.slice(0, exact) + existing.slice(exact + bodyTrim.length)).trim();
|
||||
} else {
|
||||
const sentinelAt = existing.indexOf(OPENCODE_AGENTS_MD_SENTINEL);
|
||||
const cutAt = existing.lastIndexOf('\n\n', sentinelAt);
|
||||
userPart = cutAt === -1 ? '' : existing.slice(0, cutAt).trim();
|
||||
note(` legacy block did not match the current ruleset — everything from the sentinel down was replaced; original kept at ${agentsBak}`);
|
||||
}
|
||||
const next = (userPart ? userPart + '\n\n' : '') + fencedBlock;
|
||||
fs.writeFileSync(agentsMd, next, { mode: 0o644 });
|
||||
process.stdout.write(` migrated ${agentsMd} legacy block to fenced (backup: ${agentsBak})\n`);
|
||||
}
|
||||
} else {
|
||||
const sep = existing.endsWith('\n\n') ? '' : (existing.endsWith('\n') ? '\n' : '\n\n');
|
||||
@@ -626,14 +885,17 @@ function installOpencode(ctx) {
|
||||
cfg.plugin.push(OPENCODE_PLUGIN_REL);
|
||||
}
|
||||
if (opts.withMcpShrink) {
|
||||
// opts.withMcpShrink is the array of upstream-cmd tokens parseArgs
|
||||
// produced. caveman-shrink is a proxy — it crashes without an upstream,
|
||||
// so we always wire one through.
|
||||
if (!cfg.mcp || typeof cfg.mcp !== 'object') cfg.mcp = {};
|
||||
if (!cfg.mcp['caveman-shrink']) {
|
||||
cfg.mcp['caveman-shrink'] = {
|
||||
type: 'local',
|
||||
command: ['npx', '-y', MCP_SHRINK_PKG],
|
||||
command: ['npx', '-y', MCP_SHRINK_PKG, ...opts.withMcpShrink],
|
||||
enabled: true,
|
||||
};
|
||||
process.stdout.write(' registered caveman-shrink MCP server\n');
|
||||
process.stdout.write(` registered caveman-shrink MCP server (wraps: ${opts.withMcpShrink.join(' ')})\n`);
|
||||
}
|
||||
}
|
||||
SETTINGS.writeSettings(opencodeJson, cfg);
|
||||
@@ -651,7 +913,7 @@ function installOpencode(ctx) {
|
||||
// Drops skills/caveman/ into the OpenClaw workspace and appends a small
|
||||
// auto-injected bootstrap block to the workspace SOUL.md. Always-on behavior
|
||||
// comes from SOUL.md (auto-injected each turn); the skill folder makes
|
||||
// caveman discoverable via `openclaw skills list`. See bin/lib/openclaw.js
|
||||
// caveman discoverable via `openclaw skills list`. See cli/lib/openclaw.js
|
||||
// for the actual file writes.
|
||||
function installOpenclaw(ctx) {
|
||||
const { say, note, warn, opts, repoRoot, results } = ctx;
|
||||
@@ -669,6 +931,8 @@ function installOpenclaw(ctx) {
|
||||
repoRoot,
|
||||
dryRun: opts.dryRun,
|
||||
force: opts.force,
|
||||
version: OPENCLAW_SKILL_VERSION,
|
||||
always: opts.always,
|
||||
log,
|
||||
});
|
||||
|
||||
@@ -696,6 +960,12 @@ async function installHooks(ctx) {
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
|
||||
// Copy or download each hook file. Local-clone-first for offline installs.
|
||||
// Downloaded files (the rare detached-script / curl fallback) are verified
|
||||
// against the SHA-256 manifest published at the pinned release ref (#262);
|
||||
// a mismatch aborts before the file is wired into settings.json. Local
|
||||
// copies are trusted — they come from the same package as this script.
|
||||
let checksums; // undefined = not yet loaded; null = unavailable for this ref
|
||||
let warnedNoChecksums = false;
|
||||
for (const f of HOOK_FILES) {
|
||||
const dest = path.join(hooksDir, f);
|
||||
if (sourceDir && fs.existsSync(path.join(sourceDir, f))) {
|
||||
@@ -703,6 +973,19 @@ async function installHooks(ctx) {
|
||||
} else {
|
||||
try { await downloadTo(`${HOOKS_REMOTE}/${f}`, dest); }
|
||||
catch (e) { return `download ${f} failed: ${e.message}`; }
|
||||
if (checksums === undefined) checksums = await loadRemoteHookChecksums();
|
||||
if (checksums) {
|
||||
const want = checksums.get(f);
|
||||
const got = sha256File(dest);
|
||||
if (!want || want !== got) {
|
||||
try { fs.unlinkSync(dest); } catch (_) {}
|
||||
return `integrity check failed for ${f} (expected ${want || '<not in manifest>'}, got ${got}) — ` +
|
||||
`refusing to install a hook that doesn't match pinned release ${PINNED_REF}`;
|
||||
}
|
||||
} else if (!warnedNoChecksums) {
|
||||
warnedNoChecksums = true;
|
||||
warn(` note: no integrity manifest at ${PINNED_REF} — downloaded hooks installed unverified.`);
|
||||
}
|
||||
}
|
||||
process.stdout.write(` installed: ${dest}\n`);
|
||||
}
|
||||
@@ -794,10 +1077,21 @@ function installMcpShrink(ctx) {
|
||||
note(' src/hooks/README.md to your Claude Code MCP config manually.');
|
||||
return { kind: 'skip', why: 'manual config required' };
|
||||
}
|
||||
const r = runSpawn('claude', ['mcp', 'add', 'caveman-shrink', '--', 'npx', '-y', MCP_SHRINK_PKG], null, opts.dryRun);
|
||||
if ((r.status || 0) === 0) {
|
||||
note(' registered. Wrap an upstream by editing the mcpServers entry — see:');
|
||||
note(` https://github.com/${REPO}/tree/main/src/mcp-servers/caveman-shrink`);
|
||||
// opts.withMcpShrink is always an array of upstream-cmd tokens by the
|
||||
// time we get here; parseArgs rejects bare --with-mcp-shrink. The proxy
|
||||
// gets `npx -y caveman-shrink <upstream tokens...>` so it has something
|
||||
// to wrap.
|
||||
const upstream = opts.withMcpShrink;
|
||||
const r = runSpawn(
|
||||
'claude',
|
||||
['mcp', 'add', 'caveman-shrink', '--', 'npx', '-y', MCP_SHRINK_PKG, ...upstream],
|
||||
null, opts.dryRun
|
||||
);
|
||||
if (spawnOk(r)) {
|
||||
note(` registered, wrapping: ${upstream.join(' ')}`);
|
||||
note(` Edit ~/.claude.json mcpServers["caveman-shrink"] to change the upstream,`);
|
||||
note(' or `claude mcp remove caveman-shrink` to drop it.');
|
||||
note(` Docs: https://github.com/${REPO}/tree/main/src/mcp-servers/caveman-shrink`);
|
||||
return { kind: 'ok' };
|
||||
}
|
||||
return { kind: 'fail', why: 'claude mcp add failed' };
|
||||
@@ -812,7 +1106,7 @@ async function runInit(ctx) {
|
||||
if (opts.force) args.push('--force');
|
||||
if (local && fs.existsSync(local)) {
|
||||
const r = runSpawn(absoluteNodePath(), [local, ...args], null, opts.dryRun);
|
||||
return (r.status || 0) === 0;
|
||||
return spawnOk(r);
|
||||
}
|
||||
// Curl-pipe fallback
|
||||
if (opts.dryRun) {
|
||||
@@ -824,7 +1118,7 @@ async function runInit(ctx) {
|
||||
await downloadTo(INIT_SCRIPT_URL, tmp);
|
||||
const r = child_process.spawnSync(absoluteNodePath(), [tmp, ...args], { stdio: 'inherit' });
|
||||
try { fs.unlinkSync(tmp); } catch (_) {}
|
||||
return (r.status || 0) === 0;
|
||||
return spawnOk(r);
|
||||
} catch (e) {
|
||||
warn(' ' + e.message);
|
||||
return false;
|
||||
@@ -857,6 +1151,35 @@ function downloadTo(url, dest) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Integrity verification for downloaded hooks (#262) ─────────────────────
|
||||
function sha256File(p) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
}
|
||||
|
||||
// Download + parse the hook integrity manifest from the pinned release ref.
|
||||
// Returns Map<basename, sha256hex>, or null when the manifest is unavailable
|
||||
// (release tags older than this feature predate it) — the caller treats null
|
||||
// as "cannot verify" and warns rather than aborting, for back-compat. Parses
|
||||
// the standard `sha256sum` text format: "<64-hex> <path>" (two spaces, or
|
||||
// " *<path>" binary marker).
|
||||
async function loadRemoteHookChecksums() {
|
||||
const tmp = path.join(os.tmpdir(), `caveman-checksums-${process.pid}-${Date.now()}.sha256`);
|
||||
try {
|
||||
await downloadTo(`${HOOKS_REMOTE}/checksums.sha256`, tmp);
|
||||
const txt = fs.readFileSync(tmp, 'utf8');
|
||||
const map = new Map();
|
||||
for (const line of txt.split('\n')) {
|
||||
const m = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
|
||||
if (m) map.set(path.basename(m[2].trim()), m[1].toLowerCase());
|
||||
}
|
||||
return map.size ? map : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Uninstall ─────────────────────────────────────────────────────────────
|
||||
function uninstall(ctx) {
|
||||
const { say, note, warn, ok, opts, configDir } = ctx;
|
||||
@@ -870,7 +1193,7 @@ function uninstall(ctx) {
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = SETTINGS.readSettings(settingsPath);
|
||||
if (settings) {
|
||||
const removed = SETTINGS.removeCavemanHooks(settings, 'caveman');
|
||||
const removed = SETTINGS.removeCavemanHooks(settings);
|
||||
// Drop our statusline if it points at our script
|
||||
if (settings.statusLine) {
|
||||
const cmd = typeof settings.statusLine === 'string' ? settings.statusLine : (settings.statusLine.command || '');
|
||||
@@ -886,8 +1209,12 @@ function uninstall(ctx) {
|
||||
for (const f of HOOK_FILES) {
|
||||
const p = path.join(hooksDir, f);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
if (!opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} }
|
||||
note(` removed ${p}`);
|
||||
if (opts.dryRun) {
|
||||
note(` would remove ${p}`);
|
||||
} else {
|
||||
try { fs.unlinkSync(p); } catch (_) {}
|
||||
note(` removed ${p}`);
|
||||
}
|
||||
}
|
||||
// Don't rmdir hooksDir — other plugins may use it.
|
||||
}
|
||||
@@ -899,7 +1226,7 @@ function uninstall(ctx) {
|
||||
const probe = captureSpawn('claude', ['plugin', 'list']);
|
||||
if (probe.status === 0 && /caveman/i.test(probe.stdout || '')) {
|
||||
const r = runSpawn('claude', ['plugin', 'uninstall', 'caveman@caveman'], null, opts.dryRun);
|
||||
if ((r.status || 0) === 0) ok(' removed claude plugin');
|
||||
if (spawnOk(r)) ok(' removed claude plugin');
|
||||
} else {
|
||||
note(' claude plugin not installed — skipping');
|
||||
}
|
||||
@@ -1009,9 +1336,48 @@ function uninstall(ctx) {
|
||||
if (r.touched) ok(' pruned caveman entries from OpenClaw workspace');
|
||||
}
|
||||
|
||||
// Flag file
|
||||
const flag = path.join(configDir, '.caveman-active');
|
||||
if (fs.existsSync(flag) && !opts.dryRun) { try { fs.unlinkSync(flag); } catch (_) {} }
|
||||
// Hermes native install — remove the skill folders installHermes copied.
|
||||
// Honors HERMES_HOME via hermesConfigDir(); probed by the dirs we own.
|
||||
const hermesRoot = path.join(hermesConfigDir(), 'productivity');
|
||||
if (fs.existsSync(hermesRoot)) {
|
||||
let prunedHermes = false;
|
||||
for (const name of HERMES_SKILL_DIRS) {
|
||||
const p = path.join(hermesRoot, name);
|
||||
if (fs.existsSync(p)) {
|
||||
if (!opts.dryRun) { try { fs.rmSync(p, { recursive: true, force: true }); } catch (_) {} }
|
||||
note(` removed ${p}`);
|
||||
prunedHermes = true;
|
||||
}
|
||||
}
|
||||
if (prunedHermes) ok(' pruned caveman skills from Hermes');
|
||||
}
|
||||
|
||||
// Flag + per-session state files. `.caveman-active` is the live mode flag;
|
||||
// the rest are cumulative state the mode-tracker/stats/activate hooks write
|
||||
// (issue #635 — uninstall only ever cleaned the flag, leaving these behind
|
||||
// forever). `.caveman-history.jsonl` is the user's lifetime savings ledger —
|
||||
// deliberately KEPT, not stale state; note it so the user knows it's there.
|
||||
const STATE_FILES_TO_REMOVE = [
|
||||
'.caveman-active',
|
||||
'.caveman-active.prev',
|
||||
'.caveman-mode-log.jsonl',
|
||||
'.caveman-statusline-suffix',
|
||||
'.caveman-nudge-shown',
|
||||
];
|
||||
for (const f of STATE_FILES_TO_REMOVE) {
|
||||
const p = path.join(configDir, f);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
if (opts.dryRun) {
|
||||
note(` would remove ${p}`);
|
||||
} else {
|
||||
try { fs.unlinkSync(p); } catch (_) {}
|
||||
note(` removed ${p}`);
|
||||
}
|
||||
}
|
||||
const historyPath = path.join(configDir, '.caveman-history.jsonl');
|
||||
if (fs.existsSync(historyPath)) {
|
||||
note(` kept ${historyPath} (lifetime history — delete manually if unwanted)`);
|
||||
}
|
||||
|
||||
process.stdout.write('\n');
|
||||
ok('uninstall done.');
|
||||
@@ -1048,8 +1414,9 @@ function printList(noColor) {
|
||||
process.stdout.write(` ${pad(p.id, 13)} ${pad(p.label, 22)} ${p.mech}${tag}\n`);
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
process.stdout.write(c.dim(' Defaults: --with-hooks ON, --with-mcp-shrink ON, --with-init OFF.\n'));
|
||||
process.stdout.write(c.dim(' --all turns all three on, --minimal turns all three off.\n'));
|
||||
process.stdout.write(c.dim(' Defaults: --with-hooks ON, --with-init OFF, --with-mcp-shrink OFF.\n'));
|
||||
process.stdout.write(c.dim(' --all = hooks + init (mcp-shrink needs an upstream — opt in explicitly).\n'));
|
||||
process.stdout.write(c.dim(' --minimal turns hooks + init + mcp-shrink off.\n'));
|
||||
}
|
||||
|
||||
function pad(s, n) { s = String(s); return s + ' '.repeat(Math.max(0, n - s.length)); }
|
||||
@@ -1060,7 +1427,7 @@ function printHelp() {
|
||||
|
||||
USAGE
|
||||
npx -y github:JuliusBrussee/caveman -- [flags]
|
||||
node bin/install.js [flags]
|
||||
node cli/install.js [flags]
|
||||
bash install.sh [flags] # shim → npx
|
||||
pwsh install.ps1 [flags] # shim → npx
|
||||
|
||||
@@ -1070,14 +1437,23 @@ FLAGS
|
||||
--only <agent> Install only for the named agent. Repeatable.
|
||||
See --list for valid ids.
|
||||
--skip-skills Don't run the npx-skills auto-detect fallback.
|
||||
--all Turn on hooks + init + mcp-shrink.
|
||||
--all Turn on hooks + init. (mcp-shrink needs an upstream;
|
||||
pass --with-mcp-shrink="<cmd>" to add it.)
|
||||
--minimal Just the plugin/extension install.
|
||||
--with-hooks Claude Code: install SessionStart/UserPromptSubmit hooks
|
||||
+ statusline badge. (Default ON.)
|
||||
--no-hooks Skip the hooks installer.
|
||||
--with-init Write per-repo IDE rule files into \$PWD.
|
||||
--with-mcp-shrink Claude Code: register caveman-shrink MCP proxy. (Default ON.)
|
||||
--no-mcp-shrink Skip MCP shrink.
|
||||
--no-always OpenClaw only: skip \`always: true\` frontmatter and the
|
||||
SOUL.md bootstrap append — skill loads on demand instead
|
||||
of always-on. (Default: always-on.)
|
||||
--with-mcp-shrink="<upstream cmd>"
|
||||
Claude Code (and opencode): register caveman-shrink MCP
|
||||
proxy wrapping the given upstream. Default OFF.
|
||||
caveman-shrink crashes without an upstream, so a value
|
||||
is required. The value is whitespace-tokenized.
|
||||
Example: --with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /tmp"
|
||||
--no-mcp-shrink Skip MCP shrink. (Default.)
|
||||
--uninstall, -u Remove caveman from this machine.
|
||||
--config-dir <path> Claude Code config dir for hook files + settings.json.
|
||||
Default: \$CLAUDE_CONFIG_DIR or ~/.claude. Does NOT
|
||||
@@ -1157,14 +1533,17 @@ async function main() {
|
||||
if (prov.id === 'gemini') { installGemini(ctx); continue; }
|
||||
if (prov.id === 'opencode') { installOpencode(ctx); continue; }
|
||||
if (prov.id === 'openclaw') { installOpenclaw(ctx); continue; }
|
||||
if (prov.id === 'hermes') { installHermes(ctx); continue; }
|
||||
if (prov.profile) { installViaSkills(ctx, prov); continue; }
|
||||
}
|
||||
|
||||
// Auto-detect fallback if nothing matched
|
||||
if (!opts.skipSkills && opts.only.length === 0 && ctx.results.detected === 0) {
|
||||
ctx.say('→ no known agents detected — running npx-skills auto-detect fallback');
|
||||
const r = runSpawn('npx', ['-y', 'skills', 'add', REPO], null, opts.dryRun);
|
||||
if ((r.status || 0) === 0) ctx.results.installed.push('skills-auto');
|
||||
// --yes --all for the same reason as installViaSkills above (issue #370):
|
||||
// skip the interactive skill picker so curl|bash actually installs.
|
||||
const r = runSpawn('npx', ['-y', 'skills', 'add', REPO, '--yes', '--all'], null, opts.dryRun);
|
||||
if (spawnOk(r)) ctx.results.installed.push('skills-auto');
|
||||
else ctx.results.failed.push(['skills-auto', 'npx skills add (auto) failed']);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
@@ -1201,6 +1580,8 @@ async function main() {
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
ctx.note(" start any session and say 'caveman mode', or run /caveman in Claude Code");
|
||||
ctx.note(' measure what caveman save you: run /caveman-stats (numbers are estimates)');
|
||||
ctx.note(' verified team savings coming soon — join waitlist: https://caveman.so');
|
||||
ctx.note(` uninstall: npx -y github:${REPO} -- --uninstall`);
|
||||
|
||||
// Exit code: nonzero only if every detected agent failed
|
||||
@@ -1208,5 +1589,12 @@ async function main() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
main().then(code => process.exit(code || 0))
|
||||
.catch(err => { process.stderr.write((err && err.stack || String(err)) + '\n'); process.exit(1); });
|
||||
// Guard so `require()`-ing this file for unit tests (see tests/installer/)
|
||||
// doesn't also run the installer as a side effect — only run main() when
|
||||
// invoked directly as a script (bin entry, `node cli/install.js`, npx).
|
||||
if (require.main === module) {
|
||||
main().then(code => process.exit(code || 0))
|
||||
.catch(err => { process.stderr.write((err && err.stack || String(err)) + '\n'); process.exit(1); });
|
||||
}
|
||||
|
||||
module.exports = { winQuoteIfNeeded, OPENCLAW_SKILL_VERSION };
|
||||
@@ -67,12 +67,20 @@ function frontmatterHasKey(fm, key) {
|
||||
return re.test(fm);
|
||||
}
|
||||
|
||||
function mergeOpenclawFrontmatter(src) {
|
||||
// `opts.version` defaults to SKILL_VERSION (the '1.0.0' fallback) when the
|
||||
// caller doesn't have a better one on hand — cli/install.js threads through
|
||||
// PINNED_REF (its release-tag source of truth) instead so the two never
|
||||
// drift. `opts.always` defaults to true (existing behavior); pass `false`
|
||||
// (from --no-always) to omit the `always: true` key entirely — the skill
|
||||
// then loads on demand instead of always-on.
|
||||
function mergeOpenclawFrontmatter(src, opts = {}) {
|
||||
const version = opts.version || SKILL_VERSION;
|
||||
const always = opts.always !== false;
|
||||
const { frontmatter, body } = splitFrontmatter(src);
|
||||
const additions = [];
|
||||
if (!frontmatterHasKey(frontmatter, 'name')) additions.push(`name: ${SKILL_NAME}`);
|
||||
if (!frontmatterHasKey(frontmatter, 'version')) additions.push(`version: ${SKILL_VERSION}`);
|
||||
if (!frontmatterHasKey(frontmatter, 'always')) additions.push('always: true');
|
||||
if (!frontmatterHasKey(frontmatter, 'version')) additions.push(`version: ${version}`);
|
||||
if (always && !frontmatterHasKey(frontmatter, 'always')) additions.push('always: true');
|
||||
if (additions.length === 0 && frontmatter) return src;
|
||||
const fmBody = (frontmatter ? frontmatter.trimEnd() + '\n' : '') + additions.join('\n') + (additions.length ? '\n' : '');
|
||||
return '---\n' + fmBody + '---\n' + body;
|
||||
@@ -116,33 +124,76 @@ function loadSkillBody(repoRoot) {
|
||||
}
|
||||
|
||||
// ── SOUL.md marker-block append/strip ─────────────────────────────────────
|
||||
//
|
||||
// Damage tolerance (#596): a stray or truncated marker (interrupted write,
|
||||
// partial user edit) used to chain into data loss — append saw "no complete
|
||||
// block" and added a SECOND block; strip then cut from the FIRST begin to the
|
||||
// FIRST end, which spanned all user content between the stray marker and the
|
||||
// appended block. The scan below pairs each begin with the nearest end BEFORE
|
||||
// the next begin; an unpaired marker is removed as just the marker itself,
|
||||
// never as a span over user content.
|
||||
|
||||
function stripAllBootstrapBlocks(text) {
|
||||
let result = '';
|
||||
let found = false;
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const b = text.indexOf(MARK_BEGIN, i);
|
||||
if (b === -1) { result += text.slice(i); break; }
|
||||
result += text.slice(i, b);
|
||||
found = true;
|
||||
const nextB = text.indexOf(MARK_BEGIN, b + MARK_BEGIN.length);
|
||||
const e = text.indexOf(MARK_END, b + MARK_BEGIN.length);
|
||||
if (e !== -1 && (nextB === -1 || e < nextB)) {
|
||||
i = e + MARK_END.length; // well-formed block — drop begin..end inclusive
|
||||
} else {
|
||||
i = b + MARK_BEGIN.length; // orphan begin — drop only the marker itself
|
||||
}
|
||||
// Collapse the blank-line scar around the cut (same cosmetic rule the
|
||||
// old single-cut code applied): keep at most one newline on each side.
|
||||
result = result.replace(/\n+$/, '\n');
|
||||
const lead = /^\n+/.exec(text.slice(i));
|
||||
if (lead) i += lead[0].length - (result ? 1 : 0);
|
||||
}
|
||||
// Orphan end markers (begin already gone or never written) — drop marker only.
|
||||
while (result.includes(MARK_END)) { found = true; result = result.replace(MARK_END, ''); }
|
||||
return { next: result, found };
|
||||
}
|
||||
|
||||
function appendBootstrapToSoul(soulPath, snippet) {
|
||||
const existing = readIfExists(soulPath);
|
||||
if (existing && existing.includes(MARK_BEGIN) && existing.includes(MARK_END)) {
|
||||
return { changed: false, reason: 'already present' };
|
||||
const count = (s, sub) => s.split(sub).length - 1;
|
||||
let base = existing;
|
||||
let repaired = false;
|
||||
if (existing) {
|
||||
const nb = count(existing, MARK_BEGIN);
|
||||
const ne = count(existing, MARK_END);
|
||||
if (nb === 1 && ne === 1 && existing.indexOf(MARK_END) > existing.indexOf(MARK_BEGIN)) {
|
||||
return { changed: false, reason: 'already present' };
|
||||
}
|
||||
if (nb > 0 || ne > 0) {
|
||||
// Damaged markers — strip them safely first, then append one clean block.
|
||||
base = stripAllBootstrapBlocks(existing).next;
|
||||
repaired = true;
|
||||
}
|
||||
}
|
||||
let next;
|
||||
if (existing && existing.length) {
|
||||
const sep = existing.endsWith('\n\n') ? '' : (existing.endsWith('\n') ? '\n' : '\n\n');
|
||||
next = existing + sep + snippet;
|
||||
if (base && base.length) {
|
||||
const sep = base.endsWith('\n\n') ? '' : (base.endsWith('\n') ? '\n' : '\n\n');
|
||||
next = base + sep + snippet;
|
||||
} else {
|
||||
next = snippet;
|
||||
}
|
||||
fs.writeFileSync(soulPath, next, { mode: 0o644 });
|
||||
return { changed: true };
|
||||
return repaired ? { changed: true, repaired: true } : { changed: true };
|
||||
}
|
||||
|
||||
function stripBootstrapFromSoul(soulPath) {
|
||||
const existing = readIfExists(soulPath);
|
||||
if (!existing) return { changed: false, reason: 'no SOUL.md' };
|
||||
const begin = existing.indexOf(MARK_BEGIN);
|
||||
const end = existing.indexOf(MARK_END);
|
||||
if (begin === -1 || end === -1 || end <= begin) return { changed: false, reason: 'no marker block' };
|
||||
const before = existing.slice(0, begin);
|
||||
const after = existing.slice(end + MARK_END.length);
|
||||
// Collapse adjacent blank lines around the cut so we don't leave a triple
|
||||
// newline scar from `\n\n<begin>...\n<end>\n\n`.
|
||||
let next = (before.replace(/\n+$/, '\n') + after.replace(/^\n+/, '\n')).trimEnd();
|
||||
const { next: stripped, found } = stripAllBootstrapBlocks(existing);
|
||||
if (!found) return { changed: false, reason: 'no marker block' };
|
||||
let next = stripped.trimEnd();
|
||||
next = next ? next + '\n' : '';
|
||||
if (next === '') {
|
||||
// SOUL.md only contained our block — remove the file so OpenClaw doesn't
|
||||
@@ -155,7 +206,12 @@ function stripBootstrapFromSoul(soulPath) {
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
function installOpenclaw({ workspace, repoRoot, dryRun = false, force = false, log = noopLog() } = {}) {
|
||||
// `version` — bare semver stamped into the skill frontmatter; defaults to
|
||||
// SKILL_VERSION if the caller doesn't pass one (see mergeOpenclawFrontmatter).
|
||||
// `always` — default true (existing behavior). Pass false (--no-always) to
|
||||
// skip the `always: true` frontmatter key AND the SOUL.md bootstrap append,
|
||||
// so the skill installs load-on-demand instead of always-on.
|
||||
function installOpenclaw({ workspace, repoRoot, dryRun = false, force = false, log = noopLog(), version, always = true } = {}) {
|
||||
const ws = workspace || resolveWorkspace();
|
||||
const skillBody = loadSkillBody(repoRoot);
|
||||
if (!skillBody) {
|
||||
@@ -179,19 +235,27 @@ function installOpenclaw({ workspace, repoRoot, dryRun = false, force = false, l
|
||||
const soulFile = path.join(ws, SOUL_FILE);
|
||||
|
||||
if (dryRun) {
|
||||
log.note(` would write ${skillFile} (with version/always frontmatter)`);
|
||||
log.note(` would ${fs.existsSync(soulFile) ? 'append to' : 'create'} ${soulFile} (caveman bootstrap block)`);
|
||||
log.note(` would write ${skillFile} (with version${always ? '/always' : ''} frontmatter)`);
|
||||
if (always) {
|
||||
log.note(` would ${fs.existsSync(soulFile) ? 'append to' : 'create'} ${soulFile} (caveman bootstrap block)`);
|
||||
} else {
|
||||
log.note(' --no-always: would skip SOUL.md bootstrap append (skill loads on demand)');
|
||||
}
|
||||
return { ok: true, dryRun: true };
|
||||
}
|
||||
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
const merged = mergeOpenclawFrontmatter(skillBody);
|
||||
const merged = mergeOpenclawFrontmatter(skillBody, { version, always });
|
||||
fs.writeFileSync(skillFile, merged, { mode: 0o644 });
|
||||
log.write(` installed: ${skillFile}\n`);
|
||||
|
||||
const soul = appendBootstrapToSoul(soulFile, snippet);
|
||||
if (soul.changed) log.write(` wrote bootstrap block to ${soulFile}\n`);
|
||||
else log.note(` ${soulFile} already contains caveman bootstrap`);
|
||||
if (always) {
|
||||
const soul = appendBootstrapToSoul(soulFile, snippet);
|
||||
if (soul.changed) log.write(` wrote bootstrap block to ${soulFile}\n`);
|
||||
else log.note(` ${soulFile} already contains caveman bootstrap`);
|
||||
} else {
|
||||
log.note(' --no-always: skipped SOUL.md bootstrap append (skill loads on demand via `openclaw skills list`)');
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
// Strip the `tools:` field from a Claude-Code-style subagent frontmatter so
|
||||
// the file is valid for opencode, whose schema rejects the YAML array form
|
||||
// (`tools: [Read, Grep, Bash]`) with:
|
||||
//
|
||||
// Configuration is invalid at .../agents/cavecrew-reviewer.md
|
||||
// ↳ Expected object | undefined, got ["Read","Grep","Bash"] tools
|
||||
//
|
||||
// opencode allows `tools` to be a map (`{read: true, grep: true}`) or
|
||||
// omitted entirely. Omitting falls back to opencode's default tool set,
|
||||
// which is what the cavecrew subagent prompts already self-restrict against
|
||||
// in their body ("Read-only locator", "No `Bash` available", etc.), so
|
||||
// dropping the array form is safe.
|
||||
|
||||
const TOOLS_FIELD_RE = /^tools[ \t]*:/;
|
||||
const CONTINUATION_RE = /^[ \t]/;
|
||||
const FRONTMATTER_FENCE = '---\n';
|
||||
|
||||
function stripOpencodeAgentTools(content) {
|
||||
if (typeof content !== 'string' || !content.startsWith(FRONTMATTER_FENCE)) return content;
|
||||
const fmEnd = content.indexOf('\n---', FRONTMATTER_FENCE.length);
|
||||
if (fmEnd < 0) return content;
|
||||
|
||||
const fm = content.slice(FRONTMATTER_FENCE.length, fmEnd);
|
||||
const rest = content.slice(fmEnd);
|
||||
|
||||
const out = [];
|
||||
let dropping = false;
|
||||
for (const line of fm.split('\n')) {
|
||||
if (dropping) {
|
||||
if (CONTINUATION_RE.test(line)) continue;
|
||||
dropping = false;
|
||||
}
|
||||
if (TOOLS_FIELD_RE.test(line)) { dropping = true; continue; }
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return FRONTMATTER_FENCE + out.join('\n') + rest;
|
||||
}
|
||||
|
||||
module.exports = { stripOpencodeAgentTools };
|
||||
@@ -1,7 +1,7 @@
|
||||
// caveman — JSONC-tolerant settings.json read/write + defensive hook validation.
|
||||
//
|
||||
// Lifted in spirit from gsd-build/get-shit-done's stripJsonComments + readSettings.
|
||||
// Reused by bin/install.js and (optionally) by hooks/caveman-activate.js so a
|
||||
// Reused by cli/install.js and (optionally) by hooks/caveman-activate.js so a
|
||||
// commented settings.json no longer crashes the installer or the runtime hooks.
|
||||
//
|
||||
// Public API:
|
||||
@@ -57,9 +57,38 @@ function stripJsonComments(src) {
|
||||
if (c === '/' && next === '*') { inBlock = true; i += 2; continue; }
|
||||
out += c; i++;
|
||||
}
|
||||
// Trailing-comma sweep — only outside strings, but stripping happened above
|
||||
// so a regex over the comment-free output is safe.
|
||||
return out.replace(/,(\s*[}\]])/g, '$1');
|
||||
return stripTrailingCommas(out);
|
||||
}
|
||||
|
||||
// ── stripTrailingCommas ────────────────────────────────────────────────────
|
||||
// Remove `,` when the next non-whitespace char is `}` or `]` — but only
|
||||
// OUTSIDE strings. The old global regex ran over string contents too and
|
||||
// silently corrupted values like `"echo ,}"` → `"echo }"` (issue #595);
|
||||
// comment-stripping does not sanitize string bodies, so a string-aware scan
|
||||
// is required here as well.
|
||||
function stripTrailingCommas(src) {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
let inString = false;
|
||||
let stringChar = '';
|
||||
while (i < n) {
|
||||
const c = src[i];
|
||||
if (inString) {
|
||||
out += c;
|
||||
if (c === '\\') { if (i + 1 < n) { out += src[i + 1]; i += 2; continue; } }
|
||||
if (c === stringChar) inString = false;
|
||||
i++; continue;
|
||||
}
|
||||
if (c === '"' || c === "'") { inString = true; stringChar = c; out += c; i++; continue; }
|
||||
if (c === ',') {
|
||||
let j = i + 1;
|
||||
while (j < n && /\s/.test(src[j])) j++;
|
||||
if (j < n && (src[j] === '}' || src[j] === ']')) { i++; continue; } // drop the comma
|
||||
}
|
||||
out += c; i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── readSettings ───────────────────────────────────────────────────────────
|
||||
@@ -149,12 +178,52 @@ function addCommandHook(settings, event, opts) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Managed hook scripts ──────────────────────────────────────────────────
|
||||
// The exact script basenames this installer wires into settings.json. Every
|
||||
// helper that decides "is this hook ours?" must match against these — never
|
||||
// against a bare "caveman" substring, which also matches user-authored hooks
|
||||
// that merely mention the word in a path (issue #593).
|
||||
const MANAGED_HOOK_BASENAMES = new Set([
|
||||
'caveman-activate.js',
|
||||
'caveman-mode-tracker.js',
|
||||
'caveman-stats.js',
|
||||
'caveman-statusline.sh',
|
||||
'caveman-statusline.ps1',
|
||||
]);
|
||||
|
||||
// Split a command into shell-ish tokens, honoring single/double quotes so a
|
||||
// path containing spaces survives intact. Good enough for hook commands we
|
||||
// generate (`node "/a/x.js"`, `"/abs/node" "/a/x.js"`, `bash /a/x.sh`); not
|
||||
// a full shell parser.
|
||||
function tokenizeCommand(command) {
|
||||
const out = [];
|
||||
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(command)) !== null) out.push(m[1] ?? m[2] ?? m[3]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// True iff some token's BASENAME exactly equals a managed script name. Exact
|
||||
// match — not substring — so `mycaveman-activate.js` or a user hook living
|
||||
// under a `caveman-notes/` directory is never treated as ours. win32.basename
|
||||
// splits on both / and \ so a settings.json written on Windows still matches
|
||||
// when processed elsewhere.
|
||||
function referencesManagedScript(command) {
|
||||
try {
|
||||
for (const tok of tokenizeCommand(command)) {
|
||||
if (tok && typeof tok === 'string' && MANAGED_HOOK_BASENAMES.has(path.win32.basename(tok))) return true;
|
||||
}
|
||||
} catch (_) { /* malformed command — treat as not ours */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── removeCavemanHooks ────────────────────────────────────────────────────
|
||||
// Strip every entry whose any hook command mentions `marker`. Empties events.
|
||||
// Tolerates malformed pre-existing settings (non-array hook lists, foreign
|
||||
// shapes) — those get dropped by validateHookFields first so we never call
|
||||
// .length / .filter on a non-array.
|
||||
function removeCavemanHooks(settings, marker = 'caveman') {
|
||||
// Strip every entry whose any hook command targets one of our managed hook
|
||||
// scripts (exact basename match, see above). Empties events. Tolerates
|
||||
// malformed pre-existing settings (non-array hook lists, foreign shapes) —
|
||||
// those get dropped by validateHookFields first so we never call .length /
|
||||
// .filter on a non-array.
|
||||
function removeCavemanHooks(settings) {
|
||||
if (!settings || !settings.hooks) return 0;
|
||||
validateHookFields(settings);
|
||||
if (!settings.hooks) return 0; // validate may have deleted the whole tree
|
||||
@@ -164,7 +233,7 @@ function removeCavemanHooks(settings, marker = 'caveman') {
|
||||
const before = settings.hooks[ev].length;
|
||||
settings.hooks[ev] = settings.hooks[ev].filter(entry => {
|
||||
if (!entry || !Array.isArray(entry.hooks)) return true;
|
||||
return !entry.hooks.some(h => h && typeof h.command === 'string' && h.command.includes(marker));
|
||||
return !entry.hooks.some(h => h && typeof h.command === 'string' && referencesManagedScript(h.command));
|
||||
});
|
||||
removed += before - settings.hooks[ev].length;
|
||||
if (settings.hooks[ev].length === 0) delete settings.hooks[ev];
|
||||
@@ -179,17 +248,18 @@ function removeCavemanHooks(settings, marker = 'caveman') {
|
||||
// `absoluteNode` so GUI launchers with minimal PATH still find Node. Only
|
||||
// touches commands matching the exact bare-node shape — won't false-positive
|
||||
// on user-authored hooks that just happen to mention "caveman".
|
||||
const MANAGED_HOOK_BASENAMES = new Set([
|
||||
'caveman-activate.js',
|
||||
'caveman-mode-tracker.js',
|
||||
'caveman-stats.js',
|
||||
'caveman-statusline.sh',
|
||||
]);
|
||||
function rewriteLegacyManagedHookCommands(settings, absoluteNode) {
|
||||
if (!settings || !settings.hooks || !absoluteNode) return 0;
|
||||
let rewritten = 0;
|
||||
const reBare = /^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/;
|
||||
for (const ev of Object.keys(settings.hooks)) {
|
||||
// A hook event value that is an object/string (not an array) survives
|
||||
// JSONC parse untouched — this runs BEFORE validateHookFields in
|
||||
// installHooks, so it must tolerate malformed input itself rather than
|
||||
// assume the array shape. Pre-fix, `for...of` on a non-iterable object
|
||||
// threw a TypeError here and killed the installer mid-run (mirrors the
|
||||
// guard removeCavemanHooks already has).
|
||||
if (!Array.isArray(settings.hooks[ev])) continue;
|
||||
for (const entry of settings.hooks[ev]) {
|
||||
if (!entry || !Array.isArray(entry.hooks)) continue;
|
||||
for (const h of entry.hooks) {
|
||||
@@ -207,6 +277,76 @@ function rewriteLegacyManagedHookCommands(settings, absoluteNode) {
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
// ── pruneOrphanedManagedHooks ─────────────────────────────────────────────
|
||||
// Remove managed hook entries whose target script no longer exists on disk.
|
||||
//
|
||||
// Migrating an old manual install (settings.json hooks → ~/.claude/hooks/
|
||||
// caveman-*.js) to the Claude Code plugin disables/renames those local
|
||||
// scripts but leaves the settings.json entries pointing at the now-missing
|
||||
// file. Claude Code then runs `node <missing>` every SessionStart /
|
||||
// UserPromptSubmit and crashes with `node:…/loader:1478 — Cannot find module
|
||||
// …caveman-activate.js` (issue #471). rewriteLegacyManagedHookCommands can't
|
||||
// help — it only matches the bare-node shape and these orphans are usually
|
||||
// absolute-node — and removeCavemanHooks runs only on uninstall.
|
||||
//
|
||||
// We extract the script path from any managed-looking command (bare- or
|
||||
// absolute-node, quoted or not), resolve it relative to dir if not absolute,
|
||||
// and drop the hook only when its target is genuinely absent. A managed hook
|
||||
// whose script still exists is left untouched, so this is safe to run on
|
||||
// every install.
|
||||
function pruneOrphanedManagedHooks(settings, configDir) {
|
||||
if (!settings || typeof settings !== 'object') return 0;
|
||||
const baseDir = configDir || claudeConfigDir();
|
||||
let removed = 0;
|
||||
|
||||
// A command is a missing managed target iff some token's BASENAME exactly
|
||||
// equals a managed script (exact match — not substring — so a user hook like
|
||||
// `mycaveman-activate.js` is never touched) and that resolved path is absent.
|
||||
// Relative paths resolve against configDir; honors CLAUDE_CONFIG_DIR. Wrapped
|
||||
// so a malformed command or fs error never throws out of the prune pass.
|
||||
const targetMissing = (command) => {
|
||||
try {
|
||||
for (const tok of tokenizeCommand(command)) {
|
||||
if (!tok || typeof tok !== 'string') continue;
|
||||
if (!MANAGED_HOOK_BASENAMES.has(path.basename(tok))) continue;
|
||||
const scriptPath = path.isAbsolute(tok) ? tok : path.join(baseDir, tok);
|
||||
return !fs.existsSync(scriptPath);
|
||||
}
|
||||
} catch (_) { /* silent-fail: never block install on a parse/fs hiccup */ }
|
||||
return false;
|
||||
};
|
||||
|
||||
if (settings.hooks && typeof settings.hooks === 'object') {
|
||||
// Normalize malformed shapes first so the filter below only sees valid
|
||||
// entries (and a poisoned settings.json can't survive the rewrite).
|
||||
validateHookFields(settings);
|
||||
}
|
||||
if (settings.hooks && typeof settings.hooks === 'object') {
|
||||
for (const ev of Object.keys(settings.hooks)) {
|
||||
if (!Array.isArray(settings.hooks[ev])) { delete settings.hooks[ev]; continue; }
|
||||
const before = settings.hooks[ev].length;
|
||||
settings.hooks[ev] = settings.hooks[ev].filter(entry => {
|
||||
if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) return true;
|
||||
return !entry.hooks.some(h => h && typeof h.command === 'string' && targetMissing(h.command));
|
||||
});
|
||||
removed += before - settings.hooks[ev].length;
|
||||
if (settings.hooks[ev].length === 0) delete settings.hooks[ev];
|
||||
}
|
||||
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
||||
}
|
||||
|
||||
// statusLine lives outside settings.hooks. A managed statusline command
|
||||
// pointing at a missing script leaves a blank statusline (cosmetic, exits
|
||||
// clean) but is still stale — drop it so Claude Code falls back to default.
|
||||
if (settings.statusLine && typeof settings.statusLine.command === 'string'
|
||||
&& targetMissing(settings.statusLine.command)) {
|
||||
delete settings.statusLine;
|
||||
removed++;
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ── claudeConfigDir ───────────────────────────────────────────────────────
|
||||
function claudeConfigDir() {
|
||||
if (process.env.CLAUDE_CONFIG_DIR) return process.env.CLAUDE_CONFIG_DIR;
|
||||
@@ -222,6 +362,7 @@ module.exports = {
|
||||
addCommandHook,
|
||||
removeCavemanHooks,
|
||||
rewriteLegacyManagedHookCommands,
|
||||
pruneOrphanedManagedHooks,
|
||||
claudeConfigDir,
|
||||
MANAGED_HOOK_BASENAMES,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: Generate terse caveman-style commit message
|
||||
---
|
||||
|
||||
Generate a terse commit message for the current staged changes. Conventional Commits format. Subject: ≤50 chars, imperative, lowercase after type. Body: only when 'why' isn't obvious from subject. Why over what. No period on subject.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Drop the always-on caveman activation rule into the current repo for every IDE agent
|
||||
argument-hint: "[--dry-run|--force] [--only <agent>]"
|
||||
---
|
||||
|
||||
Write the per-repo caveman rule files (Cursor, Windsurf, Cline, Copilot, AGENTS.md) into the current repo, then report the result.
|
||||
|
||||
How to run the init script — pick the first that applies:
|
||||
|
||||
1. If `src/tools/caveman-init.js` exists in the current repo (you are inside a caveman checkout), run: `node src/tools/caveman-init.js $ARGUMENTS`
|
||||
2. Otherwise download and run the standalone script (it is self-contained and supports stdin execution): `curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/tools/caveman-init.js | node - $ARGUMENTS`
|
||||
|
||||
Use `--dry-run` first if the user did not pass `--force`, so we never silently overwrite an existing rule file.
|
||||
@@ -1,4 +1,2 @@
|
||||
---
|
||||
description = "Drop the always-on caveman activation rule into the current repo for every IDE agent"
|
||||
prompt = "Run `node src/tools/caveman-init.js {{args}}` in the current repo and report the result. Use --dry-run first if the user did not pass --force, so we never silently overwrite an existing rule file."
|
||||
---
|
||||
prompt = "Write the per-repo caveman rule files into the current repo and report the result. If `src/tools/caveman-init.js` exists in the current repo (a caveman checkout), run `node src/tools/caveman-init.js {{args}}`. Otherwise run the standalone script (self-contained, supports stdin execution): `curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/tools/caveman-init.js | node - {{args}}`. Use --dry-run first if the user did not pass --force, so we never silently overwrite an existing rule file."
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: One-line code review comments
|
||||
---
|
||||
|
||||
Review the current code changes. One-line per finding. Format: L<line>: <severity> <problem>. <fix>. Severity: bug, risk, nit, q. Skip praise. Skip obvious. If code look good, say 'LGTM' and stop.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Real session token usage + lifetime savings + USD. Tweetable line via --share.
|
||||
argument-hint: "[--share|--all|--since 7d]"
|
||||
---
|
||||
|
||||
/caveman-stats $ARGUMENTS
|
||||
@@ -0,0 +1,2 @@
|
||||
description = "Real session token usage + lifetime savings + USD. Tweetable line via --share."
|
||||
prompt = "/caveman-stats {{args}}"
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Switch caveman intensity level (lite/full/ultra/wenyan)
|
||||
argument-hint: "[lite|full|ultra|wenyan]"
|
||||
---
|
||||
|
||||
Switch to caveman $ARGUMENTS mode. If no level specified, use full. Respond terse like smart caveman — drop articles, filler, pleasantries. Fragments OK. Technical terms exact. Code unchanged. Pattern: [thing] [action] [reason]. [next step].
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,47 @@
|
||||
# Honest Numbers
|
||||
|
||||
Caveman save tokens sometimes. Caveman cost tokens sometimes. This page say which is which, with the real numbers. No marketing. If caveman lose for your workload, this page tell you to turn it off.
|
||||
|
||||
## What caveman actually does
|
||||
|
||||
Caveman is a system-prompt skill. It makes the model **write shorter output**. That is the whole mechanism. It does not compress your input, your context, your files, or the model's thinking tokens.
|
||||
|
||||
## The measured numbers
|
||||
|
||||
| What | Number | How measured | Source |
|
||||
|---|---|---|---|
|
||||
| Output reduction vs default verbose replies | **65% average** (range 22–87%) | Real Claude API token counts, 10 prompts | [`benchmarks/`](../benchmarks/) |
|
||||
| Input reduction from the skill | **0%** | It's an output-style instruction | — |
|
||||
| Input cost the skill *adds* | **~1–1.5k tokens per turn** | SKILL.md rules (~5 KB) injected into context, plus skill-list entries | [`skills/caveman/SKILL.md`](../skills/caveman/SKILL.md) |
|
||||
| `/caveman-compress` on memory files | ~46% average input reduction, per session, for those files only | Real files, token counts in README table | [README](../README.md#benchmarks) |
|
||||
|
||||
These figures are output tokens only — the skill does not compress your input, your context, your files, or the model's thinking tokens. The full eval harness and its correction history are documented in [`evals/README.md`](../evals/README.md).
|
||||
|
||||
## When caveman wins
|
||||
|
||||
- **Long chatty outputs.** Explanations, architecture discussions, code review, docs, debugging walkthroughs — anywhere the model would write 1k+ output tokens per reply. This is where the 50–87% cuts happen.
|
||||
- **Long sessions with verbose agents.** The per-reply savings compound; the fixed ~1–1.5k/turn rule cost stays flat.
|
||||
- **Reading speed.** Shorter replies finish sooner and you read them faster. For many users this, not cost, is the real win.
|
||||
|
||||
## When caveman loses (net-negative)
|
||||
|
||||
Plainly: **the skill costs ~1–1.5k input tokens every turn. If it saves less output than that, you are paying to use it.**
|
||||
|
||||
- **Terse coding Q&A** ([#145](https://github.com/JuliusBrussee/caveman/issues/145)). If your normal replies are ~150 output tokens, caveman saves maybe 70–100 of them and costs ~1k+ of input overhead per turn. Net loss. The user in #145 measured exactly this. They were right.
|
||||
- **Agents that bill by request or credit, not tokens** ([#506](https://github.com/JuliusBrussee/caveman/issues/506)). GitHub Copilot charges premium *requests*. A shorter answer is the same request. Caveman cannot lower your Copilot credit use. Same logic for any per-message pricing.
|
||||
- **Session-level totals** are always smaller than the output-reduction headline, because input tokens (your prompts, your context, your files, the injected rules) dwarf output tokens in agentic coding. Independent session-level measurements land around **14–21% total savings** on output-heavy workloads — and below zero on terse ones.
|
||||
- **Some tool-side counters go the wrong way** ([#550](https://github.com/JuliusBrussee/caveman/issues/550)). One Cursor A/B showed 4.3M tokens with caveman vs 1M without, and double the wall-clock time. We could not reproduce the exact run, but the honest reading is: rule re-injection, retries, and cache/context accounting can swamp output savings in some agents. If your A/B looks like that, caveman is net-negative for you. Turn it off. Wanting the rock to work does not make the rock work.
|
||||
|
||||
## Measure it yourself
|
||||
|
||||
1. **`/caveman-stats`** (Claude Code) reads your real session log and prints actual input/output token counts. The "saved" line is an **estimate**: it extrapolates what the output would have been without caveman using the benchmark ratio. Real usage, estimated baseline — the output labels it `est.` for exactly that reason.
|
||||
2. **The only fully honest test is an A/B**: run the same task with and without caveman and compare your provider's own usage/billing page. That number outranks anything this repo prints.
|
||||
3. **Reproduce our numbers**: `benchmarks/run.py` (needs an Anthropic key) and `evals/measure.py` (offline, reads the committed snapshot).
|
||||
|
||||
## Rule of thumb
|
||||
|
||||
> Normal reply longer than ~1.5–2k output tokens → caveman probably saves you money.
|
||||
> Normal reply shorter than that, or you pay per request → caveman probably costs you money.
|
||||
> Either way, caveman replies faster to read. That part is free.
|
||||
|
||||
Found a workload where our numbers are wrong? [Open an issue](https://github.com/JuliusBrussee/caveman/issues) with the A/B. We will put it on this page.
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg width="163" height="26" viewBox="0 0 163 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.9475 25.7973C30.9995 25.7973 29.4793 25.2568 28.3984 24.1871C27.3174 23.1174 26.7769 21.6085 26.7769 19.6492V12.0148H23.8154V8.28766H24.1307C24.9752 8.28766 25.6283 8.06245 26.09 7.6233C26.5404 7.17289 26.7769 6.53106 26.7769 5.68654V4.34657H30.9769V8.28766H34.9518V12.0148H30.9769V19.4353C30.9769 20.0096 31.0783 20.4938 31.281 20.8991C31.4837 21.3045 31.7989 21.6085 32.2381 21.8225C32.6772 22.0364 33.229 22.1378 33.9046 22.1378C34.051 22.1378 34.2312 22.1378 34.4338 22.104C34.6365 22.0814 34.828 22.0589 35.0194 22.0364V25.6059C34.7266 25.6509 34.3775 25.696 34.006 25.7298C33.6231 25.7748 33.274 25.7973 32.9588 25.7973H32.9475Z" fill="#FFFFFF"/>
|
||||
<path d="M36.5732 25.6059V1.52028H40.7733V25.6059H36.5732Z" fill="#FFFFFF"/>
|
||||
<path d="M48.2839 25.9888C47.079 25.9888 46.0206 25.7861 45.1197 25.3807C44.2189 24.9753 43.5208 24.4011 43.0366 23.6466C42.5524 22.8922 42.3047 22.0139 42.3047 21.023C42.3047 20.0321 42.5186 19.2101 42.9578 18.4557C43.3969 17.7012 44.05 17.0706 44.9508 16.5639C45.8404 16.0572 46.9664 15.6969 48.3289 15.483L53.959 14.5596V17.7463L49.1171 18.602C48.2951 18.7484 47.6758 19.0074 47.2705 19.379C46.8651 19.7506 46.6624 20.246 46.6624 20.8541C46.6624 21.4621 46.8876 21.9238 47.3493 22.2729C47.7997 22.6219 48.374 22.8021 49.0496 22.8021C49.9166 22.8021 50.6936 22.6219 51.3579 22.2504C52.0223 21.8788 52.5403 21.3608 52.9006 20.7077C53.2609 20.0546 53.4411 19.334 53.4411 18.5795V14.0867C53.4411 13.3435 53.1596 12.7242 52.5853 12.2287C52.011 11.7333 51.2453 11.4856 50.2995 11.4856C49.4099 11.4856 48.6217 11.7333 47.9236 12.2175C47.2367 12.7017 46.73 13.3322 46.4147 14.0979L43.0141 12.4427C43.3519 11.5306 43.8924 10.7424 44.6243 10.0668C45.3562 9.39116 46.2233 8.87319 47.2142 8.49034C48.2163 8.10749 49.2973 7.91607 50.4571 7.91607C51.8759 7.91607 53.1258 8.17505 54.2068 8.69303C55.2878 9.211 56.1323 9.94291 56.7403 10.8775C57.3484 11.8121 57.6524 12.8818 57.6524 14.0867V25.6059H53.7113V22.6445L54.6009 22.6107C54.1505 23.3313 53.6212 23.9507 52.9907 24.4574C52.3601 24.9641 51.662 25.3469 50.885 25.6059C50.108 25.8649 49.241 25.9888 48.2951 25.9888H48.2839Z" fill="#FFFFFF"/>
|
||||
<path d="M66.4802 25.9888C64.6336 25.9888 63.0233 25.5496 61.6609 24.6713C60.2871 23.793 59.3412 22.5994 58.812 21.0906L61.9649 19.5929C62.4153 20.5726 63.0346 21.3383 63.8228 21.89C64.6223 22.4418 65.5006 22.712 66.4802 22.712C67.2234 22.712 67.8202 22.5431 68.2594 22.2053C68.7098 21.8675 68.9237 21.4171 68.9237 20.8653C68.9237 20.5275 68.8336 20.246 68.6535 20.0208C68.4733 19.7956 68.2368 19.6042 67.9328 19.4466C67.64 19.2889 67.291 19.1538 66.9194 19.0524L64.0818 18.253C62.6405 17.8476 61.537 17.2058 60.7826 16.3275C60.0281 15.4492 59.6565 14.4132 59.6565 13.2196C59.6565 12.1612 59.9268 11.2266 60.4673 10.4384C61.0078 9.65015 61.7622 9.01957 62.7306 8.58042C63.699 8.13001 64.8025 7.91607 66.0524 7.91607C67.6851 7.91607 69.1264 8.31018 70.3763 9.09839C71.6262 9.88661 72.5157 10.9901 73.045 12.4089L69.8583 13.9065C69.5656 13.1183 69.0588 12.499 68.3607 12.0486C67.6626 11.5982 66.8743 11.3617 66.0073 11.3617C65.3092 11.3617 64.7574 11.5193 64.3521 11.8234C63.9467 12.1274 63.744 12.5553 63.744 13.0845C63.744 13.3773 63.8341 13.6475 64.003 13.884C64.1719 14.1205 64.4084 14.3119 64.7236 14.4583C65.0277 14.6047 65.388 14.7398 65.7934 14.8749L68.5634 15.6969C69.9822 16.1248 71.0857 16.7554 71.8626 17.6111C72.6396 18.4557 73.0224 19.5029 73.0224 20.7302C73.0224 21.7662 72.7409 22.6895 72.2005 23.4777C71.6487 24.2772 70.883 24.8965 69.9146 25.3357C68.935 25.7861 67.7977 26 66.4802 26V25.9888Z" fill="#FFFFFF"/>
|
||||
<path d="M90.2282 25.9889C88.5279 25.9889 86.9627 25.6849 85.5214 25.0656C84.0801 24.4463 82.8302 23.5905 81.7717 22.487C80.7133 21.3835 79.88 20.0886 79.2719 18.6022C78.6639 17.1159 78.3599 15.4944 78.3599 13.7378C78.3599 11.9812 78.6526 10.3485 79.2494 8.85085C79.8462 7.35324 80.6795 6.05831 81.7492 4.96606C82.8189 3.87382 84.0688 3.0293 85.4989 2.42125C86.9289 1.8132 88.5053 1.50917 90.2282 1.50917C91.951 1.50917 93.4486 1.79068 94.7998 2.36495C96.1511 2.93922 97.2884 3.69366 98.223 4.63952C99.1576 5.58538 99.8219 6.62132 100.227 7.74735L96.3425 9.59403C95.8921 8.38918 95.1489 7.39828 94.0792 6.62132C93.0207 5.84436 91.737 5.46152 90.2282 5.46152C88.7193 5.46152 87.4356 5.81058 86.2983 6.50872C85.1611 7.20685 84.2828 8.17523 83.6522 9.4026C83.0216 10.63 82.7176 12.0713 82.7176 13.7265C82.7176 15.3818 83.0329 16.8344 83.6522 18.073C84.2828 19.3116 85.1611 20.28 86.2983 20.9894C87.4356 21.6875 88.7418 22.0366 90.2282 22.0366C91.7145 22.0366 93.0207 21.6537 94.0792 20.8768C95.1376 20.0998 95.8921 19.1202 96.3425 17.9379L100.227 19.7508C99.8219 20.8768 99.1576 21.9127 98.223 22.8586C97.2884 23.8045 96.1511 24.5589 94.7998 25.1332C93.4486 25.7074 91.9285 25.9889 90.2282 25.9889Z" fill="#FFFFFF"/>
|
||||
<path d="M101.748 25.6059V1.52028H105.948V25.6059H101.748Z" fill="#FFFFFF"/>
|
||||
<path d="M116.645 25.9884C114.968 25.9884 113.436 25.5943 112.051 24.8061C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.491 18.6917 107.491 16.9464C107.491 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86371 112.04 9.08676C113.414 8.29854 114.956 7.90443 116.657 7.90443C118.357 7.90443 119.922 8.29854 121.307 9.08676C122.681 9.87497 123.784 10.9334 124.606 12.2847C125.417 13.6359 125.834 15.1898 125.834 16.9464C125.834 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.67 24.0291 121.285 24.8061C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1712C117.602 22.1712 118.436 21.946 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6489C121.251 18.8494 121.453 17.9598 121.453 16.9577C121.453 15.9555 121.251 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.436 11.9919 117.602 11.778 116.645 11.778C115.688 11.778 114.889 12.0032 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.468 14.289C112.051 15.0772 111.848 15.9668 111.848 16.9577C111.848 17.9486 112.051 18.8494 112.468 19.6489C112.884 20.4483 113.447 21.0677 114.168 21.5068C114.889 21.9572 115.722 22.1712 116.645 22.1712Z" fill="#FFFFFF"/>
|
||||
<path d="M133.535 25.9884C132.172 25.9884 131.013 25.6956 130.033 25.0988C129.053 24.502 128.299 23.68 127.77 22.6215C127.24 21.5631 126.97 20.3245 126.97 18.8944V8.29852H131.17V18.5453C131.17 19.266 131.317 19.8966 131.598 20.4371C131.88 20.9775 132.296 21.4054 132.837 21.7095C133.377 22.0135 133.985 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7095C137.014 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.221 138.174 18.4553V8.29852H142.34V25.6055H138.399V22.2049L138.715 22.813C138.309 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.762 25.9996 133.535 25.9996V25.9884Z" fill="#FFFFFF"/>
|
||||
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.92042 148.207 9.12094C149.525 8.31021 151.011 7.9161 152.666 7.9161C153.984 7.9161 155.155 8.17508 156.179 8.69305C157.204 9.21103 158.015 9.94294 158.612 10.8775L157.97 11.7333V1.52028H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0408 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1716C154.074 22.1716 154.907 21.9464 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9447 157.778 15.0664 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7784 153.139 11.7784C152.205 11.7784 151.371 12.0036 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1716 153.139 22.1716Z" fill="#FFFFFF"/>
|
||||
<path d="M13.4447 1.71661e-05L0 25.9887C6.22692 23.5227 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06451C13.4447 9.06451 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1104 26.8895 25.9887L13.4447 1.71661e-05Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="163" height="26" viewBox="0 0 163 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.9475 25.7973C30.9995 25.7973 29.4793 25.2568 28.3984 24.1871C27.3174 23.1174 26.7769 21.6085 26.7769 19.6492V12.0148H23.8154V8.28766H24.1307C24.9752 8.28766 25.6283 8.06245 26.09 7.6233C26.5404 7.17289 26.7769 6.53106 26.7769 5.68654V4.34657H30.9769V8.28766H34.9518V12.0148H30.9769V19.4353C30.9769 20.0096 31.0783 20.4938 31.281 20.8991C31.4837 21.3045 31.7989 21.6085 32.2381 21.8225C32.6772 22.0364 33.229 22.1378 33.9046 22.1378C34.051 22.1378 34.2312 22.1378 34.4338 22.104C34.6365 22.0814 34.828 22.0589 35.0194 22.0364V25.6059C34.7266 25.6509 34.3775 25.696 34.006 25.7298C33.6231 25.7748 33.274 25.7973 32.9588 25.7973H32.9475Z" fill="#0F1111"/>
|
||||
<path d="M36.5732 25.6059V1.52028H40.7733V25.6059H36.5732Z" fill="#0F1111"/>
|
||||
<path d="M48.2839 25.9888C47.079 25.9888 46.0206 25.7861 45.1197 25.3807C44.2189 24.9753 43.5208 24.4011 43.0366 23.6466C42.5524 22.8922 42.3047 22.0139 42.3047 21.023C42.3047 20.0321 42.5186 19.2101 42.9578 18.4557C43.3969 17.7012 44.05 17.0706 44.9508 16.5639C45.8404 16.0572 46.9664 15.6969 48.3289 15.483L53.959 14.5596V17.7463L49.1171 18.602C48.2951 18.7484 47.6758 19.0074 47.2705 19.379C46.8651 19.7506 46.6624 20.246 46.6624 20.8541C46.6624 21.4621 46.8876 21.9238 47.3493 22.2729C47.7997 22.6219 48.374 22.8021 49.0496 22.8021C49.9166 22.8021 50.6936 22.6219 51.3579 22.2504C52.0223 21.8788 52.5403 21.3608 52.9006 20.7077C53.2609 20.0546 53.4411 19.334 53.4411 18.5795V14.0867C53.4411 13.3435 53.1596 12.7242 52.5853 12.2287C52.011 11.7333 51.2453 11.4856 50.2995 11.4856C49.4099 11.4856 48.6217 11.7333 47.9236 12.2175C47.2367 12.7017 46.73 13.3322 46.4147 14.0979L43.0141 12.4427C43.3519 11.5306 43.8924 10.7424 44.6243 10.0668C45.3562 9.39116 46.2233 8.87319 47.2142 8.49034C48.2163 8.10749 49.2973 7.91607 50.4571 7.91607C51.8759 7.91607 53.1258 8.17505 54.2068 8.69303C55.2878 9.211 56.1323 9.94291 56.7403 10.8775C57.3484 11.8121 57.6524 12.8818 57.6524 14.0867V25.6059H53.7113V22.6445L54.6009 22.6107C54.1505 23.3313 53.6212 23.9507 52.9907 24.4574C52.3601 24.9641 51.662 25.3469 50.885 25.6059C50.108 25.8649 49.241 25.9888 48.2951 25.9888H48.2839Z" fill="#0F1111"/>
|
||||
<path d="M66.4802 25.9888C64.6336 25.9888 63.0233 25.5496 61.6609 24.6713C60.2871 23.793 59.3412 22.5994 58.812 21.0906L61.9649 19.5929C62.4153 20.5726 63.0346 21.3383 63.8228 21.89C64.6223 22.4418 65.5006 22.712 66.4802 22.712C67.2234 22.712 67.8202 22.5431 68.2594 22.2053C68.7098 21.8675 68.9237 21.4171 68.9237 20.8653C68.9237 20.5275 68.8336 20.246 68.6535 20.0208C68.4733 19.7956 68.2368 19.6042 67.9328 19.4466C67.64 19.2889 67.291 19.1538 66.9194 19.0524L64.0818 18.253C62.6405 17.8476 61.537 17.2058 60.7826 16.3275C60.0281 15.4492 59.6565 14.4132 59.6565 13.2196C59.6565 12.1612 59.9268 11.2266 60.4673 10.4384C61.0078 9.65015 61.7622 9.01957 62.7306 8.58042C63.699 8.13001 64.8025 7.91607 66.0524 7.91607C67.6851 7.91607 69.1264 8.31018 70.3763 9.09839C71.6262 9.88661 72.5157 10.9901 73.045 12.4089L69.8583 13.9065C69.5656 13.1183 69.0588 12.499 68.3607 12.0486C67.6626 11.5982 66.8743 11.3617 66.0073 11.3617C65.3092 11.3617 64.7574 11.5193 64.3521 11.8234C63.9467 12.1274 63.744 12.5553 63.744 13.0845C63.744 13.3773 63.8341 13.6475 64.003 13.884C64.1719 14.1205 64.4084 14.3119 64.7236 14.4583C65.0277 14.6047 65.388 14.7398 65.7934 14.8749L68.5634 15.6969C69.9822 16.1248 71.0857 16.7554 71.8626 17.6111C72.6396 18.4557 73.0224 19.5029 73.0224 20.7302C73.0224 21.7662 72.7409 22.6895 72.2005 23.4777C71.6487 24.2772 70.883 24.8965 69.9146 25.3357C68.935 25.7861 67.7977 26 66.4802 26V25.9888Z" fill="#0F1111"/>
|
||||
<path d="M90.2282 25.9889C88.5279 25.9889 86.9627 25.6849 85.5214 25.0656C84.0801 24.4463 82.8302 23.5905 81.7717 22.487C80.7133 21.3835 79.88 20.0886 79.2719 18.6022C78.6639 17.1159 78.3599 15.4944 78.3599 13.7378C78.3599 11.9812 78.6526 10.3485 79.2494 8.85085C79.8462 7.35324 80.6795 6.05831 81.7492 4.96606C82.8189 3.87382 84.0688 3.0293 85.4989 2.42125C86.9289 1.8132 88.5053 1.50917 90.2282 1.50917C91.951 1.50917 93.4486 1.79068 94.7998 2.36495C96.1511 2.93922 97.2884 3.69366 98.223 4.63952C99.1576 5.58538 99.8219 6.62132 100.227 7.74735L96.3425 9.59403C95.8921 8.38918 95.1489 7.39828 94.0792 6.62132C93.0207 5.84436 91.737 5.46152 90.2282 5.46152C88.7193 5.46152 87.4356 5.81058 86.2983 6.50872C85.1611 7.20685 84.2828 8.17523 83.6522 9.4026C83.0216 10.63 82.7176 12.0713 82.7176 13.7265C82.7176 15.3818 83.0329 16.8344 83.6522 18.073C84.2828 19.3116 85.1611 20.28 86.2983 20.9894C87.4356 21.6875 88.7418 22.0366 90.2282 22.0366C91.7145 22.0366 93.0207 21.6537 94.0792 20.8768C95.1376 20.0998 95.8921 19.1202 96.3425 17.9379L100.227 19.7508C99.8219 20.8768 99.1576 21.9127 98.223 22.8586C97.2884 23.8045 96.1511 24.5589 94.7998 25.1332C93.4486 25.7074 91.9285 25.9889 90.2282 25.9889Z" fill="#0F1111"/>
|
||||
<path d="M101.748 25.6059V1.52028H105.948V25.6059H101.748Z" fill="#0F1111"/>
|
||||
<path d="M116.645 25.9884C114.968 25.9884 113.436 25.5943 112.051 24.8061C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.491 18.6917 107.491 16.9464C107.491 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86371 112.04 9.08676C113.414 8.29854 114.956 7.90443 116.657 7.90443C118.357 7.90443 119.922 8.29854 121.307 9.08676C122.681 9.87497 123.784 10.9334 124.606 12.2847C125.417 13.6359 125.834 15.1898 125.834 16.9464C125.834 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.67 24.0291 121.285 24.8061C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1712C117.602 22.1712 118.436 21.946 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6489C121.251 18.8494 121.453 17.9598 121.453 16.9577C121.453 15.9555 121.251 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.436 11.9919 117.602 11.778 116.645 11.778C115.688 11.778 114.889 12.0032 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.468 14.289C112.051 15.0772 111.848 15.9668 111.848 16.9577C111.848 17.9486 112.051 18.8494 112.468 19.6489C112.884 20.4483 113.447 21.0677 114.168 21.5068C114.889 21.9572 115.722 22.1712 116.645 22.1712Z" fill="#0F1111"/>
|
||||
<path d="M133.535 25.9884C132.172 25.9884 131.013 25.6956 130.033 25.0988C129.053 24.502 128.299 23.68 127.77 22.6215C127.24 21.5631 126.97 20.3245 126.97 18.8944V8.29852H131.17V18.5453C131.17 19.266 131.317 19.8966 131.598 20.4371C131.88 20.9775 132.296 21.4054 132.837 21.7095C133.377 22.0135 133.985 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7095C137.014 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.221 138.174 18.4553V8.29852H142.34V25.6055H138.399V22.2049L138.715 22.813C138.309 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.762 25.9996 133.535 25.9996V25.9884Z" fill="#0F1111"/>
|
||||
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.92042 148.207 9.12094C149.525 8.31021 151.011 7.9161 152.666 7.9161C153.984 7.9161 155.155 8.17508 156.179 8.69305C157.204 9.21103 158.015 9.94294 158.612 10.8775L157.97 11.7333V1.52028H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0408 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1716C154.074 22.1716 154.907 21.9464 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9447 157.778 15.0664 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7784 153.139 11.7784C152.205 11.7784 151.371 12.0036 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1716 153.139 22.1716Z" fill="#0F1111"/>
|
||||
<path d="M13.4447 1.71661e-05L0 25.9887C6.22692 23.5227 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06451C13.4447 9.06451 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1104 26.8895 25.9887L13.4447 1.71661e-05Z" fill="#0F1111"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 338 KiB |
+14
-4
@@ -33,7 +33,7 @@ body {
|
||||
background-color: var(--bg); color: var(--text-primary);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
overflow-x: hidden; -webkit-font-smoothing: antialiased; letter-spacing: -0.02em;
|
||||
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Ctext y='20' font-size='20'%3E🪨%3C/text%3E%3C/svg%3E"), auto;
|
||||
cursor: url("assets/dancing-rock-32.png") 16 16, auto;
|
||||
}
|
||||
|
||||
::selection { background: var(--text-primary); color: var(--bg); }
|
||||
@@ -208,7 +208,7 @@ body {
|
||||
<div class="telemetry-widget">
|
||||
<div>STATUS: <span class="telemetry-val" style="color:#4ade80">OPTIMIZED</span></div>
|
||||
<div>PHYSICAL_IMPACTS: <span class="telemetry-val" id="bonkCount">0</span></div>
|
||||
<div>TOKENS_PURGED: <span class="telemetry-val">~75%</span></div>
|
||||
<div>TOKENS_PURGED: <span class="telemetry-val">65%</span></div>
|
||||
</div>
|
||||
|
||||
<section class="hero container">
|
||||
@@ -363,7 +363,7 @@ document.addEventListener('mousemove', (e) => {
|
||||
|
||||
// --- Marquee Population ---
|
||||
const items = [
|
||||
{ k: "TOKENS_SAVED", v: "75%" }, { k: "ACCURACY", v: "100%" },
|
||||
{ k: "TOKENS_SAVED", v: "65%" }, { k: "ACCURACY", v: "100%" },
|
||||
{ k: "LATENCY_DROP", v: "3x" }, { k: "VIBES", v: "OOG" },
|
||||
{ k: "PRICE", v: "$0.00" }, { k: "DEPENDENCIES", v: "NONE" }
|
||||
];
|
||||
@@ -417,7 +417,17 @@ cliInput.addEventListener('keydown', (e) => {
|
||||
const val = cliInput.value.trim();
|
||||
const row = document.createElement('div');
|
||||
row.className = 'term-line';
|
||||
row.innerHTML = `<span class="term-accent">❯</span> ${val}`;
|
||||
|
||||
// SECURITY FIX: Create DOM elements safely to prevent XSS
|
||||
const prompt = document.createElement('span');
|
||||
prompt.className = 'term-accent';
|
||||
prompt.textContent = '❯';
|
||||
|
||||
const commandText = document.createElement('span');
|
||||
commandText.textContent = ` ${val}`; // textContent automatically escapes HTML
|
||||
|
||||
row.appendChild(prompt);
|
||||
row.appendChild(commandText);
|
||||
cliInput.parentElement.before(row);
|
||||
|
||||
const res = document.createElement('div');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Windows install fallback
|
||||
|
||||
If `irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex` fails on Windows (issues #249, #199, #72), set up plugin-skill activation by hand. This does **not** install the standalone hooks or the statusline — for those, run the unified Node installer afterwards: `npx -y github:JuliusBrussee/caveman -- --only claude` (or `node bin/install.js --only claude` from a clone).
|
||||
If `irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex` fails on Windows (issues #249, #199, #72), set up plugin-skill activation by hand. This does **not** install the standalone hooks or the statusline — for those, run the unified Node installer afterwards: `npx -y github:JuliusBrussee/caveman -- --only claude` (or `node cli/install.js --only claude` from a clone).
|
||||
|
||||
```powershell
|
||||
$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "caveman",
|
||||
"description": "Ultra-compressed communication mode. Cuts ~75% of tokens while keeping full technical accuracy by speaking like a caveman.",
|
||||
"description": "Ultra-compressed communication mode. Cuts 65% of output tokens (measured) while keeping full technical accuracy by speaking like a caveman.",
|
||||
"version": "1.0.1",
|
||||
"contextFileName": "GEMINI.md"
|
||||
}
|
||||
|
||||
+56
-37
@@ -1,7 +1,7 @@
|
||||
# caveman — installer shim (Windows / PowerShell).
|
||||
#
|
||||
# Thin wrapper around bin/install.js (the unified Node installer). Every flag
|
||||
# you'd pass to bin/install.js can be passed here; we just forward them.
|
||||
# Thin wrapper around cli/install.js (the unified Node installer). Every flag
|
||||
# you'd pass to cli/install.js can be passed here; we just forward them.
|
||||
#
|
||||
# One-line install:
|
||||
# irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex
|
||||
@@ -13,50 +13,69 @@
|
||||
# truth and constantly drifted (issue #249 was a `node -e "..."` quoting bug
|
||||
# that silently dropped the JSON merge step on every Windows install). One
|
||||
# Node script works everywhere without quoting bugs.
|
||||
#
|
||||
# Why no top-level param() and everything inside a function? `irm | iex`
|
||||
# executes this file as a string: script-path variables ($PSCommandPath,
|
||||
# $MyInvocation.MyCommand.Path) are $null and a top-level param block cannot
|
||||
# receive arguments through a pipe anyway (issue #565). Wrapping the logic in
|
||||
# a function and forwarding $args keeps one script working for both the pipe
|
||||
# path (no args, no script path) and the local-clone path.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$Args
|
||||
)
|
||||
function Install-Caveman {
|
||||
param(
|
||||
[string[]]$InstallerArgs = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Repo = "JuliusBrussee/caveman"
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Repo = "JuliusBrussee/caveman"
|
||||
|
||||
# Require Node ≥18.
|
||||
$node = Get-Command node -ErrorAction SilentlyContinue
|
||||
if (-not $node) {
|
||||
Write-Error @"
|
||||
# Require Node ≥18.
|
||||
$node = Get-Command node -ErrorAction SilentlyContinue
|
||||
if (-not $node) {
|
||||
Write-Error @"
|
||||
caveman: Node.js (>=18) required. Install:
|
||||
- winget install OpenJS.NodeJS.LTS
|
||||
- or download from https://nodejs.org
|
||||
"@
|
||||
exit 1
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
$nodeMajor = [int](& node -p "process.versions.node.split('.')[0]")
|
||||
if ($nodeMajor -lt 18) {
|
||||
Write-Error "caveman: Node $nodeMajor too old. Need Node >=18. Upgrade: https://nodejs.org"
|
||||
exit 1
|
||||
}
|
||||
$nodeMajor = [int](& node -p "process.versions.node.split('.')[0]")
|
||||
if ($nodeMajor -lt 18) {
|
||||
Write-Error "caveman: Node $nodeMajor too old. Need Node >=18. Upgrade: https://nodejs.org"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# If we're inside the repo clone, run the local installer directly.
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$local = Join-Path $here "bin/install.js"
|
||||
if (Test-Path $local) {
|
||||
& node $local @Args
|
||||
# If we're inside the repo clone, run the local installer directly.
|
||||
# $PSCommandPath is $null when piped to iex (#565) — the old unguarded
|
||||
# Split-Path on it was the "Cannot bind argument to parameter 'Path'
|
||||
# because it is null" crash.
|
||||
if ($PSCommandPath) {
|
||||
$here = Split-Path -Parent $PSCommandPath
|
||||
$local = Join-Path $here "cli/install.js"
|
||||
if (Test-Path $local) {
|
||||
& node $local @InstallerArgs
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
}
|
||||
|
||||
# Curl-pipe path: delegate to npx.
|
||||
$npx = Get-Command npx -ErrorAction SilentlyContinue
|
||||
if (-not $npx) {
|
||||
Write-Error "caveman: npx required (ships with Node >=18). Reinstall Node.js."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Do NOT pass `--` here — npm 7+ npx already forwards trailing args to the
|
||||
# package, and a literal `--` was tripping cli/install.js's parseArgs as an
|
||||
# unknown flag.
|
||||
# npm >=12 defaults allow-git to "none", failing github: specs with
|
||||
# EALLOWGIT (#698). Scope the override to this invocation.
|
||||
$env:NPM_CONFIG_ALLOW_GIT = "all"
|
||||
& npx -y "github:$Repo" @InstallerArgs
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# Curl-pipe path: delegate to npx.
|
||||
$npx = Get-Command npx -ErrorAction SilentlyContinue
|
||||
if (-not $npx) {
|
||||
Write-Error "caveman: npx required (ships with Node >=18). Reinstall Node.js."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Do NOT pass `--` here — npm 7+ npx already forwards trailing args to the
|
||||
# package, and a literal `--` was tripping bin/install.js's parseArgs as an
|
||||
# unknown flag.
|
||||
& npx -y "github:$Repo" @Args
|
||||
exit $LASTEXITCODE
|
||||
# $args is the automatic variable: populated when run as a file
|
||||
# (`pwsh install.ps1 --force`), empty under `irm | iex`.
|
||||
Install-Caveman -InstallerArgs $args
|
||||
|
||||
+8
-6
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# caveman — installer shim.
|
||||
#
|
||||
# Thin wrapper around bin/install.js (the unified Node installer). Every flag
|
||||
# you'd pass to bin/install.js can be passed here; we just forward them.
|
||||
# Thin wrapper around cli/install.js (the unified Node installer). Every flag
|
||||
# you'd pass to cli/install.js can be passed here; we just forward them.
|
||||
#
|
||||
# One-line install:
|
||||
# curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash
|
||||
@@ -39,16 +39,18 @@ fi
|
||||
# when bash is invoked from stdin (curl | bash), and `set -u` would trip on a
|
||||
# bare reference — default to empty so the curl-pipe path falls through cleanly.
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]:-}")" 2>/dev/null && pwd)" || here=""
|
||||
if [ -n "$here" ] && [ -f "$here/bin/install.js" ]; then
|
||||
exec node "$here/bin/install.js" "$@"
|
||||
if [ -n "$here" ] && [ -f "$here/cli/install.js" ]; then
|
||||
exec node "$here/cli/install.js" "$@"
|
||||
fi
|
||||
|
||||
# Curl-pipe path: delegate to npx. We do NOT pass `--` here — npm 7+ npx
|
||||
# already forwards trailing args to the package, and a literal `--` tripped
|
||||
# bin/install.js's parseArgs as an unknown flag.
|
||||
# cli/install.js's parseArgs as an unknown flag.
|
||||
if ! command -v npx >/dev/null 2>&1; then
|
||||
echo "caveman: npx required (ships with Node ≥18). Reinstall Node.js." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec npx -y "github:$REPO" "$@"
|
||||
# npm >=12 defaults allow-git to "none", failing github: specs with EALLOWGIT
|
||||
# (#698). Scope the override to this one invocation.
|
||||
NPM_CONFIG_ALLOW_GIT=all exec npx -y "github:$REPO" "$@"
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@
|
||||
"url": "https://github.com/JuliusBrussee/caveman/issues"
|
||||
},
|
||||
"bin": {
|
||||
"caveman": "./bin/install.js"
|
||||
"caveman": "./cli/install.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -22,7 +22,7 @@
|
||||
"test": "node --test tests/installer/*.test.mjs"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"cli/",
|
||||
"src/",
|
||||
"agents/",
|
||||
"skills/",
|
||||
|
||||
@@ -11,7 +11,7 @@ description: >
|
||||
|
||||
## Purpose
|
||||
|
||||
Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `<filename>.original.md`.
|
||||
Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `<filename>.original.md`, but NOT beside the source file — it lives in an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups/<parent-dir-name>/`, or `%LOCALAPPDATA%\caveman-compress\backups\<parent-dir-name>\` on Windows) so skill auto-loaders don't re-ingest it as a live file.
|
||||
|
||||
## Trigger
|
||||
|
||||
@@ -107,5 +107,5 @@ Compressed:
|
||||
- NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh
|
||||
- If file has mixed content (prose + code), compress ONLY the prose sections
|
||||
- If unsure whether something is code or prose, leave it unchanged
|
||||
- Original file is backed up as FILE.original.md before overwriting
|
||||
- Original file is backed up as FILE.original.md before overwriting — in the out-of-tree backup data dir (see Purpose), not beside the source file
|
||||
- Never compress FILE.original.md (skip it)
|
||||
|
||||
@@ -23,8 +23,8 @@ def count_tokens(text):
|
||||
|
||||
|
||||
def benchmark_pair(orig_path: Path, comp_path: Path):
|
||||
orig_text = orig_path.read_text()
|
||||
comp_text = comp_path.read_text()
|
||||
orig_text = orig_path.read_text(encoding="utf-8", errors="ignore")
|
||||
comp_text = comp_path.read_text(encoding="utf-8", errors="ignore")
|
||||
|
||||
orig_tokens = count_tokens(orig_text)
|
||||
comp_tokens = count_tokens(comp_text)
|
||||
|
||||
@@ -21,7 +21,7 @@ for _stream in (sys.stdout, sys.stderr):
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .compress import compress_file
|
||||
from .compress import backup_dir_for, compress_file
|
||||
from .detect import detect_file_type, should_compress
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ def main():
|
||||
|
||||
if success:
|
||||
print("\nCompression completed successfully")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
backup_path = backup_dir_for(filepath) / (filepath.stem + ".original.md")
|
||||
print(f"Compressed: {filepath}")
|
||||
print(f"Original: {backup_path}")
|
||||
sys.exit(0)
|
||||
|
||||
@@ -8,7 +8,11 @@ Usage:
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
@@ -16,6 +20,27 @@ OUTER_FENCE_REGEX = re.compile(
|
||||
r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
|
||||
)
|
||||
|
||||
# YAML frontmatter: starts at file start with --- on its own line, ends with --- on its own line.
|
||||
# Captures the entire block (including delimiters and trailing newline) and the body after.
|
||||
FRONTMATTER_REGEX = re.compile(
|
||||
r"\A(---\r?\n.*?\r?\n---\r?\n)(.*)", re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def split_frontmatter(text: str):
|
||||
"""Split YAML frontmatter from body. Returns (frontmatter, body).
|
||||
|
||||
Memory files (and many other markdown docs) start with a YAML frontmatter
|
||||
block delimited by `---` lines. The compression LLM has a habit of stripping
|
||||
or rewriting these despite preserve-structure rules in the prompt — so we
|
||||
surgically remove the frontmatter before compression and prepend it back
|
||||
verbatim to the output. Files without frontmatter pass through unchanged.
|
||||
"""
|
||||
m = FRONTMATTER_REGEX.match(text)
|
||||
if m:
|
||||
return m.group(1), m.group(2)
|
||||
return "", text
|
||||
|
||||
# Filenames and paths that almost certainly hold secrets or PII. Compressing
|
||||
# them ships raw bytes to the Anthropic API — a third-party data boundary that
|
||||
# developers on sensitive codebases cannot cross. detect.py already skips .env
|
||||
@@ -43,6 +68,30 @@ SENSITIVE_NAME_TOKENS = (
|
||||
)
|
||||
|
||||
|
||||
def backup_dir_for(filepath: Path) -> Path:
|
||||
"""Resolve the out-of-tree backup directory for a given source file.
|
||||
|
||||
Backups must live OUTSIDE the source directory so skill auto-loaders
|
||||
(Claude Code rules/, opencode instructions/, etc.) stop re-ingesting the
|
||||
`.original.md` copies as live files. Base dir is platform-aware:
|
||||
- Windows: %LOCALAPPDATA%\\caveman-compress\\backups
|
||||
- else: $XDG_DATA_HOME/caveman-compress/backups if set,
|
||||
else ~/.local/share/caveman-compress/backups
|
||||
|
||||
The source file's parent-dir name is mirrored under the base to reduce
|
||||
cross-project collisions (e.g. two `task.md` files in different repos).
|
||||
"""
|
||||
if os.name == "nt" or sys.platform == "win32":
|
||||
local_appdata = os.environ.get("LOCALAPPDATA")
|
||||
base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
|
||||
base = base / "caveman-compress" / "backups"
|
||||
else:
|
||||
xdg = os.environ.get("XDG_DATA_HOME")
|
||||
base = Path(xdg) if xdg else Path.home() / ".local" / "share"
|
||||
base = base / "caveman-compress" / "backups"
|
||||
return base / filepath.parent.name
|
||||
|
||||
|
||||
def is_sensitive_path(filepath: Path) -> bool:
|
||||
"""Heuristic denylist for files that must never be shipped to a third-party API."""
|
||||
name = filepath.name
|
||||
@@ -63,6 +112,59 @@ def strip_llm_wrapper(text: str) -> str:
|
||||
return m.group(2)
|
||||
return text
|
||||
|
||||
|
||||
def write_text_atomic(path: Path, text: str) -> None:
|
||||
"""Write ``text`` to ``path`` atomically as UTF-8.
|
||||
|
||||
Path.write_text() truncates the destination before encoding the string —
|
||||
a UnicodeEncodeError (or any other failure) partway through leaves a
|
||||
0-byte file, destroying whatever was there before (issue #655). Encode
|
||||
first, write the bytes to a sibling temp file, fsync, then os.replace()
|
||||
so the destination only ever moves from one complete, valid file to
|
||||
another. Preserves the original file's permission bits across the swap.
|
||||
"""
|
||||
data = text.encode("utf-8")
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(path.parent), prefix=path.name + ".", suffix=".tmp"
|
||||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
if path.exists():
|
||||
os.chmod(tmp_path, stat.S_IMODE(path.stat().st_mode))
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def first_nonblank_line(text: str) -> str:
|
||||
"""Return the first non-blank line, stripped — used to detect a prose
|
||||
preamble smuggled in ahead of the real content (issue #588)."""
|
||||
for line in text.splitlines():
|
||||
if line.strip():
|
||||
return line.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _write_target(filepath: Path, text: str, backup_path: Path) -> None:
|
||||
"""Write to the target file, surfacing the backup location if the write
|
||||
itself fails. write_text_atomic already leaves the target untouched on
|
||||
failure, but the caller still needs to know where the pre-compression
|
||||
original lives instead of being left to guess (issue #652)."""
|
||||
try:
|
||||
write_text_atomic(filepath, text)
|
||||
except Exception:
|
||||
print(f"❌ Write to {filepath} failed. Original preserved at backup: {backup_path}")
|
||||
raise
|
||||
|
||||
|
||||
from .detect import should_compress
|
||||
from .validate import validate
|
||||
|
||||
@@ -73,6 +175,18 @@ MAX_RETRIES = 2
|
||||
|
||||
|
||||
def call_claude(prompt: str) -> str:
|
||||
"""Send a prompt to Claude.
|
||||
|
||||
Prefers the Anthropic SDK when ANTHROPIC_API_KEY is set; otherwise falls
|
||||
back to the ``claude --print`` CLI (which handles desktop auth).
|
||||
|
||||
On Windows the CLI subprocess decoding defaults to the system codepage
|
||||
(cp1251 / cp1252) and crashes on UTF-8 output — see issue #152. Pinning
|
||||
``encoding="utf-8"`` with ``errors="replace"`` matches the CLI's actual
|
||||
native I/O and prevents the UnicodeDecodeError before validation can
|
||||
report. Windows users with non-ASCII content can also set
|
||||
``ANTHROPIC_API_KEY`` to route through the SDK and skip the subprocess.
|
||||
"""
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if api_key:
|
||||
try:
|
||||
@@ -87,14 +201,22 @@ def call_claude(prompt: str) -> str:
|
||||
return strip_llm_wrapper(msg.content[0].text.strip())
|
||||
except ImportError:
|
||||
pass # anthropic not installed, fall back to CLI
|
||||
# Fallback: use claude CLI (handles desktop auth)
|
||||
# Fallback: use claude CLI (handles desktop auth).
|
||||
# Resolve binary via shutil.which so Windows .cmd/.bat shims (e.g.
|
||||
# %APPDATA%\npm\claude.CMD) work without shell=True. On POSIX,
|
||||
# shutil.which returns the same absolute path as the implicit lookup,
|
||||
# so this is a no-op there. Falls back to bare "claude" if not found
|
||||
# on PATH so subprocess raises a clear FileNotFoundError.
|
||||
claude_bin = shutil.which("claude") or "claude"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "--print"],
|
||||
[claude_bin, "--print"],
|
||||
input=prompt,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return strip_llm_wrapper(result.stdout.strip())
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -179,8 +301,13 @@ def compress_file(filepath: Path) -> bool:
|
||||
print("Skipping (not natural language)")
|
||||
return False
|
||||
|
||||
original_text = filepath.read_text(errors="ignore")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
original_text = filepath.read_text(encoding="utf-8", errors="ignore")
|
||||
# Store backup outside the source directory so skill auto-loaders don't
|
||||
# re-ingest the `.original.md` copy as a live file. Mirror the source's
|
||||
# parent-dir name + stem under a platform-aware base to reduce collisions.
|
||||
backup_dir = backup_dir_for(filepath)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_path = backup_dir / (filepath.stem + ".original.md")
|
||||
|
||||
if not original_text.strip():
|
||||
print("❌ Refusing to compress: file is empty or whitespace-only.")
|
||||
@@ -193,27 +320,43 @@ def compress_file(filepath: Path) -> bool:
|
||||
print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.")
|
||||
return False
|
||||
|
||||
# Step 1: Compress
|
||||
print("Compressing with Claude...")
|
||||
compressed = call_claude(build_compress_prompt(original_text))
|
||||
# Split YAML frontmatter off before compression. Claude tends to strip or
|
||||
# rewrite frontmatter despite preserve-structure rules; we keep it verbatim
|
||||
# by removing it from the input and re-prepending it to the output.
|
||||
frontmatter, body = split_frontmatter(original_text)
|
||||
if frontmatter:
|
||||
print(f"Detected YAML frontmatter ({len(frontmatter)} chars) — preserving verbatim")
|
||||
|
||||
if compressed is None or not compressed.strip():
|
||||
if not body.strip():
|
||||
print("❌ Refusing to compress: body is empty after frontmatter removal.")
|
||||
return False
|
||||
|
||||
# Step 1: Compress (body only, frontmatter excluded)
|
||||
print("Compressing with Claude...")
|
||||
compressed_body = call_claude(build_compress_prompt(body))
|
||||
|
||||
if compressed_body is None or not compressed_body.strip():
|
||||
print("❌ Compression aborted: Claude returned an empty response.")
|
||||
print(" Original file is untouched (no backup created).")
|
||||
return False
|
||||
|
||||
if compressed.strip() == original_text.strip():
|
||||
# Compare the BODY (not the whole file) — frontmatter is preserved verbatim
|
||||
# and would never change, so identity must be judged on the compressible part.
|
||||
if compressed_body.strip() == body.strip():
|
||||
print("❌ Compression aborted: output is identical to input.")
|
||||
print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
|
||||
print(" already in caveman form. Original file is untouched (no backup created).")
|
||||
return False
|
||||
|
||||
# Reassemble: frontmatter (verbatim) + compressed body
|
||||
compressed = frontmatter + compressed_body
|
||||
|
||||
# Save original as backup, then verify the backup readback before
|
||||
# touching the input file. If the filesystem dropped bytes (encoding,
|
||||
# antivirus, disk full), unlink the bad backup and abort instead of
|
||||
# leaving the user with a corrupt backup + compressed primary.
|
||||
backup_path.write_text(original_text)
|
||||
backup_readback = backup_path.read_text(errors="ignore")
|
||||
write_text_atomic(backup_path, original_text)
|
||||
backup_readback = backup_path.read_text(encoding="utf-8", errors="ignore")
|
||||
if backup_readback != original_text:
|
||||
print(f"❌ Backup write verification failed: {backup_path}")
|
||||
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
|
||||
@@ -222,7 +365,7 @@ def compress_file(filepath: Path) -> bool:
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
filepath.write_text(compressed)
|
||||
_write_target(filepath, compressed, backup_path)
|
||||
|
||||
# Step 2: Validate + Retry
|
||||
for attempt in range(MAX_RETRIES):
|
||||
@@ -240,7 +383,7 @@ def compress_file(filepath: Path) -> bool:
|
||||
|
||||
if attempt == MAX_RETRIES - 1:
|
||||
# Restore original on failure
|
||||
filepath.write_text(original_text)
|
||||
_write_target(filepath, original_text, backup_path)
|
||||
backup_path.unlink(missing_ok=True)
|
||||
print("❌ Failed after retries — original restored")
|
||||
return False
|
||||
@@ -249,6 +392,23 @@ def compress_file(filepath: Path) -> bool:
|
||||
compressed = call_claude(
|
||||
build_fix_prompt(original_text, compressed, result.errors)
|
||||
)
|
||||
filepath.write_text(compressed)
|
||||
|
||||
if compressed is None or not compressed.strip():
|
||||
print("❌ Fix attempt aborted: Claude returned an empty response.")
|
||||
print(" Skipping this attempt.")
|
||||
continue
|
||||
|
||||
# Guard against a prose preamble smuggled in ahead of the real fixed
|
||||
# content (issue #588). Only enforced when the original starts with a
|
||||
# structural anchor (frontmatter `---` or a heading) — plain-prose
|
||||
# first lines get legitimately rewritten by compression, and requiring
|
||||
# them verbatim would reject every valid fix.
|
||||
anchor = first_nonblank_line(original_text)
|
||||
if anchor.startswith(("---", "#")) and first_nonblank_line(compressed) != anchor:
|
||||
print("❌ Fix attempt aborted: output does not start with the original's first line.")
|
||||
print(" Possible preamble leak. Skipping this attempt.")
|
||||
continue
|
||||
|
||||
_write_target(filepath, compressed, backup_path)
|
||||
|
||||
return True
|
||||
|
||||
@@ -17,6 +17,16 @@ SKIP_EXTENSIONS = {
|
||||
".dockerfile", ".makefile", ".csv", ".ini", ".cfg",
|
||||
}
|
||||
|
||||
# Well-known build/config files that carry no (or a misleading) extension —
|
||||
# `Dockerfile` has no suffix so `.dockerfile` above never matches it, and
|
||||
# `CMakeLists.txt` would ride the compressible `.txt` rule. Checked by
|
||||
# basename before any extension rule.
|
||||
KNOWN_CODE_FILENAMES = {
|
||||
"dockerfile", "makefile", "gnumakefile", "jenkinsfile", "vagrantfile",
|
||||
"rakefile", "gemfile", "justfile", "procfile", "brewfile",
|
||||
"cmakelists.txt",
|
||||
}
|
||||
|
||||
# Patterns that indicate a line is code
|
||||
CODE_PATTERNS = [
|
||||
re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"),
|
||||
@@ -67,6 +77,10 @@ def detect_file_type(filepath: Path) -> str:
|
||||
"""
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
# Known code filenames win over any extension rule
|
||||
if filepath.name.lower() in KNOWN_CODE_FILENAMES:
|
||||
return "code"
|
||||
|
||||
# Extension-based classification
|
||||
if ext in COMPRESSIBLE_EXTENSIONS:
|
||||
return "natural_language"
|
||||
@@ -76,12 +90,16 @@ def detect_file_type(filepath: Path) -> str:
|
||||
# Extensionless files (like CLAUDE.md, TODO) — check content
|
||||
if not ext:
|
||||
try:
|
||||
text = filepath.read_text(errors="ignore")
|
||||
text = filepath.read_text(encoding="utf-8", errors="ignore")
|
||||
except (OSError, PermissionError):
|
||||
return "unknown"
|
||||
|
||||
lines = text.splitlines()[:50]
|
||||
|
||||
# Shebang means executable script, never prose
|
||||
if text.startswith("#!"):
|
||||
return "code"
|
||||
|
||||
if _is_json_content(text[:10000]):
|
||||
return "config"
|
||||
if _is_yaml_content(lines):
|
||||
|
||||
@@ -28,7 +28,7 @@ class ValidationResult:
|
||||
|
||||
|
||||
def read_file(path: Path) -> str:
|
||||
return path.read_text(errors="ignore")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------- Extractors ----------
|
||||
@@ -95,8 +95,16 @@ def count_bullets(text):
|
||||
|
||||
|
||||
def extract_inline_codes(text):
|
||||
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
|
||||
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
|
||||
"""Backtick-delimited inline spans, with fenced code blocks stripped first.
|
||||
|
||||
Previously used a column-0-anchored regex to strip fences, which misses
|
||||
fences indented 1-3 spaces (valid CommonMark). Reuse extract_code_blocks
|
||||
(FENCE_OPEN_REGEX-based, indentation-aware) instead so an indented fence's
|
||||
body backticks don't leak into inline-code pairing.
|
||||
"""
|
||||
text_without_fences = text
|
||||
for block in extract_code_blocks(text):
|
||||
text_without_fences = text_without_fences.replace(block, "", 1)
|
||||
return re.findall(r"`([^`]+)`", text_without_fences)
|
||||
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ description: >
|
||||
---
|
||||
|
||||
This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately.
|
||||
|
||||
Output also includes `Est. rule overhead` and `Est. net` lines wherever a savings estimate exists with a known turn count. Rule overhead is the estimated per-turn INPUT-token cost of the injected caveman rules (default 1,250 tokens/turn, override with `CAVEMAN_RULE_OVERHEAD_TOKENS`) times the turn count. Net is savings minus that overhead — when negative, the output says so plainly and suggests turning caveman off for that workload, rather than hiding the net-negative regime behind a gross-savings number (see `docs/HONEST-NUMBERS.md`).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||
Ultra-compressed communication mode. Cuts output tokens 65% (measured) by speaking like caveman
|
||||
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||
@@ -14,11 +14,21 @@ Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
|
||||
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off`.
|
||||
|
||||
## Rules
|
||||
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) — tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either — own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
|
||||
Never drop not/never/no/only/except — flip meaning worse than any token saved. Numbers, units exact.
|
||||
|
||||
Tool calls: fire direct. No preamble, plan, or progress note before or between calls. After result: next call direct or final answer — never announce next call. Text before call only to clarify, warn security/irreversible, or resolve ambiguity.
|
||||
|
||||
Preserve user's dominant language exactly — reply in the language user writes, never switch regardless of example text or multilingual context elsewhere. Compress the style, not the language. Every emitted line in that language — openings, pre-tool status lines, all — not just final reply. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
|
||||
|
||||
'Drop articles' = article languages only. Where small markers carry case/role (particles, postpositions), keep them — grammar, not filler; compress politeness/filler instead.
|
||||
|
||||
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
|
||||
|
||||
Pattern: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
@@ -30,26 +40,28 @@ Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
| Level | What change |
|
||||
|-------|------------|
|
||||
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
|
||||
| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
|
||||
| **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) — measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch |
|
||||
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction — chars, not tokens. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||
|
||||
Example — "Why React component re-render?"
|
||||
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||
- ultra: "Inline obj prop, new ref, re-render. `useMemo`."
|
||||
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||
- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
|
||||
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||
- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
|
||||
- wenyan-ultra: "新參照則重繪。useMemo 包之。"
|
||||
|
||||
Example — "Explain database connection pooling."
|
||||
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||
- ultra: "Pool reuse open DB connections. No per-request handshake."
|
||||
- wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。"
|
||||
- wenyan-ultra: "池蓄連,免逐請新開,省握手。"
|
||||
|
||||
Classical chars = wenyan modes only. Never swap a word to a classical char to shrink at non-wenyan levels.
|
||||
|
||||
## Auto-Clarity
|
||||
|
||||
@@ -62,6 +74,8 @@ Drop caveman when:
|
||||
|
||||
Resume caveman after clear part done.
|
||||
|
||||
Example shows FORMAT only — write warning in session language, not example's.
|
||||
|
||||
Example — destructive op:
|
||||
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||
> ```sql
|
||||
@@ -71,4 +85,4 @@ Example — destructive op:
|
||||
|
||||
## Boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
Persisted outside chat: write normal prose — code, comments, commits, docs, issue/PR/MR text, memory files, third-party messages (/caveman-compress exempt). "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
@@ -32,6 +32,26 @@ Locate → fix → verify (most common):
|
||||
|
||||
Parallel scout: spawn 2-3 `cavecrew-investigator` calls in one message with different angles (defs, callers, tests). Aggregate in main.
|
||||
|
||||
## Model overrides
|
||||
|
||||
By default, `cavecrew-reviewer` and `cavecrew-investigator` pin `model: haiku` in their frontmatter; `cavecrew-builder` has no `model:` line (uses the API session default). Set env vars in your shell before launching Claude Code to override per-agent:
|
||||
|
||||
| Env var | Agent |
|
||||
|---|---|
|
||||
| `CAVECREW_REVIEWER_MODEL` | `cavecrew-reviewer` |
|
||||
| `CAVECREW_BUILDER_MODEL` | `cavecrew-builder` |
|
||||
| `CAVECREW_INVESTIGATOR_MODEL` | `cavecrew-investigator` |
|
||||
|
||||
Example — run reviewer on sonnet, keep others on default:
|
||||
|
||||
```sh
|
||||
export CAVECREW_REVIEWER_MODEL=sonnet
|
||||
```
|
||||
|
||||
Use the same model name strings you'd use in any Claude Code agent frontmatter (e.g. `haiku`, `sonnet`, `opus`).
|
||||
|
||||
Overrides patch only the `model:` line in the installed agent's frontmatter; the prompt body is untouched and keeps receiving upstream updates. Plugin installs only — standalone hook installs have no local agent files to patch. Unset or blank = no change. The patch persists in the installed file until the plugin is updated or reinstalled.
|
||||
|
||||
## See also
|
||||
|
||||
- [`SKILL.md`](./SKILL.md) — full decision matrix and output contracts
|
||||
|
||||
@@ -29,7 +29,7 @@ Write commit messages terse and exact. Conventional Commits format. No fluff. Wh
|
||||
**What NEVER goes in:**
|
||||
- "This commit does X", "I", "we", "now", "currently" — the diff says what
|
||||
- "As requested by..." — use Co-authored-by trailer
|
||||
- "Generated with Claude Code" or any AI attribution
|
||||
- "Generated with Claude Code" or any AI attribution — unless the user's own rule requires an `Assisted-by`/AI-attribution trailer, then add it as a trailer
|
||||
- Emoji (unless project convention requires)
|
||||
- Restating the file name when scope already says it
|
||||
|
||||
@@ -62,4 +62,4 @@ Always include body for: breaking changes, security fixes, data migrations, anyt
|
||||
|
||||
## Boundaries
|
||||
|
||||
Only generates the commit message. Does not run `git commit`, does not stage files, does not amend. Output the message as a code block ready to paste. "stop caveman-commit" or "normal mode": revert to verbose commit style.
|
||||
Only generates the commit message. Does not run `git commit`, does not stage files, does not amend. Output the message as a code block ready to paste. "stop caveman-commit" or "normal mode": revert to verbose commit style.
|
||||
|
||||
@@ -25,7 +25,7 @@ CLAUDE.md ← compressed (Claude reads this — fewer tokens every sess
|
||||
CLAUDE.original.md ← human-readable backup (you edit this)
|
||||
```
|
||||
|
||||
Original never lost. You can read and edit `.original.md`. Run skill again to re-compress after edits.
|
||||
Original never lost. Backup lives in a data dir, not next to your file — `$XDG_DATA_HOME/caveman-compress/backups/<parent-dir-name>/` (macOS/Linux) or `%LOCALAPPDATA%\caveman-compress\backups\<parent-dir-name>\` (Windows) — so skill auto-loaders don't re-read it as a live file. You can read and edit `.original.md` there. Run skill again to re-compress after edits.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
@@ -55,7 +55,7 @@ All validations passed ✅ — headings, code blocks, URLs, file paths preserved
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### 🪨 Caveman (285 tokens)
|
||||
### <img src="../../docs/assets/dancing-rock.svg" width="20" height="20" alt="rock"/> Caveman (285 tokens)
|
||||
|
||||
> "Prefer TypeScript strict mode always. No `any` unless unavoidable — comment why if used. Proper types catch bugs early."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
1. **subprocess usage**: The skill calls the `claude` CLI via `subprocess.run()` as a fallback when `ANTHROPIC_API_KEY` is not set. The subprocess call uses a fixed argument list — no shell interpolation occurs. User file content is passed via stdin, not as a shell argument.
|
||||
|
||||
2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result back to the same path. A `.original.md` backup is saved alongside it. No files outside the user-specified path are read or written.
|
||||
2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result back to the same path. A `.original.md` backup is saved to an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups/<parent-dir-name>/`, or `%LOCALAPPDATA%\caveman-compress\backups\<parent-dir-name>\` on Windows). Beyond the target file and that backup location, no files are read or written.
|
||||
|
||||
### What the skill does NOT do
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ description: >
|
||||
|
||||
## Purpose
|
||||
|
||||
Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `<filename>.original.md`.
|
||||
Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `<filename>.original.md`, but NOT beside the source file — it lives in an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups/<parent-dir-name>/`, or `%LOCALAPPDATA%\caveman-compress\backups\<parent-dir-name>\` on Windows) so skill auto-loaders don't re-ingest it as a live file.
|
||||
|
||||
## Trigger
|
||||
|
||||
@@ -107,5 +107,5 @@ Compressed:
|
||||
- NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh
|
||||
- If file has mixed content (prose + code), compress ONLY the prose sections
|
||||
- If unsure whether something is code or prose, leave it unchanged
|
||||
- Original file is backed up as FILE.original.md before overwriting
|
||||
- Original file is backed up as FILE.original.md before overwriting — in the out-of-tree backup data dir (see Purpose), not beside the source file
|
||||
- Never compress FILE.original.md (skip it)
|
||||
|
||||
@@ -23,8 +23,8 @@ def count_tokens(text):
|
||||
|
||||
|
||||
def benchmark_pair(orig_path: Path, comp_path: Path):
|
||||
orig_text = orig_path.read_text()
|
||||
comp_text = comp_path.read_text()
|
||||
orig_text = orig_path.read_text(encoding="utf-8", errors="ignore")
|
||||
comp_text = comp_path.read_text(encoding="utf-8", errors="ignore")
|
||||
|
||||
orig_tokens = count_tokens(orig_text)
|
||||
comp_tokens = count_tokens(comp_text)
|
||||
|
||||
@@ -21,7 +21,7 @@ for _stream in (sys.stdout, sys.stderr):
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .compress import compress_file
|
||||
from .compress import backup_dir_for, compress_file
|
||||
from .detect import detect_file_type, should_compress
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ def main():
|
||||
|
||||
if success:
|
||||
print("\nCompression completed successfully")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
backup_path = backup_dir_for(filepath) / (filepath.stem + ".original.md")
|
||||
print(f"Compressed: {filepath}")
|
||||
print(f"Original: {backup_path}")
|
||||
sys.exit(0)
|
||||
|
||||
@@ -8,7 +8,11 @@ Usage:
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
@@ -16,6 +20,27 @@ OUTER_FENCE_REGEX = re.compile(
|
||||
r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
|
||||
)
|
||||
|
||||
# YAML frontmatter: starts at file start with --- on its own line, ends with --- on its own line.
|
||||
# Captures the entire block (including delimiters and trailing newline) and the body after.
|
||||
FRONTMATTER_REGEX = re.compile(
|
||||
r"\A(---\r?\n.*?\r?\n---\r?\n)(.*)", re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def split_frontmatter(text: str):
|
||||
"""Split YAML frontmatter from body. Returns (frontmatter, body).
|
||||
|
||||
Memory files (and many other markdown docs) start with a YAML frontmatter
|
||||
block delimited by `---` lines. The compression LLM has a habit of stripping
|
||||
or rewriting these despite preserve-structure rules in the prompt — so we
|
||||
surgically remove the frontmatter before compression and prepend it back
|
||||
verbatim to the output. Files without frontmatter pass through unchanged.
|
||||
"""
|
||||
m = FRONTMATTER_REGEX.match(text)
|
||||
if m:
|
||||
return m.group(1), m.group(2)
|
||||
return "", text
|
||||
|
||||
# Filenames and paths that almost certainly hold secrets or PII. Compressing
|
||||
# them ships raw bytes to the Anthropic API — a third-party data boundary that
|
||||
# developers on sensitive codebases cannot cross. detect.py already skips .env
|
||||
@@ -43,6 +68,30 @@ SENSITIVE_NAME_TOKENS = (
|
||||
)
|
||||
|
||||
|
||||
def backup_dir_for(filepath: Path) -> Path:
|
||||
"""Resolve the out-of-tree backup directory for a given source file.
|
||||
|
||||
Backups must live OUTSIDE the source directory so skill auto-loaders
|
||||
(Claude Code rules/, opencode instructions/, etc.) stop re-ingesting the
|
||||
`.original.md` copies as live files. Base dir is platform-aware:
|
||||
- Windows: %LOCALAPPDATA%\\caveman-compress\\backups
|
||||
- else: $XDG_DATA_HOME/caveman-compress/backups if set,
|
||||
else ~/.local/share/caveman-compress/backups
|
||||
|
||||
The source file's parent-dir name is mirrored under the base to reduce
|
||||
cross-project collisions (e.g. two `task.md` files in different repos).
|
||||
"""
|
||||
if os.name == "nt" or sys.platform == "win32":
|
||||
local_appdata = os.environ.get("LOCALAPPDATA")
|
||||
base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
|
||||
base = base / "caveman-compress" / "backups"
|
||||
else:
|
||||
xdg = os.environ.get("XDG_DATA_HOME")
|
||||
base = Path(xdg) if xdg else Path.home() / ".local" / "share"
|
||||
base = base / "caveman-compress" / "backups"
|
||||
return base / filepath.parent.name
|
||||
|
||||
|
||||
def is_sensitive_path(filepath: Path) -> bool:
|
||||
"""Heuristic denylist for files that must never be shipped to a third-party API."""
|
||||
name = filepath.name
|
||||
@@ -63,6 +112,59 @@ def strip_llm_wrapper(text: str) -> str:
|
||||
return m.group(2)
|
||||
return text
|
||||
|
||||
|
||||
def write_text_atomic(path: Path, text: str) -> None:
|
||||
"""Write ``text`` to ``path`` atomically as UTF-8.
|
||||
|
||||
Path.write_text() truncates the destination before encoding the string —
|
||||
a UnicodeEncodeError (or any other failure) partway through leaves a
|
||||
0-byte file, destroying whatever was there before (issue #655). Encode
|
||||
first, write the bytes to a sibling temp file, fsync, then os.replace()
|
||||
so the destination only ever moves from one complete, valid file to
|
||||
another. Preserves the original file's permission bits across the swap.
|
||||
"""
|
||||
data = text.encode("utf-8")
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(path.parent), prefix=path.name + ".", suffix=".tmp"
|
||||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
if path.exists():
|
||||
os.chmod(tmp_path, stat.S_IMODE(path.stat().st_mode))
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def first_nonblank_line(text: str) -> str:
|
||||
"""Return the first non-blank line, stripped — used to detect a prose
|
||||
preamble smuggled in ahead of the real content (issue #588)."""
|
||||
for line in text.splitlines():
|
||||
if line.strip():
|
||||
return line.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _write_target(filepath: Path, text: str, backup_path: Path) -> None:
|
||||
"""Write to the target file, surfacing the backup location if the write
|
||||
itself fails. write_text_atomic already leaves the target untouched on
|
||||
failure, but the caller still needs to know where the pre-compression
|
||||
original lives instead of being left to guess (issue #652)."""
|
||||
try:
|
||||
write_text_atomic(filepath, text)
|
||||
except Exception:
|
||||
print(f"❌ Write to {filepath} failed. Original preserved at backup: {backup_path}")
|
||||
raise
|
||||
|
||||
|
||||
from .detect import should_compress
|
||||
from .validate import validate
|
||||
|
||||
@@ -73,6 +175,18 @@ MAX_RETRIES = 2
|
||||
|
||||
|
||||
def call_claude(prompt: str) -> str:
|
||||
"""Send a prompt to Claude.
|
||||
|
||||
Prefers the Anthropic SDK when ANTHROPIC_API_KEY is set; otherwise falls
|
||||
back to the ``claude --print`` CLI (which handles desktop auth).
|
||||
|
||||
On Windows the CLI subprocess decoding defaults to the system codepage
|
||||
(cp1251 / cp1252) and crashes on UTF-8 output — see issue #152. Pinning
|
||||
``encoding="utf-8"`` with ``errors="replace"`` matches the CLI's actual
|
||||
native I/O and prevents the UnicodeDecodeError before validation can
|
||||
report. Windows users with non-ASCII content can also set
|
||||
``ANTHROPIC_API_KEY`` to route through the SDK and skip the subprocess.
|
||||
"""
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if api_key:
|
||||
try:
|
||||
@@ -87,14 +201,22 @@ def call_claude(prompt: str) -> str:
|
||||
return strip_llm_wrapper(msg.content[0].text.strip())
|
||||
except ImportError:
|
||||
pass # anthropic not installed, fall back to CLI
|
||||
# Fallback: use claude CLI (handles desktop auth)
|
||||
# Fallback: use claude CLI (handles desktop auth).
|
||||
# Resolve binary via shutil.which so Windows .cmd/.bat shims (e.g.
|
||||
# %APPDATA%\npm\claude.CMD) work without shell=True. On POSIX,
|
||||
# shutil.which returns the same absolute path as the implicit lookup,
|
||||
# so this is a no-op there. Falls back to bare "claude" if not found
|
||||
# on PATH so subprocess raises a clear FileNotFoundError.
|
||||
claude_bin = shutil.which("claude") or "claude"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "--print"],
|
||||
[claude_bin, "--print"],
|
||||
input=prompt,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return strip_llm_wrapper(result.stdout.strip())
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -179,8 +301,13 @@ def compress_file(filepath: Path) -> bool:
|
||||
print("Skipping (not natural language)")
|
||||
return False
|
||||
|
||||
original_text = filepath.read_text(errors="ignore")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
original_text = filepath.read_text(encoding="utf-8", errors="ignore")
|
||||
# Store backup outside the source directory so skill auto-loaders don't
|
||||
# re-ingest the `.original.md` copy as a live file. Mirror the source's
|
||||
# parent-dir name + stem under a platform-aware base to reduce collisions.
|
||||
backup_dir = backup_dir_for(filepath)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_path = backup_dir / (filepath.stem + ".original.md")
|
||||
|
||||
if not original_text.strip():
|
||||
print("❌ Refusing to compress: file is empty or whitespace-only.")
|
||||
@@ -193,27 +320,43 @@ def compress_file(filepath: Path) -> bool:
|
||||
print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.")
|
||||
return False
|
||||
|
||||
# Step 1: Compress
|
||||
print("Compressing with Claude...")
|
||||
compressed = call_claude(build_compress_prompt(original_text))
|
||||
# Split YAML frontmatter off before compression. Claude tends to strip or
|
||||
# rewrite frontmatter despite preserve-structure rules; we keep it verbatim
|
||||
# by removing it from the input and re-prepending it to the output.
|
||||
frontmatter, body = split_frontmatter(original_text)
|
||||
if frontmatter:
|
||||
print(f"Detected YAML frontmatter ({len(frontmatter)} chars) — preserving verbatim")
|
||||
|
||||
if compressed is None or not compressed.strip():
|
||||
if not body.strip():
|
||||
print("❌ Refusing to compress: body is empty after frontmatter removal.")
|
||||
return False
|
||||
|
||||
# Step 1: Compress (body only, frontmatter excluded)
|
||||
print("Compressing with Claude...")
|
||||
compressed_body = call_claude(build_compress_prompt(body))
|
||||
|
||||
if compressed_body is None or not compressed_body.strip():
|
||||
print("❌ Compression aborted: Claude returned an empty response.")
|
||||
print(" Original file is untouched (no backup created).")
|
||||
return False
|
||||
|
||||
if compressed.strip() == original_text.strip():
|
||||
# Compare the BODY (not the whole file) — frontmatter is preserved verbatim
|
||||
# and would never change, so identity must be judged on the compressible part.
|
||||
if compressed_body.strip() == body.strip():
|
||||
print("❌ Compression aborted: output is identical to input.")
|
||||
print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
|
||||
print(" already in caveman form. Original file is untouched (no backup created).")
|
||||
return False
|
||||
|
||||
# Reassemble: frontmatter (verbatim) + compressed body
|
||||
compressed = frontmatter + compressed_body
|
||||
|
||||
# Save original as backup, then verify the backup readback before
|
||||
# touching the input file. If the filesystem dropped bytes (encoding,
|
||||
# antivirus, disk full), unlink the bad backup and abort instead of
|
||||
# leaving the user with a corrupt backup + compressed primary.
|
||||
backup_path.write_text(original_text)
|
||||
backup_readback = backup_path.read_text(errors="ignore")
|
||||
write_text_atomic(backup_path, original_text)
|
||||
backup_readback = backup_path.read_text(encoding="utf-8", errors="ignore")
|
||||
if backup_readback != original_text:
|
||||
print(f"❌ Backup write verification failed: {backup_path}")
|
||||
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
|
||||
@@ -222,7 +365,7 @@ def compress_file(filepath: Path) -> bool:
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
filepath.write_text(compressed)
|
||||
_write_target(filepath, compressed, backup_path)
|
||||
|
||||
# Step 2: Validate + Retry
|
||||
for attempt in range(MAX_RETRIES):
|
||||
@@ -240,7 +383,7 @@ def compress_file(filepath: Path) -> bool:
|
||||
|
||||
if attempt == MAX_RETRIES - 1:
|
||||
# Restore original on failure
|
||||
filepath.write_text(original_text)
|
||||
_write_target(filepath, original_text, backup_path)
|
||||
backup_path.unlink(missing_ok=True)
|
||||
print("❌ Failed after retries — original restored")
|
||||
return False
|
||||
@@ -249,6 +392,23 @@ def compress_file(filepath: Path) -> bool:
|
||||
compressed = call_claude(
|
||||
build_fix_prompt(original_text, compressed, result.errors)
|
||||
)
|
||||
filepath.write_text(compressed)
|
||||
|
||||
if compressed is None or not compressed.strip():
|
||||
print("❌ Fix attempt aborted: Claude returned an empty response.")
|
||||
print(" Skipping this attempt.")
|
||||
continue
|
||||
|
||||
# Guard against a prose preamble smuggled in ahead of the real fixed
|
||||
# content (issue #588). Only enforced when the original starts with a
|
||||
# structural anchor (frontmatter `---` or a heading) — plain-prose
|
||||
# first lines get legitimately rewritten by compression, and requiring
|
||||
# them verbatim would reject every valid fix.
|
||||
anchor = first_nonblank_line(original_text)
|
||||
if anchor.startswith(("---", "#")) and first_nonblank_line(compressed) != anchor:
|
||||
print("❌ Fix attempt aborted: output does not start with the original's first line.")
|
||||
print(" Possible preamble leak. Skipping this attempt.")
|
||||
continue
|
||||
|
||||
_write_target(filepath, compressed, backup_path)
|
||||
|
||||
return True
|
||||
|
||||
@@ -17,6 +17,16 @@ SKIP_EXTENSIONS = {
|
||||
".dockerfile", ".makefile", ".csv", ".ini", ".cfg",
|
||||
}
|
||||
|
||||
# Well-known build/config files that carry no (or a misleading) extension —
|
||||
# `Dockerfile` has no suffix so `.dockerfile` above never matches it, and
|
||||
# `CMakeLists.txt` would ride the compressible `.txt` rule. Checked by
|
||||
# basename before any extension rule.
|
||||
KNOWN_CODE_FILENAMES = {
|
||||
"dockerfile", "makefile", "gnumakefile", "jenkinsfile", "vagrantfile",
|
||||
"rakefile", "gemfile", "justfile", "procfile", "brewfile",
|
||||
"cmakelists.txt",
|
||||
}
|
||||
|
||||
# Patterns that indicate a line is code
|
||||
CODE_PATTERNS = [
|
||||
re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"),
|
||||
@@ -67,6 +77,10 @@ def detect_file_type(filepath: Path) -> str:
|
||||
"""
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
# Known code filenames win over any extension rule
|
||||
if filepath.name.lower() in KNOWN_CODE_FILENAMES:
|
||||
return "code"
|
||||
|
||||
# Extension-based classification
|
||||
if ext in COMPRESSIBLE_EXTENSIONS:
|
||||
return "natural_language"
|
||||
@@ -76,12 +90,16 @@ def detect_file_type(filepath: Path) -> str:
|
||||
# Extensionless files (like CLAUDE.md, TODO) — check content
|
||||
if not ext:
|
||||
try:
|
||||
text = filepath.read_text(errors="ignore")
|
||||
text = filepath.read_text(encoding="utf-8", errors="ignore")
|
||||
except (OSError, PermissionError):
|
||||
return "unknown"
|
||||
|
||||
lines = text.splitlines()[:50]
|
||||
|
||||
# Shebang means executable script, never prose
|
||||
if text.startswith("#!"):
|
||||
return "code"
|
||||
|
||||
if _is_json_content(text[:10000]):
|
||||
return "config"
|
||||
if _is_yaml_content(lines):
|
||||
|
||||
@@ -28,7 +28,7 @@ class ValidationResult:
|
||||
|
||||
|
||||
def read_file(path: Path) -> str:
|
||||
return path.read_text(errors="ignore")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------- Extractors ----------
|
||||
@@ -95,8 +95,16 @@ def count_bullets(text):
|
||||
|
||||
|
||||
def extract_inline_codes(text):
|
||||
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
|
||||
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
|
||||
"""Backtick-delimited inline spans, with fenced code blocks stripped first.
|
||||
|
||||
Previously used a column-0-anchored regex to strip fences, which misses
|
||||
fences indented 1-3 spaces (valid CommonMark). Reuse extract_code_blocks
|
||||
(FENCE_OPEN_REGEX-based, indentation-aware) instead so an indented fence's
|
||||
body backticks don't leak into inline-code pairing.
|
||||
"""
|
||||
text_without_fences = text
|
||||
for block in extract_code_blocks(text):
|
||||
text_without_fences = text_without_fences.replace(block, "", 1)
|
||||
return re.findall(r"`([^`]+)`", text_without_fences)
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ Mode stick until changed or session end.
|
||||
|
||||
Say "stop caveman" or "normal mode". Resume anytime with `/caveman`.
|
||||
|
||||
## Language
|
||||
|
||||
Keep user's language by default. User write Portuguese → reply Portuguese caveman. Compress the style, not the language. Technical terms, code, commands, commit types, and exact error strings stay verbatim unless user ask for translation.
|
||||
|
||||
## Configure Default Mode
|
||||
|
||||
Default mode = `full`. Change it:
|
||||
@@ -45,7 +49,7 @@ Default mode = `full`. Change it:
|
||||
export CAVEMAN_DEFAULT_MODE=ultra
|
||||
```
|
||||
|
||||
**Config file** (`~/.config/caveman/config.json`):
|
||||
**Config file** (`~/.config/caveman/config.json` macOS/Linux, `%APPDATA%\caveman\config.json` Windows):
|
||||
```json
|
||||
{ "defaultMode": "lite" }
|
||||
```
|
||||
|
||||
@@ -6,7 +6,9 @@ Real session token receipts. No AI estimation.
|
||||
|
||||
Reads the current Claude Code session log directly and reports actual input/output token usage plus estimated savings versus a non-caveman baseline. Numbers come from the JSONL session log on disk — the model itself does not compute or estimate them. Output is injected by the `caveman-mode-tracker` hook, which intercepts `/caveman-stats` and returns the formatted stats as a blocked-decision reason.
|
||||
|
||||
Each run also writes a lifetime-savings suffix file used by the statusline badge (`⛏ 12.4k`).
|
||||
Output also includes an `Est. rule overhead` and `Est. net` line whenever the savings figure above them is unambiguous (a single benchmarked mode with a known turn count — no guessing across mixed or unattributed spans). Overhead estimates the per-turn INPUT-token cost of the rules the skill injects every turn — default 1,250 tokens/turn, override with `CAVEMAN_RULE_OVERHEAD_TOKENS` if you've measured your own setup. Net is savings minus that overhead. On short, terse replies this can go negative — caveman's OUTPUT savings don't clear its INPUT cost — and the line says so directly instead of hiding it behind a gross-savings number. Background: `docs/HONEST-NUMBERS.md`.
|
||||
|
||||
Each run also writes a lifetime-savings suffix file used by the statusline badge (`⛏ 12.4k`). That badge stays a gross-savings figure on purpose — it is a glanceable summary, not a full accounting; run `/caveman-stats` for the net picture.
|
||||
|
||||
## How to invoke
|
||||
|
||||
@@ -22,8 +24,12 @@ Input: 12,304 tokens
|
||||
Output: 3,891 tokens (caveman)
|
||||
Baseline: 11,247 tokens (estimated without caveman)
|
||||
Saved: 7,356 tokens (~65%)
|
||||
Est. rule overhead: 58,750 (input, ~1,250/turn over 47 turns)
|
||||
Est. net: -51,394 (caveman cost more than it saved for this workload — consider turning it off)
|
||||
```
|
||||
|
||||
(Numbers above are illustrative — see `docs/HONEST-NUMBERS.md` for why short, terse-reply sessions tend to land net-negative even at a healthy output-savings percentage.)
|
||||
|
||||
## See also
|
||||
|
||||
- [`SKILL.md`](./SKILL.md) — hook contract and mechanics
|
||||
|
||||
@@ -8,3 +8,5 @@ description: >
|
||||
---
|
||||
|
||||
This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately.
|
||||
|
||||
Output also includes `Est. rule overhead` and `Est. net` lines wherever a savings estimate exists with a known turn count. Rule overhead is the estimated per-turn INPUT-token cost of the injected caveman rules (default 1,250 tokens/turn, override with `CAVEMAN_RULE_OVERHEAD_TOKENS`) times the turn count. Net is savings minus that overhead — when negative, the output says so plainly and suggests turning caveman off for that workload, rather than hiding the net-negative regime behind a gross-savings number (see `docs/HONEST-NUMBERS.md`).
|
||||
|
||||
@@ -4,7 +4,7 @@ Talk like smart caveman. Same brain, fewer tokens.
|
||||
|
||||
## What it does
|
||||
|
||||
Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts ~65-75% of output tokens with full accuracy preserved. Mode persists for the whole session until changed or stopped.
|
||||
Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts 65% of output tokens (measured) with full accuracy preserved. Mode persists for the whole session until changed or stopped.
|
||||
|
||||
Six intensity levels:
|
||||
|
||||
|
||||
+27
-13
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||
Ultra-compressed communication mode. Cuts output tokens 65% (measured) by speaking like caveman
|
||||
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||
@@ -14,11 +14,21 @@ Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
|
||||
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off`.
|
||||
|
||||
## Rules
|
||||
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) — tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either — own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
|
||||
Never drop not/never/no/only/except — flip meaning worse than any token saved. Numbers, units exact.
|
||||
|
||||
Tool calls: fire direct. No preamble, plan, or progress note before or between calls. After result: next call direct or final answer — never announce next call. Text before call only to clarify, warn security/irreversible, or resolve ambiguity.
|
||||
|
||||
Preserve user's dominant language exactly — reply in the language user writes, never switch regardless of example text or multilingual context elsewhere. Compress the style, not the language. Every emitted line in that language — openings, pre-tool status lines, all — not just final reply. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
|
||||
|
||||
'Drop articles' = article languages only. Where small markers carry case/role (particles, postpositions), keep them — grammar, not filler; compress politeness/filler instead.
|
||||
|
||||
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
|
||||
|
||||
Pattern: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
@@ -30,26 +40,28 @@ Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
| Level | What change |
|
||||
|-------|------------|
|
||||
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
|
||||
| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
|
||||
| **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) — measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch |
|
||||
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction — chars, not tokens. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||
|
||||
Example — "Why React component re-render?"
|
||||
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||
- ultra: "Inline obj prop, new ref, re-render. `useMemo`."
|
||||
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||
- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
|
||||
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||
- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
|
||||
- wenyan-ultra: "新參照則重繪。useMemo 包之。"
|
||||
|
||||
Example — "Explain database connection pooling."
|
||||
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||
- ultra: "Pool reuse open DB connections. No per-request handshake."
|
||||
- wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。"
|
||||
- wenyan-ultra: "池蓄連,免逐請新開,省握手。"
|
||||
|
||||
Classical chars = wenyan modes only. Never swap a word to a classical char to shrink at non-wenyan levels.
|
||||
|
||||
## Auto-Clarity
|
||||
|
||||
@@ -62,6 +74,8 @@ Drop caveman when:
|
||||
|
||||
Resume caveman after clear part done.
|
||||
|
||||
Example shows FORMAT only — write warning in session language, not example's.
|
||||
|
||||
Example — destructive op:
|
||||
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||
> ```sql
|
||||
@@ -71,4 +85,4 @@ Example — destructive op:
|
||||
|
||||
## Boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
Persisted outside chat: write normal prose — code, comments, commits, docs, issue/PR/MR text, memory files, third-party messages (/caveman-compress exempt). "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
These hooks are **bundled with the caveman plugin** and activate automatically when the plugin is installed. No manual setup required.
|
||||
|
||||
If you installed caveman standalone (without the plugin), the unified Node installer at `bin/install.js` wires them into your `settings.json` for you — run `node bin/install.js --only claude` from a clone, or `npx -y github:JuliusBrussee/caveman -- --only claude` for the curl-pipe path.
|
||||
If you installed caveman standalone (without the plugin), the unified Node installer at `cli/install.js` wires them into your `settings.json` for you — run `node cli/install.js --only claude` from a clone, or `npx -y github:JuliusBrussee/caveman -- --only claude` for the curl-pipe path.
|
||||
|
||||
## What's Included
|
||||
|
||||
@@ -34,7 +34,7 @@ The statusline badge shows which caveman mode is active directly in your Claude
|
||||
|
||||
If you already have a custom statusline, caveman does not overwrite it and Claude stays quiet. Add the badge snippet to your existing script instead.
|
||||
|
||||
**Standalone users:** the unified installer (`bin/install.js`, invoked by the `install.sh` / `install.ps1` shims at the repo root) wires the statusline automatically if you do not already have a custom statusline. If you do, the installer leaves it alone and prints the merge note.
|
||||
**Standalone users:** the unified installer (`cli/install.js`, invoked by the `install.sh` / `install.ps1` shims at the repo root) wires the statusline automatically if you do not already have a custom statusline. If you do, the installer leaves it alone and prints the merge note.
|
||||
|
||||
**Manual setup:** If you need to configure it yourself, add one of these to `~/.claude/settings.json`:
|
||||
|
||||
@@ -102,7 +102,7 @@ If installed via the standalone Node installer:
|
||||
```bash
|
||||
npx -y github:JuliusBrussee/caveman -- --uninstall
|
||||
# or, from a clone:
|
||||
node bin/install.js --uninstall
|
||||
node cli/install.js --uninstall
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
// cavecrew model overrides — patch installed agent frontmatter from env vars.
|
||||
//
|
||||
// Called by caveman-activate.js early in SessionStart so users can pin
|
||||
// per-agent models without shadow-copying entire agent files.
|
||||
//
|
||||
// Env vars:
|
||||
// CAVECREW_REVIEWER_MODEL → agents/cavecrew-reviewer.md
|
||||
// CAVECREW_BUILDER_MODEL → agents/cavecrew-builder.md
|
||||
// CAVECREW_INVESTIGATOR_MODEL → agents/cavecrew-investigator.md
|
||||
//
|
||||
// Rules:
|
||||
// - Unset / blank → no-op.
|
||||
// - Values containing newlines or control characters → ignored.
|
||||
// - Existing `model:` line in frontmatter → replaced in-place.
|
||||
// - No `model:` line → inserted after `tools:` (or before closing `---`).
|
||||
// - File missing / outside plugin layout → silent no-op.
|
||||
// - All filesystem errors → silent fail.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const AGENT_ENV_MAP = [
|
||||
{ envVar: 'CAVECREW_REVIEWER_MODEL', file: path.join('agents', 'cavecrew-reviewer.md') },
|
||||
{ envVar: 'CAVECREW_BUILDER_MODEL', file: path.join('agents', 'cavecrew-builder.md') },
|
||||
{ envVar: 'CAVECREW_INVESTIGATOR_MODEL', file: path.join('agents', 'cavecrew-investigator.md') },
|
||||
];
|
||||
|
||||
// Return the plugin root directory given the hooks directory path.
|
||||
// Layouts (#645): plugin/repo checkout puts this file at <root>/src/hooks/
|
||||
// (agents/ lives two levels up); standalone installs at <config>/hooks/
|
||||
// (one level up). Prefer CLAUDE_PLUGIN_ROOT, then the first candidate that
|
||||
// actually contains an agents/ directory.
|
||||
function resolvePluginRoot(hookDir) {
|
||||
const candidates = [];
|
||||
if (process.env.CLAUDE_PLUGIN_ROOT) candidates.push(process.env.CLAUDE_PLUGIN_ROOT);
|
||||
candidates.push(path.resolve(hookDir, '..', '..'), path.resolve(hookDir, '..'));
|
||||
for (const root of candidates) {
|
||||
try {
|
||||
if (fs.statSync(path.join(root, 'agents')).isDirectory()) return root;
|
||||
} catch (e) {}
|
||||
}
|
||||
return path.resolve(hookDir, '..');
|
||||
}
|
||||
|
||||
// Patch the YAML frontmatter of `content` to set `model: <modelValue>`.
|
||||
// Returns the patched string, or the original if no frontmatter or already identical.
|
||||
// Rejects `modelValue` strings that contain newlines or control characters.
|
||||
function patchFrontmatterModel(content, modelValue) {
|
||||
// Reject blank or unsafe model strings
|
||||
if (!modelValue || /[\x00-\x1f\x7f]/.test(modelValue)) return content;
|
||||
|
||||
// Must begin with YAML frontmatter delimiter
|
||||
if (!content.startsWith('---')) return content;
|
||||
|
||||
// Find the closing ---
|
||||
const closeIdx = content.indexOf('\n---', 3);
|
||||
if (closeIdx === -1) return content;
|
||||
|
||||
const fmRaw = content.slice(0, closeIdx); // opening --- through last fm line
|
||||
const after = content.slice(closeIdx); // \n--- onward (body)
|
||||
|
||||
// Preserve original line ending so we don't create mixed CRLF/LF on Windows
|
||||
const nl = fmRaw.includes('\r\n') ? '\r\n' : '\n';
|
||||
|
||||
const modelLine = 'model: ' + modelValue;
|
||||
const modelRe = /^model:[ \t]*.*$/m;
|
||||
|
||||
if (modelRe.test(fmRaw)) {
|
||||
// Replace existing model: line
|
||||
const patched = fmRaw.replace(modelRe, modelLine);
|
||||
if (patched === fmRaw) return content; // already identical
|
||||
return patched + after;
|
||||
}
|
||||
|
||||
// Insert after tools: line when present; else before closing ---
|
||||
const toolsMatch = fmRaw.match(/^tools:[ \t]*.*$/m);
|
||||
if (toolsMatch) {
|
||||
const toolsEnd = fmRaw.indexOf(toolsMatch[0]) + toolsMatch[0].length;
|
||||
return fmRaw.slice(0, toolsEnd) + nl + modelLine + fmRaw.slice(toolsEnd) + after;
|
||||
}
|
||||
|
||||
// Append before closing delimiter
|
||||
return fmRaw + nl + modelLine + after;
|
||||
}
|
||||
|
||||
// Apply all env-var overrides to agent files under `pluginRoot`.
|
||||
// `env` defaults to process.env; pass an object in tests.
|
||||
function applyOverrides(pluginRoot, env) {
|
||||
const envArg = env || process.env;
|
||||
for (const { envVar, file } of AGENT_ENV_MAP) {
|
||||
const raw = envArg[envVar];
|
||||
if (!raw || !raw.trim()) continue;
|
||||
|
||||
const modelValue = raw.trim();
|
||||
if (/[\x00-\x1f\x7f]/.test(modelValue)) continue;
|
||||
|
||||
const agentPath = path.join(pluginRoot, file);
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(agentPath, 'utf8');
|
||||
} catch (e) {
|
||||
continue; // missing file or wrong layout → silent no-op
|
||||
}
|
||||
|
||||
const patched = patchFrontmatterModel(content, modelValue);
|
||||
if (patched === content) continue;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(agentPath, patched, 'utf8');
|
||||
} catch (e) {
|
||||
// Silent fail — never block session start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { resolvePluginRoot, patchFrontmatterModel, applyOverrides, AGENT_ENV_MAP };
|
||||
@@ -9,22 +9,54 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { getDefaultMode, safeWriteFlag } = require('./caveman-config');
|
||||
const { getDefaultMode, safeWriteFlag, recordModeChange, readFlag, VALID_MODES } = require('./caveman-config');
|
||||
|
||||
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const flagPath = path.join(claudeDir, '.caveman-active');
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
|
||||
const mode = getDefaultMode();
|
||||
// Apply per-agent model overrides from env vars before emitting rules.
|
||||
// Best-effort: any error is swallowed so SessionStart is never blocked.
|
||||
try {
|
||||
const { applyOverrides, resolvePluginRoot } = require('./cavecrew-model-overrides');
|
||||
applyOverrides(resolvePluginRoot(__dirname));
|
||||
} catch (e) {}
|
||||
|
||||
// SessionStart re-fires mid-conversation (resume, /clear, context compaction),
|
||||
// not just at true session start. Re-firing must not clobber a mode the user
|
||||
// switched to mid-session (#691): branch on the hook payload's `source` field —
|
||||
// only a real `startup` resets to the configured default; resume/clear/compact
|
||||
// preserve a valid existing flag.
|
||||
// Sync stdin read assumes the parent (Claude Code) writes the payload and
|
||||
// closes the pipe — it always does. A parent that held the pipe open forever
|
||||
// would block here; no such caller exists, and a TTY (manual run) skips it.
|
||||
let source = 'startup';
|
||||
try {
|
||||
if (!process.stdin.isTTY) {
|
||||
const raw = fs.readFileSync(0, 'utf8');
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw);
|
||||
if (data && typeof data.source === 'string') source = data.source;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* no/bad stdin → treat as startup */ }
|
||||
|
||||
let mode = getDefaultMode();
|
||||
if (source !== 'startup') {
|
||||
const existing = readFlag(flagPath);
|
||||
if (existing && VALID_MODES.includes(existing)) mode = existing;
|
||||
}
|
||||
|
||||
// "off" mode — skip activation entirely, don't write flag or emit rules
|
||||
if (mode === 'off') {
|
||||
recordModeChange(claudeDir, null); // #601: timestamped transition log
|
||||
try { fs.unlinkSync(flagPath); } catch (e) {}
|
||||
process.stdout.write('OK');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Write flag file (symlink-safe)
|
||||
recordModeChange(claudeDir, mode); // #601
|
||||
safeWriteFlag(flagPath, mode);
|
||||
|
||||
// 2. Emit full caveman ruleset, filtered to the active intensity level.
|
||||
@@ -48,14 +80,32 @@ if (INDEPENDENT_MODES.has(mode)) {
|
||||
const modeLabel = mode === 'wenyan' ? 'wenyan-full' : mode;
|
||||
|
||||
// Read SKILL.md — the single source of truth for caveman behavior.
|
||||
// Plugin installs: __dirname = <plugin_root>/hooks/, SKILL.md at <plugin_root>/skills/caveman/SKILL.md
|
||||
// Standalone installs: __dirname = $CLAUDE_CONFIG_DIR/hooks/, SKILL.md won't exist — falls back to hardcoded rules.
|
||||
// Candidate locations, tried in order (#587/#589 — the old single '..' path
|
||||
// resolved to <plugin_root>/src/skills/, which doesn't exist, so plugin
|
||||
// installs silently used the stale fallback ruleset):
|
||||
// 1. $CLAUDE_PLUGIN_ROOT/skills/caveman/SKILL.md — Claude Code sets
|
||||
// CLAUDE_PLUGIN_ROOT when invoking plugin hooks; authoritative when present.
|
||||
// 2. ../../skills/caveman/SKILL.md — hook at <plugin_root>/src/hooks/
|
||||
// (plugin.json layout) or a repo checkout.
|
||||
// 3. ../skills/caveman/SKILL.md — standalone install with hooks at
|
||||
// $CLAUDE_CONFIG_DIR/hooks/ and the skill at $CLAUDE_CONFIG_DIR/skills/caveman/.
|
||||
// All misses fall through to the hardcoded fallback ruleset below.
|
||||
const skillCandidates = [];
|
||||
if (process.env.CLAUDE_PLUGIN_ROOT) {
|
||||
skillCandidates.push(path.join(process.env.CLAUDE_PLUGIN_ROOT, 'skills', 'caveman', 'SKILL.md'));
|
||||
}
|
||||
skillCandidates.push(
|
||||
path.join(__dirname, '..', '..', 'skills', 'caveman', 'SKILL.md'),
|
||||
path.join(__dirname, '..', 'skills', 'caveman', 'SKILL.md')
|
||||
);
|
||||
|
||||
let skillContent = '';
|
||||
try {
|
||||
skillContent = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'skills', 'caveman', 'SKILL.md'), 'utf8'
|
||||
);
|
||||
} catch (e) { /* standalone install — will use fallback below */ }
|
||||
for (const candidate of skillCandidates) {
|
||||
try {
|
||||
skillContent = fs.readFileSync(candidate, 'utf8');
|
||||
break;
|
||||
} catch (e) { /* try next candidate */ }
|
||||
}
|
||||
|
||||
let output;
|
||||
|
||||
@@ -101,6 +151,8 @@ if (skillContent) {
|
||||
'## Rules\n\n' +
|
||||
'Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. ' +
|
||||
'Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.\n\n' +
|
||||
"Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. Compress the style, not the language. Technical terms, code, API names, commands, error strings stay verbatim.\n\n" +
|
||||
'No self-reference. Never name or announce the style. No "caveman mode on" tags. Output caveman-only.\n\n' +
|
||||
'Pattern: `[thing] [action] [reason]. [next step].`\n\n' +
|
||||
'Not: "Sure! I\'d be happy to help you with that. The issue you\'re experiencing is likely caused by..."\n' +
|
||||
'Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"\n\n' +
|
||||
@@ -110,7 +162,10 @@ if (skillContent) {
|
||||
'Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.';
|
||||
}
|
||||
|
||||
// 3. Detect missing statusline config — nudge Claude to help set it up
|
||||
// 3. Detect missing statusline config — nudge Claude to help set it up.
|
||||
// One-shot (#661): the nudge costs ~90 tokens per session, so a marker file
|
||||
// gates it to the first session only. Users who declined stop paying for it.
|
||||
const nudgeMarkerPath = path.join(claudeDir, '.caveman-nudge-shown');
|
||||
try {
|
||||
let hasStatusline = false;
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
@@ -120,7 +175,8 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasStatusline) {
|
||||
if (!hasStatusline && !fs.existsSync(nudgeMarkerPath)) {
|
||||
safeWriteFlag(nudgeMarkerPath, '1');
|
||||
const isWindows = process.platform === 'win32';
|
||||
const scriptName = isWindows ? 'caveman-statusline.ps1' : 'caveman-statusline.sh';
|
||||
const scriptPath = path.join(__dirname, scriptName);
|
||||
|
||||
+132
-23
@@ -3,11 +3,17 @@
|
||||
//
|
||||
// Resolution order for default mode:
|
||||
// 1. CAVEMAN_DEFAULT_MODE environment variable
|
||||
// 2. Config file defaultMode field:
|
||||
// 2. Repo-local config (checked-in, per-project default):
|
||||
// - <cwd>/.caveman/config.json
|
||||
// - <cwd>/.caveman.json
|
||||
// Walks up from process.cwd() to the nearest ancestor containing one of
|
||||
// these (stops at filesystem root). Lets a team pin a project's default
|
||||
// mode without polluting every contributor's user-level config or env.
|
||||
// 3. User config file defaultMode field:
|
||||
// - $XDG_CONFIG_HOME/caveman/config.json (any platform, if set)
|
||||
// - ~/.config/caveman/config.json (macOS / Linux fallback)
|
||||
// - %APPDATA%\caveman\config.json (Windows fallback)
|
||||
// 3. 'full'
|
||||
// 4. 'full'
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -36,25 +42,76 @@ function getConfigPath() {
|
||||
return path.join(getConfigDir(), 'config.json');
|
||||
}
|
||||
|
||||
function getDefaultMode() {
|
||||
// Walk up from `start` looking for a repo-local caveman config. Returns the
|
||||
// absolute path of the first match, or null. Stops at the filesystem root.
|
||||
// Candidates per dir (first wins): .caveman/config.json, .caveman.json.
|
||||
//
|
||||
// Bounded to 64 levels to defend against symlink cycles on pathological mounts.
|
||||
function findRepoConfigPath(start) {
|
||||
try {
|
||||
let dir = path.resolve(start || process.cwd());
|
||||
const candidates = ['.caveman/config.json', '.caveman.json'];
|
||||
for (let i = 0; i < 64; i++) {
|
||||
for (const rel of candidates) {
|
||||
const p = path.join(dir, rel);
|
||||
try {
|
||||
const st = fs.lstatSync(p);
|
||||
// Refuse symlinks — symmetric with safeWriteFlag/readFlag policy.
|
||||
if (st.isSymbolicLink() || !st.isFile()) continue;
|
||||
return p;
|
||||
} catch (e) {
|
||||
// not present, try next candidate
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
} catch (e) {
|
||||
// Defensive: any cwd / fs failure → no repo config
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readModeFromConfigFile(configPath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
if (config && config.defaultMode &&
|
||||
VALID_MODES.includes(String(config.defaultMode).toLowerCase())) {
|
||||
return String(config.defaultMode).toLowerCase();
|
||||
}
|
||||
} catch (e) {
|
||||
// Missing / unreadable / invalid JSON → caller falls through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// startDir overrides the cwd the repo-config walk starts from (default
|
||||
// process.cwd(), same as findRepoConfigPath's own fallback) — lets a caller
|
||||
// resolve the mode for a directory other than its own process cwd (#634:
|
||||
// the UserPromptSubmit hook's stdin carries the session's cwd, which can
|
||||
// differ from the hook process's cwd). Every other resolution step is
|
||||
// cwd-independent, so only the repo-config walk takes it.
|
||||
function getDefaultMode(startDir) {
|
||||
// 1. Environment variable (highest priority)
|
||||
const envMode = process.env.CAVEMAN_DEFAULT_MODE;
|
||||
if (envMode && VALID_MODES.includes(envMode.toLowerCase())) {
|
||||
return envMode.toLowerCase();
|
||||
}
|
||||
|
||||
// 2. Config file
|
||||
try {
|
||||
const configPath = getConfigPath();
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) {
|
||||
return config.defaultMode.toLowerCase();
|
||||
}
|
||||
} catch (e) {
|
||||
// Config file doesn't exist or is invalid — fall through
|
||||
// 2. Repo-local config (checked-in, per-project default)
|
||||
const repoConfigPath = findRepoConfigPath(startDir);
|
||||
if (repoConfigPath) {
|
||||
const repoMode = readModeFromConfigFile(repoConfigPath);
|
||||
if (repoMode) return repoMode;
|
||||
}
|
||||
|
||||
// 3. Default
|
||||
// 3. User config file
|
||||
const userMode = readModeFromConfigFile(getConfigPath());
|
||||
if (userMode) return userMode;
|
||||
|
||||
// 4. Default
|
||||
return 'full';
|
||||
}
|
||||
|
||||
@@ -127,18 +184,47 @@ function safeWriteFlag(flagPath, content) {
|
||||
if (e.code !== 'ENOENT') return;
|
||||
}
|
||||
|
||||
const tempPath = path.join(realFlagDir, `.caveman-active.${process.pid}.${Date.now()}`);
|
||||
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
||||
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
|
||||
let fd;
|
||||
// tempPath is hoisted above the try so the finally below can always find
|
||||
// it. On Windows, renameSync onto an existing target throws EPERM/EBUSY/
|
||||
// EACCES/EEXIST when another process (statusline read, a concurrent
|
||||
// session's hook) holds the file open without FILE_SHARE_DELETE —
|
||||
// without the retry + guaranteed cleanup here, every such miss left an
|
||||
// orphaned .caveman-active.<pid>.<ts> file behind (#511/#578/#657).
|
||||
let tempPath;
|
||||
try {
|
||||
fd = fs.openSync(tempPath, flags, 0o600);
|
||||
fs.writeSync(fd, String(content));
|
||||
try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
|
||||
tempPath = path.join(realFlagDir, `.caveman-active.${process.pid}.${Date.now()}`);
|
||||
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
||||
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(tempPath, flags, 0o600);
|
||||
fs.writeSync(fd, String(content));
|
||||
try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
|
||||
// Retry brief lock contention a few times. A missed write is harmless
|
||||
// (concurrent writers publish the same mode value); a leaked temp file
|
||||
// is not, so the finally below always sweeps it regardless of outcome.
|
||||
let renamed = false;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
fs.renameSync(tempPath, realFlagPath);
|
||||
renamed = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
const transient = e.code === 'EPERM' || e.code === 'EBUSY' ||
|
||||
e.code === 'EACCES' || e.code === 'EEXIST';
|
||||
if (!transient) throw e;
|
||||
}
|
||||
}
|
||||
if (!renamed && debug) {
|
||||
process.stderr.write('[caveman] safeWriteFlag: rename contended after 3 attempts; flag not updated this write\n');
|
||||
}
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
if (tempPath) { try { fs.unlinkSync(tempPath); } catch (e) { /* renamed already, or never created */ } }
|
||||
}
|
||||
fs.renameSync(tempPath, realFlagPath);
|
||||
} catch (e) {
|
||||
// Silent fail — flag is best-effort
|
||||
}
|
||||
@@ -248,6 +334,29 @@ function appendFlag(filePath, line) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mode-transition log (#601). Whenever the active-mode flag actually changes,
|
||||
// append {ts, mode, prev} to $CLAUDE_CONFIG_DIR/.caveman-mode-log.jsonl so
|
||||
// caveman-stats can attribute output tokens to the mode that was active when
|
||||
// each message was generated, instead of whatever mode the flag holds at
|
||||
// stats time. mode/prev are a VALID_MODES string or null (null = caveman off).
|
||||
// prev lets stats attribute messages that predate the first logged transition
|
||||
// of a session. No-op when the mode is unchanged; best-effort like all flag IO.
|
||||
const MODE_LOG_BASENAME = '.caveman-mode-log.jsonl';
|
||||
|
||||
function recordModeChange(claudeDir, newMode) {
|
||||
try {
|
||||
const current = readFlag(path.join(claudeDir, '.caveman-active'));
|
||||
const next = newMode || null;
|
||||
if ((current || null) === next) return;
|
||||
appendFlag(
|
||||
path.join(claudeDir, MODE_LOG_BASENAME),
|
||||
JSON.stringify({ ts: Date.now(), mode: next, prev: current || null })
|
||||
);
|
||||
} catch (e) {
|
||||
// Silent fail — the log is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// Symlink-safe history read. Returns lines (untrimmed) or empty array on any
|
||||
// anomaly. Caller is responsible for parsing JSON. Does NOT enforce a size cap
|
||||
// the way readFlag does — history is expected to grow with use.
|
||||
@@ -271,4 +380,4 @@ function readHistory(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES, safeWriteFlag, readFlag, appendFlag, readHistory };
|
||||
module.exports = { getDefaultMode, getConfigDir, getConfigPath, findRepoConfigPath, VALID_MODES, safeWriteFlag, readFlag, appendFlag, readHistory, recordModeChange, MODE_LOG_BASENAME };
|
||||
|
||||
@@ -6,41 +6,71 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = require('./caveman-config');
|
||||
|
||||
// Modes handled by their own slash commands (/caveman-commit, etc.) — not
|
||||
// selectable via /caveman <arg>.
|
||||
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
|
||||
const { getDefaultMode, safeWriteFlag, readFlag, recordModeChange } = require('./caveman-config');
|
||||
const { parseModeChange, INDEPENDENT_MODES } = require('./caveman-parse');
|
||||
|
||||
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const flagPath = path.join(claudeDir, '.caveman-active');
|
||||
// Remembers the prose mode active before a one-shot independent mode
|
||||
// (/caveman-commit etc.) so the next ordinary prompt can restore it (#599).
|
||||
const prevPath = path.join(claudeDir, '.caveman-active.prev');
|
||||
|
||||
let input = '';
|
||||
process.stdin.on('data', chunk => { input += chunk; });
|
||||
// Abnormal stdin close (broken pipe, parent crash) emits 'error'; without a
|
||||
// listener Node throws it as an uncaught exception and the hook exits
|
||||
// non-zero — a spurious hook failure (#538). Hooks must always exit 0.
|
||||
process.stdin.on('error', () => process.exit(0));
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(input);
|
||||
const prompt = (data.prompt || '').trim().toLowerCase();
|
||||
// Collapse whitespace so phrase triggers still match multiline prompts —
|
||||
// every regex below sees a single-line prompt (#598).
|
||||
let prompt = (data.prompt || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
|
||||
// Natural language activation (e.g. "activate caveman", "turn on caveman mode",
|
||||
// "talk like caveman"). README tells users they can say these, but the hook
|
||||
// only matched /caveman commands — flag file and statusline stayed out of sync.
|
||||
if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) ||
|
||||
/\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) {
|
||||
if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) {
|
||||
const mode = getDefaultMode();
|
||||
if (mode !== 'off') {
|
||||
safeWriteFlag(flagPath, mode);
|
||||
}
|
||||
// Unattended scheduled-task runs must never receive caveman styling —
|
||||
// the per-turn reinforcement would hijack the task prompt, and a
|
||||
// lightweight scheduled task would answer with a caveman greeting
|
||||
// instead of doing its job. Claude Code wraps these in a
|
||||
// <scheduled-task ...> marker; bail out completely when present: no flag
|
||||
// mutation, no reinforcement, no stats. Interactive sessions are
|
||||
// unaffected.
|
||||
if (/<scheduled-task\b/.test(prompt)) return;
|
||||
|
||||
// Claude Code delivers slash commands to this hook as an envelope, not
|
||||
// the literal command (#537):
|
||||
// <command-message>caveman</command-message>
|
||||
// <command-name>/caveman</command-name>
|
||||
// <command-args>ultra</command-args>
|
||||
// (one-line or newline-separated — the collapse above normalizes both
|
||||
// into single spaces; <command-args> may be empty or absent). Every
|
||||
// switch below matches against the literal command string, so this
|
||||
// envelope was a silent no-op for every slash command, including
|
||||
// '/caveman off'. Reconstruct '<name> <args>' for /caveman* envelopes so
|
||||
// the rest of this hook sees exactly what the user selected. A foreign
|
||||
// command's envelope is left untouched, and natural-language detection
|
||||
// is skipped for it so another command's own args can't misfire our
|
||||
// activation/deactivation triggers.
|
||||
let skipNaturalLanguage = false;
|
||||
const envName = /<command-name>\s*([^<\s]+)\s*<\/command-name>/.exec(prompt);
|
||||
if (envName) {
|
||||
if (envName[1].startsWith('/caveman')) {
|
||||
const envArgs = /<command-args>\s*([^<]*?)\s*<\/command-args>/.exec(prompt);
|
||||
const args = envArgs ? envArgs[1].trim() : '';
|
||||
prompt = args ? envName[1] + ' ' + args : envName[1];
|
||||
} else {
|
||||
skipNaturalLanguage = true;
|
||||
}
|
||||
}
|
||||
|
||||
// /caveman-stats [--share] — block the prompt and inject stats output as
|
||||
// the hook's reason. The script reads the active session log, so we pass
|
||||
// /caveman-stats [--share] — run the stats script and inject its output
|
||||
// as additionalContext (#618), instructing the model to relay it
|
||||
// verbatim. The script reads the active session log, so we pass
|
||||
// transcript_path through when Claude Code provides it.
|
||||
const statsMatch = /^\/caveman(?::caveman)?-stats(?:\s+(.*))?$/.exec(prompt);
|
||||
if (statsMatch) {
|
||||
const tailArgs = (statsMatch[1] || '').trim().split(/\s+/).filter(Boolean);
|
||||
let block;
|
||||
try {
|
||||
const statsPath = path.join(__dirname, 'caveman-stats.js');
|
||||
const argv = [statsPath];
|
||||
@@ -51,61 +81,50 @@ process.stdin.on('end', () => {
|
||||
if (sinceIdx !== -1 && tailArgs[sinceIdx + 1]) {
|
||||
argv.push('--since', tailArgs[sinceIdx + 1]);
|
||||
}
|
||||
const out = execFileSync(process.execPath, argv, { encoding: 'utf8', timeout: 5000 });
|
||||
process.stdout.write(JSON.stringify({ decision: 'block', reason: out.trim() }));
|
||||
block = execFileSync(process.execPath, argv, { encoding: 'utf8', timeout: 5000 }).trim();
|
||||
} catch (e) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
decision: 'block',
|
||||
reason: 'caveman-stats: could not run stats script.\nTry manually: node hooks/caveman-stats.js'
|
||||
}));
|
||||
block = 'caveman-stats: could not run stats script.\nTry manually: node hooks/caveman-stats.js';
|
||||
}
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "UserPromptSubmit",
|
||||
additionalContext: 'Print this stats block verbatim inside a fenced code block. Say nothing else.\n\n' + block
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Match /caveman commands
|
||||
if (prompt.startsWith('/caveman')) {
|
||||
const parts = prompt.split(/\s+/);
|
||||
const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc.
|
||||
const arg = parts[1] || '';
|
||||
// Shared mode-change parser (#602) — single source of truth with the
|
||||
// opencode plugin for slash commands, namespaced /caveman:caveman-*,
|
||||
// natural-language activation/deactivation, and brevity triggers.
|
||||
const change = parseModeChange(prompt, { getDefaultMode, skipNaturalLanguage });
|
||||
|
||||
let mode = null;
|
||||
|
||||
if (cmd === '/caveman-commit') {
|
||||
mode = 'commit';
|
||||
} else if (cmd === '/caveman-review') {
|
||||
mode = 'review';
|
||||
} else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') {
|
||||
mode = 'compress';
|
||||
} else if (cmd === '/caveman' || cmd === '/caveman:caveman') {
|
||||
// Bare /caveman → activate at configured default
|
||||
if (!arg) {
|
||||
mode = getDefaultMode();
|
||||
} else if (arg === 'off' || arg === 'stop' || arg === 'disable') {
|
||||
mode = 'off';
|
||||
} else if (arg === 'wenyan-full') {
|
||||
// Canonical alias — config stores as 'wenyan'
|
||||
mode = 'wenyan';
|
||||
} else if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) {
|
||||
mode = arg;
|
||||
// Independent one-shot modes remember the prose mode active before them
|
||||
// so the next ordinary prompt restores it (#599) — SKILL.md promises
|
||||
// "Level persist until changed or session end", and a one-shot skill
|
||||
// invocation should not count as "changed" forever.
|
||||
let setIndependentThisTurn = false;
|
||||
if (change && change.action === 'set') {
|
||||
const mode = change.mode;
|
||||
if (INDEPENDENT_MODES.has(mode)) {
|
||||
// Save the prose mode being displaced — but never overwrite an
|
||||
// already-saved one with another independent mode (/caveman-commit
|
||||
// followed by /caveman-review must still restore the original).
|
||||
const current = readFlag(flagPath);
|
||||
if (current && !INDEPENDENT_MODES.has(current)) {
|
||||
safeWriteFlag(prevPath, current);
|
||||
}
|
||||
// Unknown arg → mode stays null, flag untouched (no silent overwrite)
|
||||
setIndependentThisTurn = true;
|
||||
}
|
||||
|
||||
if (mode && mode !== 'off') {
|
||||
safeWriteFlag(flagPath, mode);
|
||||
} else if (mode === 'off') {
|
||||
try { fs.unlinkSync(flagPath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect deactivation — natural language and slash commands
|
||||
if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) ||
|
||||
/\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) ||
|
||||
/\bnormal mode\b/i.test(prompt)) {
|
||||
recordModeChange(claudeDir, mode); // #601: timestamped transition log
|
||||
safeWriteFlag(flagPath, mode);
|
||||
} else if (change && change.action === 'clear') {
|
||||
recordModeChange(claudeDir, null); // #601
|
||||
try { fs.unlinkSync(flagPath); } catch (e) {}
|
||||
try { fs.unlinkSync(prevPath); } catch (e) {}
|
||||
}
|
||||
|
||||
// Per-turn reinforcement: emit a structured reminder when caveman is active.
|
||||
// Per-turn reinforcement: emit a short reminder when caveman is active.
|
||||
// The SessionStart hook injects the full ruleset once, but models lose it
|
||||
// when other plugins inject competing style instructions every turn.
|
||||
// This keeps caveman visible in the model's attention on every user message.
|
||||
@@ -116,14 +135,35 @@ process.stdin.on('end', () => {
|
||||
// If the flag is missing, corrupted, oversized, or a symlink pointing at
|
||||
// something like ~/.ssh/id_rsa, readFlag returns null and we emit nothing
|
||||
// — never inject untrusted bytes into model context.
|
||||
const activeMode = readFlag(flagPath);
|
||||
if (activeMode && !INDEPENDENT_MODES.has(activeMode)) {
|
||||
let activeMode = readFlag(flagPath);
|
||||
|
||||
// One-shot restore (#599): an independent mode set on a PREVIOUS prompt
|
||||
// has served its turn — bring back the prose mode that was active before
|
||||
// it, or deactivate if caveman wasn't active then.
|
||||
if (activeMode && INDEPENDENT_MODES.has(activeMode) && !setIndependentThisTurn) {
|
||||
const prev = readFlag(prevPath);
|
||||
try { fs.unlinkSync(prevPath); } catch (e) {}
|
||||
if (prev && !INDEPENDENT_MODES.has(prev)) {
|
||||
recordModeChange(claudeDir, prev); // #601
|
||||
safeWriteFlag(flagPath, prev);
|
||||
activeMode = prev;
|
||||
} else {
|
||||
recordModeChange(claudeDir, null); // #601
|
||||
try { fs.unlinkSync(flagPath); } catch (e) {}
|
||||
activeMode = null;
|
||||
}
|
||||
}
|
||||
|
||||
// #634: a repo-local .caveman.json / .caveman/config.json can set
|
||||
// defaultMode "off" to opt a project out of caveman entirely. Thread the
|
||||
// hook stdin's cwd through so that check resolves for the session's
|
||||
// directory, not this hook process's own cwd. This gates ONLY the
|
||||
// reinforcement output below — it never deletes or writes the flag file.
|
||||
if (activeMode && !INDEPENDENT_MODES.has(activeMode) && getDefaultMode(data.cwd) !== 'off') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "UserPromptSubmit",
|
||||
additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " +
|
||||
"Drop articles/filler/pleasantries/hedging. Fragments OK. " +
|
||||
"Code/commits/security: write normal."
|
||||
additionalContext: `CAVEMAN MODE ACTIVE (${activeMode}) — session ruleset applies.`
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env node
|
||||
// caveman — shared mode-change parser (#602)
|
||||
//
|
||||
// Single source of truth for interpreting a user prompt as a caveman mode
|
||||
// change. Extracted from caveman-mode-tracker.js so the Claude Code hook and
|
||||
// the opencode plugin can't drift out of sync with each other. The tracker's
|
||||
// regexes are the reference behavior — every pattern below is copied from it
|
||||
// verbatim; don't tighten or loosen a match here without a matching change
|
||||
// (and test) on the tracker side.
|
||||
//
|
||||
// parseModeChange(prompt, { getDefaultMode, skipNaturalLanguage, expandedTpl, unwrapQuotes })
|
||||
// → { action: 'set', mode } — caller should activate `mode`
|
||||
// → { action: 'clear' } — caller should deactivate (delete the flag)
|
||||
// → null — prompt does not change state
|
||||
//
|
||||
// Options:
|
||||
// getDefaultMode — required; () => resolved default mode string.
|
||||
// Injected rather than required directly so the
|
||||
// caller controls resolution (cwd, env, etc.).
|
||||
// skipNaturalLanguage — when true, skip deactivation/activation/brevity
|
||||
// phrase matching entirely. Set this for prompt text
|
||||
// that isn't the user's own words (e.g. a foreign
|
||||
// slash-command envelope's <command-args>), so
|
||||
// another command's arguments can't misfire our
|
||||
// triggers.
|
||||
// expandedTpl — when true, also recognize opencode's expanded
|
||||
// command-template bodies (a typed "/caveman ultra"
|
||||
// gets replaced by the command file's prose before
|
||||
// this parser ever sees it). Claude Code prompts
|
||||
// never take this shape, so Claude Code callers
|
||||
// should leave this off.
|
||||
// unwrapQuotes — when true, strip a single layer of matching quote
|
||||
// characters wrapping the whole prompt (opencode's
|
||||
// non-interactive `run` path delivers messages this
|
||||
// way).
|
||||
|
||||
// Sibling require that tolerates the opencode install layout, where this
|
||||
// file is copied next to a renamed `caveman-config.cjs` (the plugin dir is
|
||||
// "type": "module", so a bare `.js` sibling would load as ESM). A plain
|
||||
// `require('./caveman-config')` only auto-resolves the `.js` extension, so
|
||||
// try that first (dev tree, standalone hook install) and fall back to the
|
||||
// explicit `.cjs` name.
|
||||
let cavemanConfig;
|
||||
try {
|
||||
cavemanConfig = require('./caveman-config');
|
||||
} catch (e) {
|
||||
cavemanConfig = require('./caveman-config.cjs');
|
||||
}
|
||||
const { VALID_MODES } = cavemanConfig;
|
||||
|
||||
// Modes handled by their own slash commands (/caveman-commit, etc.) — not
|
||||
// selectable via /caveman <arg>.
|
||||
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
|
||||
|
||||
function parseModeChange(promptRaw, options) {
|
||||
options = options || {};
|
||||
const getDefaultMode = options.getDefaultMode || cavemanConfig.getDefaultMode;
|
||||
|
||||
let prompt = (promptRaw || '').trim();
|
||||
if (options.unwrapQuotes) {
|
||||
const wrapped = /^(["'`])([\s\S]*)\1$/.exec(prompt);
|
||||
if (wrapped) prompt = wrapped[2].trim();
|
||||
}
|
||||
// Capture the first line before whitespace collapse. The expandedTpl
|
||||
// templates (opencode's commands/caveman.md etc.) put $ARGUMENTS at the end
|
||||
// of the first line, followed by a blank line and then fixed boilerplate
|
||||
// ("If no level given, use full. If \"off\", deactivate."). Collapsing all
|
||||
// whitespace to single spaces (below) merges an EMPTY argument directly
|
||||
// into that boilerplate, so a bare `/caveman` with no level looked like the
|
||||
// level was the word "if" and got rejected as bogus. Extract the template
|
||||
// argument from this uncollapsed first line instead.
|
||||
const firstLine = prompt.toLowerCase().split(/\r?\n/, 1)[0];
|
||||
// Collapse whitespace so phrase triggers still match multiline prompts —
|
||||
// every regex below expects a single-line prompt (#598).
|
||||
prompt = prompt.toLowerCase().replace(/\s+/g, ' ');
|
||||
if (!prompt) return null;
|
||||
|
||||
// Deactivation intent — computed FIRST so "turn caveman mode off" never
|
||||
// falls through to the activation patterns (#598), and applied with the
|
||||
// highest priority: it's what the tracker's original unconditional
|
||||
// end-of-function deactivation check amounted to.
|
||||
const wantsOff = !options.skipNaturalLanguage && (
|
||||
/\b(stop|disable|deactivate|quit|exit|kill)\s+(the\s+)?caveman\b/.test(prompt) ||
|
||||
/\bcaveman(\s+mode)?\s+(off|stop|disabled?)\b/.test(prompt) ||
|
||||
/\bturn\s+off\s+(the\s+)?caveman\b/.test(prompt) ||
|
||||
// "normal mode" only as a command (prompt-initial, optionally led by a
|
||||
// switch-back verb) or with caveman context — never mid-sentence for
|
||||
// e.g. vim's normal mode ("how do I exit vim normal mode").
|
||||
/^(please\s+)?(go\s+|back\s+to\s+|switch\s+(back\s+)?to\s+|return\s+to\s+)?normal\s+mode\b/.test(prompt) ||
|
||||
(/\bnormal\s+mode\b/.test(prompt) && /\bcaveman\b/.test(prompt))
|
||||
);
|
||||
if (wantsOff) return { action: 'clear' };
|
||||
|
||||
// opencode expands a typed "/caveman <level>" (and the independent-mode
|
||||
// commands) into the command file's prose before chat.message fires, so
|
||||
// the literal slash-command branch below never sees the original text.
|
||||
// Recover the level from each template's fixed prefix instead. This MUST
|
||||
// run before the generic NL-activation match below: "Activate caveman
|
||||
// mode: ultra" would otherwise trip the "activate ... caveman" trigger and
|
||||
// swallow the level, activating at the default instead (#602). Claude Code
|
||||
// prompts never take this shape, so gate behind expandedTpl (opencode-only).
|
||||
if (options.expandedTpl) {
|
||||
if (/^generate a commit message for the current staged changes\b/.test(prompt)) {
|
||||
return { action: 'set', mode: 'commit' };
|
||||
}
|
||||
if (/^review the current diff\b/.test(prompt)) {
|
||||
return { action: 'set', mode: 'review' };
|
||||
}
|
||||
if (/^compress the file at:/.test(prompt)) {
|
||||
return { action: 'set', mode: 'compress' };
|
||||
}
|
||||
const tpl = /^activate caveman mode:[ \t]*(\S*)/.exec(firstLine);
|
||||
if (tpl) {
|
||||
const arg = tpl[1] || '';
|
||||
if (!arg) {
|
||||
const mode = getDefaultMode();
|
||||
return mode === 'off' ? { action: 'clear' } : { action: 'set', mode };
|
||||
}
|
||||
if (arg === 'off' || arg === 'stop' || arg === 'disable') return { action: 'clear' };
|
||||
if (arg === 'wenyan-full') return { action: 'set', mode: 'wenyan' };
|
||||
if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return { action: 'set', mode: arg };
|
||||
return null; // unknown/bogus level — leave flag untouched (#602)
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.skipNaturalLanguage) {
|
||||
// Questions about caveman are not activation commands
|
||||
// ("what is caveman mode?", "does caveman lite drop articles?").
|
||||
const isQuestion =
|
||||
/^(what|whats|what's|how|why|when|where|who|does|do|did|is|are|can|could|would|should|tell me|explain)\b/.test(prompt);
|
||||
|
||||
// Natural language activation (e.g. "activate caveman", "turn on caveman
|
||||
// mode", "talk like caveman"). Also brevity requests ("less tokens",
|
||||
// "be brief/terse", "fewer tokens", "shorter answers") — but not when
|
||||
// scoped to a single section ("be brief in the summary"), which is a
|
||||
// one-off instruction, not a session-wide mode switch.
|
||||
if (!isQuestion) {
|
||||
if (/\b(activate|enable|start|turn on|use|switch to|want|give me)\b[^.]{0,40}\bcaveman\b/.test(prompt) ||
|
||||
/\btalk like\b[^.]{0,40}\bcaveman\b/.test(prompt) ||
|
||||
/\bcaveman\s+mode\s+(on|please|now)\b/.test(prompt) ||
|
||||
/^caveman(\s+mode)?\s*[.!]*$/.test(prompt) ||
|
||||
/\b(less tokens|fewer tokens|be brief|be terse|shorter answers)\b(?!\s+(in|for|on|about|when|during|with)\b)/.test(prompt)) {
|
||||
const mode = getDefaultMode();
|
||||
// Mirrors the tracker exactly: a configured-off default makes this a
|
||||
// no-op (leave whatever flag state already exists), NOT a clear —
|
||||
// that's only what an explicit "/caveman" bare command does.
|
||||
return mode !== 'off' ? { action: 'set', mode } : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match /caveman commands. Marketplace plugin installs surface commands
|
||||
// namespaced as /caveman:caveman-<name> — accept both forms for every
|
||||
// skill (#599: only compress and stats had the namespaced variant).
|
||||
if (prompt.startsWith('/caveman')) {
|
||||
const parts = prompt.split(/\s+/);
|
||||
const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc.
|
||||
const arg = parts[1] || '';
|
||||
|
||||
if (cmd === '/caveman-commit' || cmd === '/caveman:caveman-commit') {
|
||||
return { action: 'set', mode: 'commit' };
|
||||
}
|
||||
if (cmd === '/caveman-review' || cmd === '/caveman:caveman-review') {
|
||||
return { action: 'set', mode: 'review' };
|
||||
}
|
||||
if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') {
|
||||
return { action: 'set', mode: 'compress' };
|
||||
}
|
||||
if (cmd === '/caveman' || cmd === '/caveman:caveman') {
|
||||
// Bare /caveman → activate at configured default
|
||||
if (!arg) {
|
||||
const mode = getDefaultMode();
|
||||
return mode === 'off' ? { action: 'clear' } : { action: 'set', mode };
|
||||
}
|
||||
if (arg === 'off' || arg === 'stop' || arg === 'disable') return { action: 'clear' };
|
||||
if (arg === 'wenyan-full') return { action: 'set', mode: 'wenyan' }; // canonical alias — config stores as 'wenyan'
|
||||
if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return { action: 'set', mode: arg };
|
||||
// Unknown arg → no-op, flag untouched (no silent overwrite with default)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { parseModeChange, INDEPENDENT_MODES };
|
||||
+284
-40
@@ -10,7 +10,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { readFlag, appendFlag, readHistory, safeWriteFlag } = require('./caveman-config');
|
||||
const { readFlag, appendFlag, readHistory, safeWriteFlag, VALID_MODES, MODE_LOG_BASENAME } = require('./caveman-config');
|
||||
|
||||
// Mean per-task savings from benchmarks/results/*.json (avg_savings: 65 across
|
||||
// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite / ultra /
|
||||
@@ -18,17 +18,39 @@ const { readFlag, appendFlag, readHistory, safeWriteFlag } = require('./caveman-
|
||||
// run is committed.
|
||||
const COMPRESSION = { 'full': 0.65 };
|
||||
|
||||
// Per-turn INPUT cost the rules add: SKILL.md (~5 KB) is injected into
|
||||
// context, plus the per-turn reinforcement the mode tracker emits. This is
|
||||
// the ~1-1.5k/turn figure docs/HONEST-NUMBERS.md admits and #145/#677 flag as
|
||||
// hidden — gross output savings alone can look great while the session is
|
||||
// still net-negative. 1250 sits mid-range; override with
|
||||
// CAVEMAN_RULE_OVERHEAD_TOKENS if you've measured your own setup.
|
||||
const DEFAULT_RULE_OVERHEAD_TOKENS_PER_TURN = 1250;
|
||||
|
||||
function ruleOverheadPerTurn() {
|
||||
const raw = process.env.CAVEMAN_RULE_OVERHEAD_TOKENS;
|
||||
if (raw === undefined) return DEFAULT_RULE_OVERHEAD_TOKENS_PER_TURN;
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n > 0 ? n : DEFAULT_RULE_OVERHEAD_TOKENS_PER_TURN;
|
||||
}
|
||||
|
||||
// Approximate Anthropic public output-token pricing, USD per million.
|
||||
// Match by model id prefix so this stays correct across point releases
|
||||
// (e.g. claude-sonnet-4-20250514, claude-sonnet-4-7). Update from
|
||||
// https://www.anthropic.com/pricing if a release changes the tier.
|
||||
// Most-specific prefixes MUST come first — priceForModel returns the first match.
|
||||
const MODEL_OUTPUT_PRICE_PER_M = [
|
||||
['claude-opus-4', 75.00],
|
||||
['claude-sonnet-4', 15.00],
|
||||
['claude-haiku-4', 4.00],
|
||||
['claude-3-5-sonnet', 15.00],
|
||||
['claude-3-5-haiku', 4.00],
|
||||
['claude-3-opus', 75.00],
|
||||
// Legacy Opus 4.0 / 4.1 (pre-4.5) billed at the old $75/M output tier,
|
||||
// including the dated ids (e.g. claude-opus-4-20250514).
|
||||
['claude-opus-4-0', 75.00],
|
||||
['claude-opus-4-1', 75.00],
|
||||
['claude-opus-4-2025', 75.00],
|
||||
// Opus 4.5–4.8 dropped to $25/M output (rate card held since 4.5).
|
||||
['claude-opus-4', 25.00],
|
||||
['claude-sonnet-4', 15.00],
|
||||
['claude-haiku-4', 5.00], // Haiku 4.5 = $5/M output
|
||||
['claude-3-5-sonnet', 15.00],
|
||||
['claude-3-5-haiku', 4.00],
|
||||
['claude-3-opus', 75.00],
|
||||
];
|
||||
|
||||
function priceForModel(model) {
|
||||
@@ -71,12 +93,13 @@ function findRecentSession(claudeDir) {
|
||||
function parseSession(filePath) {
|
||||
let raw;
|
||||
try { raw = fs.readFileSync(filePath, 'utf8'); }
|
||||
catch { return { outputTokens: 0, cacheReadTokens: 0, turns: 0, model: null }; }
|
||||
catch { return { outputTokens: 0, cacheReadTokens: 0, turns: 0, model: null, messages: [] }; }
|
||||
|
||||
let outputTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let turns = 0;
|
||||
let model = null;
|
||||
const messages = []; // per-message {ts, outputTokens} for mode attribution (#601)
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
@@ -88,8 +111,13 @@ function parseSession(filePath) {
|
||||
cacheReadTokens += usage.cache_read_input_tokens || 0;
|
||||
turns++;
|
||||
if (!model && entry.message.model) model = entry.message.model;
|
||||
const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
||||
messages.push({
|
||||
ts: Number.isFinite(ts) ? ts : null,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
});
|
||||
}
|
||||
return { outputTokens, cacheReadTokens, turns, model };
|
||||
return { outputTokens, cacheReadTokens, turns, model, messages };
|
||||
}
|
||||
|
||||
// Detect *.original.md / *.md pairs left behind by caveman-compress. The
|
||||
@@ -131,17 +159,134 @@ function summarizeCompressed(pairs) {
|
||||
return { count: pairs.length, bytesSaved, tokensSaved };
|
||||
}
|
||||
|
||||
// ── Per-mode attribution (#601) ─────────────────────────────────────────────
|
||||
// The whole session's tokens must never be credited to whatever mode the flag
|
||||
// happens to hold at stats time — a mid-session mode change would inflate the
|
||||
// estimate (verbose tokens counted as compressed) or zero it (caveman tokens
|
||||
// counted as uncompressed). The mode tracker + SessionStart hook append
|
||||
// {ts, mode, prev} rows to .caveman-mode-log.jsonl on every actual transition;
|
||||
// stats joins those timestamps against the session JSONL message timestamps.
|
||||
|
||||
// Read + validate the transition log. Returns rows sorted by ts.
|
||||
function readModeLog(logPath) {
|
||||
const rows = [];
|
||||
for (const line of readHistory(logPath)) {
|
||||
let e;
|
||||
try { e = JSON.parse(line); } catch { continue; }
|
||||
if (!e || typeof e !== 'object' || !Number.isFinite(e.ts)) continue;
|
||||
const norm = (v) => (v == null ? null : (VALID_MODES.includes(String(v)) ? String(v) : undefined));
|
||||
const mode = norm(e.mode);
|
||||
const prev = norm(e.prev);
|
||||
if (mode === undefined || prev === undefined) continue; // reject non-whitelisted values
|
||||
rows.push({ ts: e.ts, mode, prev });
|
||||
}
|
||||
rows.sort((a, b) => a.ts - b.ts);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Attribute each message's output tokens to the mode active when it was
|
||||
// generated. Sources, most to least exact:
|
||||
// 'log' — the transition log covers the message (rows at/before its
|
||||
// ts, or the first row's `prev` for the pre-inception span).
|
||||
// 'flag-mtime' — no log rows, but the flag was written mid-session: tokens
|
||||
// from the write onward belong to the current mode; earlier
|
||||
// tokens have UNKNOWN mode and are excluded, never guessed
|
||||
// (no-fake-savings). Messages without timestamps are also
|
||||
// unknown in this case.
|
||||
// 'whole-session' — no log and no evidence of a mid-session change: the
|
||||
// current mode covers the whole session (correct when the
|
||||
// mode never changed; pre-#601 behavior).
|
||||
// Returns { byMode: {modeKey: tokens}, unknownTokens, basis } where modeKey is
|
||||
// a mode string or 'none' (caveman inactive).
|
||||
function attributeByMode({ messages, modeLog, mode, flagMtimeMs, outputTokens }) {
|
||||
const currentKey = mode || 'none';
|
||||
const msgs = messages || [];
|
||||
let firstTs = null;
|
||||
for (const m of msgs) {
|
||||
if (m.ts != null && (firstTs === null || m.ts < firstTs)) firstTs = m.ts;
|
||||
}
|
||||
|
||||
let events = modeLog || [];
|
||||
let basis = 'log';
|
||||
let prefixMode; // mode for messages before the first event (undefined = unknown)
|
||||
if (events.length === 0) {
|
||||
if (flagMtimeMs != null && firstTs != null && flagMtimeMs > firstTs) {
|
||||
// Flag written mid-session with no transition log: only the span from
|
||||
// the write onward is attributable. The write may have been a
|
||||
// reaffirmation of the same mode, but assuming so would guess savings
|
||||
// into existence — exclude the prefix instead.
|
||||
events = [{ ts: flagMtimeMs, mode: mode || null }];
|
||||
basis = 'flag-mtime';
|
||||
prefixMode = undefined;
|
||||
} else {
|
||||
return { byMode: { [currentKey]: outputTokens || 0 }, unknownTokens: 0, basis: 'whole-session' };
|
||||
}
|
||||
} else {
|
||||
// Every transition since log inception is recorded, so the span before
|
||||
// the first row ran under that row's `prev` mode.
|
||||
prefixMode = events[0].prev;
|
||||
}
|
||||
|
||||
const byMode = {};
|
||||
let unknownTokens = 0;
|
||||
const add = (key, tokens) => { byMode[key] = (byMode[key] || 0) + tokens; };
|
||||
for (const m of msgs) {
|
||||
if (m.ts == null) { unknownTokens += m.outputTokens; continue; }
|
||||
let active;
|
||||
for (const ev of events) {
|
||||
if (ev.ts <= m.ts) active = ev;
|
||||
else break;
|
||||
}
|
||||
if (active !== undefined) add(active.mode || 'none', m.outputTokens);
|
||||
else if (prefixMode !== undefined) add(prefixMode || 'none', m.outputTokens);
|
||||
else unknownTokens += m.outputTokens;
|
||||
}
|
||||
return { byMode, unknownTokens, basis };
|
||||
}
|
||||
|
||||
// Attribution shape for callers without a session log to join against
|
||||
// (kept for formatStats/formatShare backward compatibility in tests).
|
||||
function wholeSessionAttribution(mode, outputTokens) {
|
||||
return { byMode: { [mode || 'none']: outputTokens || 0 }, unknownTokens: 0, basis: 'whole-session' };
|
||||
}
|
||||
|
||||
// Compute the savings figures we want to log/share for one session snapshot.
|
||||
function deriveSavings({ outputTokens, mode, model }) {
|
||||
const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
|
||||
// Sums per-mode: only spans whose mode has benchmark data earn an estimate;
|
||||
// unknown spans earn nothing.
|
||||
function deriveSavings({ byMode, model }) {
|
||||
let estSavedTokens = 0;
|
||||
for (const [key, tokens] of Object.entries(byMode || {})) {
|
||||
const ratio = COMPRESSION[key];
|
||||
if (ratio == null || tokens <= 0) continue;
|
||||
estSavedTokens += Math.round(tokens / (1 - ratio)) - tokens;
|
||||
}
|
||||
const price = priceForModel(model);
|
||||
if (ratio === null) return { estSavedTokens: 0, estSavedUsd: 0 };
|
||||
const estNormal = Math.round(outputTokens / (1 - ratio));
|
||||
const estSavedTokens = estNormal - outputTokens;
|
||||
const estSavedUsd = price !== null ? (estSavedTokens / 1_000_000) * price : 0;
|
||||
return { estSavedTokens, estSavedUsd };
|
||||
}
|
||||
|
||||
// Net token effect = output tokens saved minus the input tokens the rules
|
||||
// cost. Savings are OUTPUT tokens, overhead is INPUT tokens — different
|
||||
// buckets, but summing them is the only honest whole-budget delta (see
|
||||
// docs/HONEST-NUMBERS.md). Never called with an unattributed savings figure —
|
||||
// callers only invoke this where mode attribution and turn counts both exist.
|
||||
function deriveNet({ estSavedTokens, turns }) {
|
||||
const overheadTokens = Math.max(0, turns || 0) * ruleOverheadPerTurn();
|
||||
return { overheadTokens, netTokens: (estSavedTokens || 0) - overheadTokens };
|
||||
}
|
||||
|
||||
// Shared "rule overhead" + "net" lines for the session and lifetime views.
|
||||
function netLines({ estSavedTokens, turns }) {
|
||||
const perTurn = ruleOverheadPerTurn();
|
||||
const { overheadTokens, netTokens } = deriveNet({ estSavedTokens, turns });
|
||||
const overhead = `Est. rule overhead: ${overheadTokens.toLocaleString()} ` +
|
||||
`(input, ~${perTurn.toLocaleString()}/turn over ${turns} turn${turns === 1 ? '' : 's'})`;
|
||||
const net = netTokens >= 0
|
||||
? `Est. net: +${netTokens.toLocaleString()} (net saving after rule overhead)`
|
||||
: `Est. net: ${netTokens.toLocaleString()} (caveman cost more than it saved for this workload — consider turning it off)`;
|
||||
return `${overhead}\n${net}`;
|
||||
}
|
||||
|
||||
// Parse "7d", "12h" etc. to milliseconds. Returns null on invalid input.
|
||||
function parseDuration(spec) {
|
||||
if (!spec) return null;
|
||||
@@ -167,12 +312,38 @@ function aggregateHistory(historyPath, sinceMs) {
|
||||
if (!prev || (entry.ts || 0) >= (prev.ts || 0)) latestPerSession.set(id, entry);
|
||||
}
|
||||
let outputTokens = 0, estSavedTokens = 0, estSavedUsd = 0;
|
||||
// Net (rule-overhead) figures only ever sum rows that actually logged a
|
||||
// turn count. Legacy history rows predate #145's `turns` field — folding
|
||||
// their savings into a net computed from someone else's turns would either
|
||||
// over- or under-state the overhead, so they're excluded from net entirely
|
||||
// (they still count toward the plain gross totals above, unchanged).
|
||||
let netSavedTokens = 0, netTurns = 0;
|
||||
for (const e of latestPerSession.values()) {
|
||||
outputTokens += e.output_tokens || 0;
|
||||
estSavedTokens += e.est_saved_tokens || 0;
|
||||
estSavedUsd += e.est_saved_usd || 0;
|
||||
if (e.turns != null) {
|
||||
netSavedTokens += e.est_saved_tokens || 0;
|
||||
netTurns += e.turns || 0;
|
||||
}
|
||||
}
|
||||
return { sessions: latestPerSession.size, outputTokens, estSavedTokens, estSavedUsd };
|
||||
return { sessions: latestPerSession.size, outputTokens, estSavedTokens, estSavedUsd, netSavedTokens, netTurns };
|
||||
}
|
||||
|
||||
// Output-reduction share: saved / (saved + used) = the fraction of the
|
||||
// would-be OUTPUT tokens that caveman avoided. That is the only ratio we can
|
||||
// honestly compute from output counts alone. It is NOT a share of session or
|
||||
// limit usage — input + cache tokens dominate agentic sessions, count against
|
||||
// Pro/Max limits, and are not reduced by caveman, so real limit relief is far
|
||||
// smaller (docs/HONEST-NUMBERS.md: session-level totals land ~14–21%, below
|
||||
// zero on terse workloads). Never label this "usage" or "budget". Returns a
|
||||
// rounded percent, or null when there is nothing measured to divide.
|
||||
function outputReductionPct(savedTokens, usedTokens) {
|
||||
if (!Number.isFinite(savedTokens) || !Number.isFinite(usedTokens)) return null;
|
||||
if (savedTokens <= 0 || usedTokens < 0) return null;
|
||||
const total = savedTokens + usedTokens;
|
||||
if (total <= 0) return null;
|
||||
return Math.round((savedTokens / total) * 100);
|
||||
}
|
||||
|
||||
function humanizeTokens(n) {
|
||||
@@ -182,42 +353,48 @@ function humanizeTokens(n) {
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
function formatHistory({ sessions, outputTokens, estSavedTokens, estSavedUsd, since }) {
|
||||
function formatHistory({ sessions, outputTokens, estSavedTokens, estSavedUsd, netSavedTokens, netTurns, since }) {
|
||||
const sep = '──────────────────────────────────';
|
||||
const window = since ? ` (last ${since})` : '';
|
||||
if (sessions === 0) {
|
||||
return `\nCaveman Stats — Lifetime${window}\n${sep}\nNo sessions logged yet — run /caveman-stats inside any session to start tracking.\n${sep}\n`;
|
||||
}
|
||||
const usdLine = estSavedUsd > 0 ? `Est. saved (USD): ~${formatUsd(estSavedUsd)}\n` : '';
|
||||
const pct = outputReductionPct(estSavedTokens, outputTokens);
|
||||
const budgetLine = pct !== null
|
||||
? `Est. output reduction: ~${pct}% (output tokens only, est.)\n`
|
||||
: '';
|
||||
// Only sessions that logged a turn count feed the net figure (older rows
|
||||
// predate #145) — omit rather than understate the overhead.
|
||||
const netBlock = netTurns > 0 ? netLines({ estSavedTokens: netSavedTokens, turns: netTurns }) + '\n' : '';
|
||||
return `\nCaveman Stats — Lifetime${window}\n${sep}\n` +
|
||||
`Sessions: ${sessions.toLocaleString()}\n${sep}\n` +
|
||||
`Output tokens: ${outputTokens.toLocaleString()}\n` +
|
||||
`Est. tokens saved: ${estSavedTokens.toLocaleString()}\n` +
|
||||
usdLine + sep + '\n';
|
||||
netBlock + budgetLine + usdLine + sep + '\n';
|
||||
}
|
||||
|
||||
// Single-line tweetable summary. Stays human-friendly when no ratio is known.
|
||||
function formatShare({ outputTokens, turns, mode, model }) {
|
||||
// Savings come from per-mode attribution (#601) so a mid-session mode change
|
||||
// never inflates the shared number.
|
||||
function formatShare({ outputTokens, turns, mode, model, attribution }) {
|
||||
if (turns === 0) {
|
||||
return '🪨 caveman armed but no turns yet — caveman.sh';
|
||||
}
|
||||
const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
|
||||
const price = priceForModel(model);
|
||||
const attr = attribution || wholeSessionAttribution(mode, outputTokens);
|
||||
const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attr.byMode, model });
|
||||
|
||||
if (ratio !== null) {
|
||||
const estSaved = Math.round(outputTokens / (1 - ratio)) - outputTokens;
|
||||
let usd = '';
|
||||
if (price !== null) {
|
||||
const amt = (estSaved / 1_000_000) * price;
|
||||
usd = ` (~${formatUsd(amt)})`;
|
||||
}
|
||||
return `🪨 Saved ${estSaved.toLocaleString()} output tokens${usd} across ${turns} turns this session — caveman.sh`;
|
||||
if (estSavedTokens > 0) {
|
||||
const usd = estSavedUsd > 0 ? ` (~${formatUsd(estSavedUsd)})` : '';
|
||||
return `🪨 Saved ${estSavedTokens.toLocaleString()} output tokens${usd} across ${turns} turns this session — caveman.sh`;
|
||||
}
|
||||
return `🪨 ${turns} turns, ${outputTokens.toLocaleString()} output tokens this session — caveman.sh`;
|
||||
}
|
||||
|
||||
// Pure formatter — separated from main() so tests can pass synthetic inputs.
|
||||
function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessionPath, compressed }) {
|
||||
// `attribution` (from attributeByMode, #601) splits output tokens per mode;
|
||||
// when omitted, the current mode is assumed for the whole session.
|
||||
function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessionPath, compressed, attribution }) {
|
||||
const sep = '──────────────────────────────────';
|
||||
const shortPath = sessionPath && sessionPath.length > 45
|
||||
? '...' + sessionPath.slice(-45)
|
||||
@@ -227,12 +404,49 @@ function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessio
|
||||
return `\nCaveman Stats\n${sep}\nNo conversation yet — stats available after first response.\n${sep}\n`;
|
||||
}
|
||||
|
||||
const attr = attribution || wholeSessionAttribution(mode, outputTokens);
|
||||
const activeKeys = Object.keys(attr.byMode).filter(k => attr.byMode[k] > 0);
|
||||
// Uniform = every token ran under the CURRENT mode. Anything else — a
|
||||
// second mode, tokens under a mode the flag no longer shows, or spans we
|
||||
// could not attribute — gets the per-mode breakdown below.
|
||||
const uniform = attr.unknownTokens === 0 &&
|
||||
(activeKeys.length === 0 || (activeKeys.length === 1 && activeKeys[0] === (mode || 'none')));
|
||||
|
||||
const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
|
||||
const price = priceForModel(model);
|
||||
|
||||
let savings;
|
||||
let footer = '';
|
||||
if (ratio !== null) {
|
||||
if (!uniform) {
|
||||
const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attr.byMode, model });
|
||||
const lines = [attr.basis === 'flag-mtime'
|
||||
? 'Mode was set mid-session — only output after the change is attributed:'
|
||||
: 'Mode changed mid-session — output attributed per mode:'];
|
||||
for (const key of activeKeys) {
|
||||
const tokens = attr.byMode[key];
|
||||
const r = COMPRESSION[key];
|
||||
const label = key === 'none' ? 'caveman off' : key;
|
||||
const note = r != null
|
||||
? `est. ${(Math.round(tokens / (1 - r)) - tokens).toLocaleString()} saved`
|
||||
: 'no benchmark estimate';
|
||||
lines.push(` ${label}: ${tokens.toLocaleString()} tokens (${note})`);
|
||||
}
|
||||
if (attr.unknownTokens > 0) {
|
||||
lines.push(` unattributed: ${attr.unknownTokens.toLocaleString()} tokens (mode unknown — excluded from estimate)`);
|
||||
}
|
||||
lines.push(`Est. tokens saved: ${estSavedTokens.toLocaleString()}`);
|
||||
if (estSavedUsd > 0) lines.push(`Est. saved (USD): ~${formatUsd(estSavedUsd)}`);
|
||||
savings = lines.join('\n');
|
||||
|
||||
footer = 'Savings est. from benchmarks/ (mean per-task), applied only to spans whose mode is known.';
|
||||
if (estSavedUsd > 0) footer += ` Pricing for ${model}.`;
|
||||
if (attr.basis === 'flag-mtime') {
|
||||
footer += ' Tokens before the mode change could not be attributed and are excluded rather than guessed.';
|
||||
} else if (attr.unknownTokens > 0) {
|
||||
footer += ' Unattributed tokens are excluded rather than guessed.';
|
||||
}
|
||||
footer += ' Reduction is of output tokens only; input/cache usage is unchanged.';
|
||||
} else if (ratio !== null) {
|
||||
const estNormal = Math.round(outputTokens / (1 - ratio));
|
||||
const estSaved = estNormal - outputTokens;
|
||||
let usdLine = '';
|
||||
@@ -243,9 +457,22 @@ function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessio
|
||||
} else {
|
||||
footer = 'Savings est. from benchmarks/ (mean per-task). Actual varies by task.';
|
||||
}
|
||||
savings = `Est. without caveman: ${estNormal.toLocaleString()}\n` +
|
||||
`Est. tokens saved: ${estSaved.toLocaleString()} (~${Math.round(ratio * 100)}%)\n` +
|
||||
usdLine.replace(/\n$/, '');
|
||||
// No "% of your usage/budget" line here on purpose: from output tokens
|
||||
// alone the only computable ratio is the output reduction already shown
|
||||
// on the line above, and input + cache tokens (which dominate agentic
|
||||
// sessions and count against Pro/Max limits) are untouched by caveman —
|
||||
// any session-usage % would overstate real limit relief. See
|
||||
// docs/HONEST-NUMBERS.md.
|
||||
footer += ' Reduction is of output tokens only; input/cache usage is unchanged.';
|
||||
footer += ` Net subtracts the rules' est. input cost (~${ruleOverheadPerTurn().toLocaleString()}/turn — docs/HONEST-NUMBERS.md).`;
|
||||
savings = (`Est. without caveman: ${estNormal.toLocaleString()}\n` +
|
||||
`Est. tokens saved: ${estSaved.toLocaleString()} (~${Math.round(ratio * 100)}% of output)\n` +
|
||||
usdLine).replace(/\n$/, '');
|
||||
// Net only makes sense where the savings figure above is unambiguous: a
|
||||
// single benchmarked mode ran the whole span (uniform) with a known turn
|
||||
// count. Mixed-mode or partially-unattributed spans (the !uniform branch
|
||||
// above) intentionally get no net line rather than a guessed one.
|
||||
if (turns > 0) savings += '\n' + netLines({ estSavedTokens: estSaved, turns });
|
||||
} else if (mode && mode !== 'off') {
|
||||
savings = `No savings estimate for '${mode}' mode — only 'full' has benchmark data.`;
|
||||
} else {
|
||||
@@ -301,13 +528,28 @@ function main() {
|
||||
}
|
||||
|
||||
const parsed = parseSession(sessionFile);
|
||||
const mode = readFlag(path.join(claudeDir, '.caveman-active'));
|
||||
const flagPath = path.join(claudeDir, '.caveman-active');
|
||||
const mode = readFlag(flagPath);
|
||||
|
||||
// #601: attribute tokens to the mode active when each message happened,
|
||||
// via the transition log the hooks maintain (fallbacks documented on
|
||||
// attributeByMode). Never credit the whole session to the current flag.
|
||||
let flagMtimeMs = null;
|
||||
try { flagMtimeMs = fs.statSync(flagPath).mtimeMs; } catch (e) {}
|
||||
const modeLog = readModeLog(path.join(claudeDir, MODE_LOG_BASENAME));
|
||||
const attribution = attributeByMode({
|
||||
messages: parsed.messages,
|
||||
modeLog,
|
||||
mode,
|
||||
flagMtimeMs,
|
||||
outputTokens: parsed.outputTokens,
|
||||
});
|
||||
|
||||
// Append a snapshot of this session's totals to the lifetime log. Multiple
|
||||
// /caveman-stats calls in one session emit multiple lines for the same
|
||||
// session_id; aggregateHistory keeps only the latest per session_id.
|
||||
if (parsed.turns > 0) {
|
||||
const { estSavedTokens, estSavedUsd } = deriveSavings({ ...parsed, mode });
|
||||
const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attribution.byMode, model: parsed.model });
|
||||
const sessionId = path.basename(sessionFile, '.jsonl');
|
||||
appendFlag(historyPath, JSON.stringify({
|
||||
ts: Date.now(),
|
||||
@@ -315,6 +557,7 @@ function main() {
|
||||
mode: mode || null,
|
||||
model: parsed.model || null,
|
||||
output_tokens: parsed.outputTokens,
|
||||
turns: parsed.turns,
|
||||
est_saved_tokens: estSavedTokens,
|
||||
est_saved_usd: estSavedUsd,
|
||||
}));
|
||||
@@ -324,16 +567,16 @@ function main() {
|
||||
// Routed through safeWriteFlag — the suffix path is predictable and
|
||||
// user-owned, same symlink-clobber surface as the .caveman-active flag.
|
||||
const agg = aggregateHistory(historyPath, null);
|
||||
const suffix = agg.estSavedTokens > 0 ? `⛏ ${humanizeTokens(agg.estSavedTokens)}` : '';
|
||||
const suffix = agg.estSavedTokens > 0 ? `⛏ ${humanizeTokens(agg.estSavedTokens)}` : '';
|
||||
safeWriteFlag(path.join(claudeDir, '.caveman-statusline-suffix'), suffix);
|
||||
}
|
||||
|
||||
if (share) {
|
||||
process.stdout.write(formatShare({ ...parsed, mode }) + '\n');
|
||||
process.stdout.write(formatShare({ ...parsed, mode, attribution }) + '\n');
|
||||
} else {
|
||||
const scanDirs = [claudeDir, process.cwd()].filter((d, i, a) => a.indexOf(d) === i);
|
||||
const compressed = summarizeCompressed(findCompressedPairs(scanDirs));
|
||||
process.stdout.write(formatStats({ ...parsed, mode, sessionPath: sessionFile, compressed }));
|
||||
process.stdout.write(formatStats({ ...parsed, mode, sessionPath: sessionFile, compressed, attribution }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +584,7 @@ if (require.main === module) main();
|
||||
|
||||
module.exports = {
|
||||
formatStats, formatShare, formatHistory, aggregateHistory, parseDuration, deriveSavings,
|
||||
parseSession, priceForModel, formatUsd, COMPRESSION, MODEL_OUTPUT_PRICE_PER_M,
|
||||
findCompressedPairs, summarizeCompressed, humanizeTokens,
|
||||
deriveNet, ruleOverheadPerTurn, parseSession, priceForModel, formatUsd, COMPRESSION,
|
||||
MODEL_OUTPUT_PRICE_PER_M, findCompressedPairs, summarizeCompressed, humanizeTokens,
|
||||
outputReductionPct, readModeLog, attributeByMode,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
|
||||
$Flag = Join-Path $ClaudeDir ".caveman-active"
|
||||
if (-not (Test-Path $Flag)) { exit 0 }
|
||||
@@ -50,7 +51,7 @@ if ($env:CAVEMAN_STATUSLINE_SAVINGS -ne "0") {
|
||||
$SavingsItem = Get-Item -LiteralPath $SavingsFile -Force -ErrorAction Stop
|
||||
if (-not ($SavingsItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and
|
||||
$SavingsItem.Length -le 64) {
|
||||
$Savings = (Get-Content -LiteralPath $SavingsFile -Raw -ErrorAction Stop).TrimEnd()
|
||||
$Savings = (Get-Content -LiteralPath $SavingsFile -Encoding UTF8 -Raw -ErrorAction Stop).TrimEnd()
|
||||
$Savings = ($Savings -replace '[\x00-\x1F]', '')
|
||||
if ($Savings.Length -gt 0) {
|
||||
[Console]::Write(" ${Esc}[38;5;172m$Savings${Esc}[0m")
|
||||
|
||||
@@ -48,3 +48,7 @@ if [ "${CAVEMAN_STATUSLINE_SAVINGS:-1}" != "0" ]; then
|
||||
[ -n "$SAVINGS" ] && printf ' \033[38;5;172m%s\033[0m' "$SAVINGS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# An empty suffix file leaves the last [ -n ] test as the script's exit status
|
||||
# (1), and Claude Code hides the whole status bar on non-zero exit (#711).
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a package.json
|
||||
bece20e2d95b2502606dedc8b3bc329ac769a2f3638e3013d8477cabeab13f34 caveman-config.js
|
||||
397cf3d243fae04859e0c135f87f456a4972256ae425ee66457da4631ffa509a caveman-parse.js
|
||||
fea02dc4f0460433a5b892a32ccd4a735eb8bfd8974b592113574c6e55c90370 caveman-activate.js
|
||||
07a16ec91be50900eaaa5cb24d0518fc77c98b6159102e27c37d43aae1d70640 caveman-mode-tracker.js
|
||||
f598dde3cc7b701c68547c103a56d566ccc2f75d1c1f3484883ab9f396032b5d caveman-stats.js
|
||||
4b22120731be5a23f08d0b87d627cd5ac1833d994077d554aa78d7c51a212435 caveman-statusline.sh
|
||||
1690c639f05940cbff39e0383a27053898b30c224aa651043db29b2842cb524a caveman-statusline.ps1
|
||||
9b72e18343a5487acde46d795f4871abfae21212b6eeb853d54981aae260bdf1 cavecrew-model-overrides.js
|
||||
@@ -24,7 +24,7 @@ $HooksDir = Join-Path $ClaudeDir "hooks"
|
||||
$Settings = Join-Path $ClaudeDir "settings.json"
|
||||
$RepoUrl = "https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks"
|
||||
|
||||
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1")
|
||||
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1", "cavecrew-model-overrides.js")
|
||||
|
||||
# Resolve source — works from repo clone or remote
|
||||
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $null }
|
||||
|
||||
@@ -37,7 +37,7 @@ HOOKS_DIR="$CLAUDE_DIR/hooks"
|
||||
SETTINGS="$CLAUDE_DIR/settings.json"
|
||||
REPO_URL="https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks"
|
||||
|
||||
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh")
|
||||
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh" "cavecrew-model-overrides.js")
|
||||
|
||||
# Resolve source — works from repo clone or curl pipe
|
||||
SCRIPT_DIR=""
|
||||
|
||||
@@ -11,7 +11,7 @@ $HooksDir = Join-Path $ClaudeDir "hooks"
|
||||
$Settings = Join-Path $ClaudeDir "settings.json"
|
||||
$FlagFile = Join-Path $ClaudeDir ".caveman-active"
|
||||
|
||||
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1")
|
||||
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1", "cavecrew-model-overrides.js")
|
||||
|
||||
# Detect if caveman is installed as a plugin
|
||||
$PluginInstalled = $false
|
||||
|
||||
@@ -10,7 +10,7 @@ HOOKS_DIR="$CLAUDE_DIR/hooks"
|
||||
SETTINGS="$CLAUDE_DIR/settings.json"
|
||||
FLAG_FILE="$CLAUDE_DIR/.caveman-active"
|
||||
|
||||
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh")
|
||||
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh" "cavecrew-model-overrides.js")
|
||||
|
||||
# Detect if caveman is installed as a plugin (check plugin cache)
|
||||
PLUGIN_INSTALLED=0
|
||||
|
||||
Binary file not shown.
@@ -40,9 +40,9 @@ const debug = process.env.CAVEMAN_SHRINK_DEBUG === '1';
|
||||
const fields = (process.env.CAVEMAN_SHRINK_FIELDS || 'description')
|
||||
.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
const upstream = spawn(args[0], args.slice(1), {
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
});
|
||||
const { getSpawnOptions } = require('./spawn-options');
|
||||
|
||||
const upstream = spawn(args[0], args.slice(1), getSpawnOptions());
|
||||
|
||||
upstream.on('error', err => {
|
||||
process.stderr.write(`caveman-shrink: failed to spawn upstream: ${err.message}\n`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "caveman-shrink",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "MCP proxy that compresses prose fields (tool descriptions, etc.) using caveman rules. Same accuracy, fewer context tokens.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/JuliusBrussee/caveman",
|
||||
@@ -25,6 +25,7 @@
|
||||
"files": [
|
||||
"index.js",
|
||||
"compress.js",
|
||||
"spawn-options.js",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Spawn options for the upstream MCP child process.
|
||||
//
|
||||
// Windows: spawn('npx', ...) (and any .cmd shim such as 'gemini') hits ENOENT
|
||||
// because PATHEXT resolution only happens when child_process spawns through
|
||||
// a shell. POSIX systems resolve fine without a shell. Keep shell:false on
|
||||
// POSIX to avoid argv quoting surprises.
|
||||
//
|
||||
// Exported standalone so the behavior is unit-testable without re-running
|
||||
// the CLI entry point (index.js exits immediately when args are empty).
|
||||
|
||||
'use strict';
|
||||
|
||||
function getSpawnOptions(platform = process.platform) {
|
||||
return {
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
shell: platform === 'win32',
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getSpawnOptions };
|
||||
@@ -11,7 +11,7 @@ opencode's `session.created` + `tui.prompt.append` lifecycle hooks.
|
||||
| `package.json` | Marks the directory as ESM so Bun loads `plugin.js` correctly. |
|
||||
| `commands/*.md` | Six slash-command prompt templates (`/caveman`, `/caveman-commit`, …). |
|
||||
|
||||
The installer (`bin/install.js --only opencode`) copies these alongside
|
||||
The installer (`cli/install.js --only opencode`) copies these alongside
|
||||
`src/hooks/caveman-config.js` (for the symlink-safe flag-write helpers, renamed
|
||||
to `caveman-config.cjs` because this directory is `"type": "module"`) into
|
||||
`~/.config/opencode/plugins/caveman/` and patches `opencode.json` with a
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
description: Compress a markdown/text file into caveman format to save tokens
|
||||
---
|
||||
Compress the file at: $ARGUMENTS
|
||||
|
||||
Run the `caveman-compress` skill against the given filepath. The skill rewrites
|
||||
prose into terse caveman style — drops articles, filler, hedging — while
|
||||
preserving code blocks, inline code, URLs, file paths, commands, and markdown
|
||||
structure exactly. Original is backed up as `<file>.original.md` before
|
||||
overwrite.
|
||||
|
||||
Only compress natural-language files (`.md`, `.txt`, `.typ`, `.tex`,
|
||||
extensionless). Refuse source/config files (`.py`, `.js`, `.ts`, `.json`,
|
||||
`.yaml`, `.toml`, `.sh`, etc.). Never compress an existing `*.original.md`
|
||||
backup.
|
||||
+128
-99
@@ -1,120 +1,130 @@
|
||||
// caveman — opencode plugin
|
||||
//
|
||||
// Mirrors the Claude Code SessionStart + UserPromptSubmit hook pair using
|
||||
// opencode's lifecycle hook system. Bun ESM module; loads the existing
|
||||
// security-hardened helpers from caveman-config.js via createRequire so the
|
||||
// symlink-safe flag-write code lives in one place.
|
||||
// Provides dynamic caveman mode tracking for opencode:
|
||||
// - Writes the mode flag on each session start (via the `event` dispatcher)
|
||||
// - Parses user messages for /caveman commands and natural-language toggles
|
||||
// - Injects per-turn reinforcement into the system prompt
|
||||
//
|
||||
// Bun ESM module; loads the existing security-hardened helpers from
|
||||
// caveman-config.js via createRequire so the symlink-safe flag-write code
|
||||
// lives in one place. Same trick loads caveman-parse.js (#602) so the mode-
|
||||
// change parsing is a single shared source with caveman-mode-tracker.js.
|
||||
//
|
||||
// Layout once installed:
|
||||
// ~/.config/opencode/plugins/caveman/
|
||||
// ├── package.json
|
||||
// ├── plugin.js ← this file
|
||||
// └── caveman-config.js ← copied sibling of src/hooks/caveman-config.js
|
||||
// ├── caveman-config.cjs ← copied sibling of src/hooks/caveman-config.js
|
||||
// └── caveman-parse.cjs ← copied sibling of src/hooks/caveman-parse.js
|
||||
//
|
||||
// Always-on caveman ruleset is provided separately via
|
||||
// ~/.config/opencode/AGENTS.md (Tier-3 base) so this plugin only handles
|
||||
// dynamic state — flag writes, slash-command parsing, natural-language
|
||||
// activation, and per-prompt reinforcement. opencode's `session.created`
|
||||
// payload doesn't expose a documented system-prompt-injection return, so we
|
||||
// don't try to emit ruleset content here.
|
||||
// The always-on caveman ruleset is provided separately via
|
||||
// ~/.config/opencode/AGENTS.md (Tier-3 base). This plugin handles dynamic
|
||||
// state only: flag writes, slash-command parsing, natural-language
|
||||
// activation, and per-turn reinforcement.
|
||||
//
|
||||
// Hook mapping (opencode >= 1.15.x):
|
||||
// - event (event.type === 'session.created'): session-init flag write,
|
||||
// re-fires per session rather than once per plugin-process load
|
||||
// - chat.message: intercept user prompts for mode changes
|
||||
// - experimental.chat.system.transform: inject reinforcement per-turn
|
||||
//
|
||||
// Note: opencode does NOT support 'session.created' or 'tui.prompt.append'
|
||||
// as named plugin-hook keys. 'session.created' is an event *type* dispatched
|
||||
// through the single `event` handler; the old direct-key handlers were
|
||||
// silently ignored. See:
|
||||
// https://github.com/JuliusBrussee/caveman/issues/418
|
||||
// https://github.com/JuliusBrussee/caveman/issues/421
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { existsSync, unlinkSync } from 'node:fs';
|
||||
import { existsSync, unlinkSync, readFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// When installed: caveman-config.cjs sits next to plugin.js (copied by
|
||||
// bin/install.js, renamed to .cjs because this directory's package.json
|
||||
// declares "type": "module" — bare .js would be loaded as ESM and break
|
||||
// require()). When loaded from the source tree (tests, dev): fall back
|
||||
// to the canonical src/hooks/caveman-config.js, which lives in a directory
|
||||
// whose own package.json pins "type": "commonjs". One source of truth
|
||||
// either way.
|
||||
// cli/install.js, renamed to .cjs because this directory's package.json
|
||||
// declares "type": "module" — bare .js would be loaded as ESM). When loaded
|
||||
// from the source tree (tests, dev): fall back to the canonical
|
||||
// src/hooks/caveman-config.js, which lives in a directory whose own
|
||||
// package.json pins "type": "commonjs". One source of truth either way.
|
||||
//
|
||||
// Loaded by evaluating the file as CommonJS by hand, NOT via the module
|
||||
// loader: opencode runs plugins inside a compiled Bun binary where
|
||||
// require() of on-disk files is rejected ("require() async module is
|
||||
// unsupported") and await import() of a CJS file yields an empty namespace —
|
||||
// both silently break the plugin (#418 follow-up). createRequire() still
|
||||
// resolves node BUILT-INS fine in the compiled binary, which is all
|
||||
// caveman-config needs (fs/path/os).
|
||||
function loadConfig() {
|
||||
try { return require(join(here, 'caveman-config.cjs')); }
|
||||
catch (_) { return require(join(here, '..', '..', 'hooks', 'caveman-config.js')); }
|
||||
const installed = join(here, 'caveman-config.cjs');
|
||||
const dev = join(here, '..', '..', 'hooks', 'caveman-config.js');
|
||||
const target = existsSync(installed) ? installed : dev;
|
||||
const code = readFileSync(target, 'utf8').replace(/^#![^\n]*\n/, '');
|
||||
const mod = { exports: {} };
|
||||
// Base require on the loaded file, not plugin.js — caveman-parse.js does a
|
||||
// relative require('./caveman-config') that must resolve against src/hooks/
|
||||
// in the dev layout and against pluginDir when installed.
|
||||
new Function('module', 'exports', 'require', '__dirname', '__filename', code)(
|
||||
mod, mod.exports, createRequire(pathToFileURL(target).href), dirname(target), target
|
||||
);
|
||||
return mod.exports;
|
||||
}
|
||||
const config = loadConfig();
|
||||
|
||||
const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = config;
|
||||
const { getDefaultMode, safeWriteFlag, readFlag } = config;
|
||||
|
||||
// Modes handled by independent skills — not selectable via /caveman <arg>.
|
||||
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
|
||||
// Load the shared mode-change parser (#602) the same way loadConfig() loads
|
||||
// caveman-config.js — see the doc comment above loadConfig() for why this
|
||||
// can't go through require()/import() in a compiled Bun binary.
|
||||
function loadParse() {
|
||||
const installed = join(here, 'caveman-parse.cjs');
|
||||
const dev = join(here, '..', '..', 'hooks', 'caveman-parse.js');
|
||||
const target = existsSync(installed) ? installed : dev;
|
||||
const code = readFileSync(target, 'utf8').replace(/^#![^\n]*\n/, '');
|
||||
const mod = { exports: {} };
|
||||
new Function('module', 'exports', 'require', '__dirname', '__filename', code)(
|
||||
mod, mod.exports, createRequire(pathToFileURL(target).href), dirname(target), target
|
||||
);
|
||||
return mod.exports;
|
||||
}
|
||||
const { parseModeChange, INDEPENDENT_MODES } = loadParse();
|
||||
|
||||
// opencode resolves its config dir from $XDG_CONFIG_HOME, else ~/.config/opencode
|
||||
// on every platform — including Windows, where it uses %USERPROFILE%\.config\opencode
|
||||
// (NOT %APPDATA%). os.homedir() is %USERPROFILE% on win32, so the default branch
|
||||
// is already correct cross-platform.
|
||||
function opencodeConfigDir() {
|
||||
if (process.env.XDG_CONFIG_HOME) {
|
||||
return path.join(process.env.XDG_CONFIG_HOME, 'opencode');
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'opencode'
|
||||
);
|
||||
}
|
||||
return path.join(os.homedir(), '.config', 'opencode');
|
||||
}
|
||||
|
||||
const flagPath = path.join(opencodeConfigDir(), '.caveman-active');
|
||||
|
||||
function reinforcementLine(mode) {
|
||||
return 'CAVEMAN MODE ACTIVE (' + mode + '). ' +
|
||||
'Drop articles/filler/pleasantries/hedging. Fragments OK. ' +
|
||||
'Code/commits/security: write normal.';
|
||||
return 'CAVEMAN MODE ACTIVE (' + mode + ') — session ruleset applies.';
|
||||
}
|
||||
|
||||
// Parse a prompt for slash-command activation or natural-language toggles.
|
||||
// Returns the new mode to write, the literal string 'off' to deactivate, or
|
||||
// null when the prompt doesn't change state. Mirrors caveman-mode-tracker.js.
|
||||
function parseModeChange(promptRaw) {
|
||||
const prompt = (promptRaw || '').trim().toLowerCase();
|
||||
if (!prompt) return null;
|
||||
|
||||
// Natural-language deactivation — checked before activation so "stop talking
|
||||
// like caveman" doesn't trip the activation regex.
|
||||
if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) ||
|
||||
/\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) ||
|
||||
/\bnormal mode\b/i.test(prompt)) {
|
||||
return 'off';
|
||||
function applyModeChange(change) {
|
||||
if (!change) return;
|
||||
if (change.action === 'clear') {
|
||||
try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
|
||||
// Natural-language activation
|
||||
if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) ||
|
||||
/\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) {
|
||||
const mode = getDefaultMode();
|
||||
return mode === 'off' ? null : mode;
|
||||
if (change.action === 'set' && change.mode) {
|
||||
safeWriteFlag(flagPath, change.mode);
|
||||
}
|
||||
|
||||
// Slash-command parsing — opencode also expands command files, but if the
|
||||
// user types the literal slash command we still want to flip the flag.
|
||||
if (prompt.startsWith('/caveman')) {
|
||||
const parts = prompt.split(/\s+/);
|
||||
const cmd = parts[0];
|
||||
const arg = parts[1] || '';
|
||||
|
||||
if (cmd === '/caveman-commit') return 'commit';
|
||||
if (cmd === '/caveman-review') return 'review';
|
||||
if (cmd === '/caveman-compress') return 'compress';
|
||||
|
||||
if (cmd === '/caveman') {
|
||||
if (!arg) return getDefaultMode();
|
||||
if (arg === 'off' || arg === 'stop' || arg === 'disable') return 'off';
|
||||
if (arg === 'wenyan-full') return 'wenyan';
|
||||
if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return arg;
|
||||
// Unknown arg — leave flag alone. No silent overwrite.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyModeChange(mode) {
|
||||
if (!mode) return;
|
||||
// Session-start logic — extracted so the `event` dispatcher (opencode >= 1.15)
|
||||
// drives one shared implementation. Re-fires on every `session.created` event,
|
||||
// so a new session in a long-lived plugin process re-asserts the flag.
|
||||
function handleSessionCreated() {
|
||||
const mode = getDefaultMode();
|
||||
if (mode === 'off') {
|
||||
try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {}
|
||||
return;
|
||||
@@ -122,33 +132,52 @@ function applyModeChange(mode) {
|
||||
safeWriteFlag(flagPath, mode);
|
||||
}
|
||||
|
||||
export const CavemanPlugin = async (_ctx) => ({
|
||||
'session.created': async () => {
|
||||
const mode = getDefaultMode();
|
||||
if (mode === 'off') {
|
||||
try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
safeWriteFlag(flagPath, mode);
|
||||
export const CavemanPlugin = async (_ctx) => {
|
||||
// Assert the flag at plugin load as well: in one-shot `opencode run` the
|
||||
// first session.created publishes before plugin event dispatch is wired,
|
||||
// so the event handler alone misses it. The factory-time write covers that
|
||||
// race; the event handler re-asserts on every later session in long-lived
|
||||
// TUI processes.
|
||||
handleSessionCreated();
|
||||
|
||||
return {
|
||||
// opencode dispatches session/lifecycle events through a single `event`
|
||||
// handler keyed on event.type; the older direct top-level
|
||||
// 'session.created' key is silently ignored. Routing session-init through
|
||||
// here means the flag is rewritten on every new session, not just once when
|
||||
// the plugin module loads. See https://opencode.ai/docs/plugins#events.
|
||||
event: async ({ event } = {}) => {
|
||||
if (event && event.type === 'session.created') handleSessionCreated();
|
||||
},
|
||||
|
||||
// opencode's TUI prompt-append hook fires before the prompt is sent to the
|
||||
// model. We use it for two things: react to mode-changing prompts (slash
|
||||
// commands + natural language), and append a one-line reinforcement when
|
||||
// caveman is active so the model can't drift mid-session. Returning an
|
||||
// object with `append` is the documented way to inject prompt content.
|
||||
'tui.prompt.append': async (input) => {
|
||||
const promptText = (input && (input.prompt || input.text)) || '';
|
||||
|
||||
const change = parseModeChange(promptText);
|
||||
if (change) applyModeChange(change);
|
||||
// Intercept user messages to detect /caveman commands and natural-language
|
||||
// mode toggles. opencode fires chat.message with (input, output) where
|
||||
// output.parts is the array of message parts; text parts carry .text.
|
||||
// Return value is ignored — state changes happen via the flag file.
|
||||
// expandedTpl: opencode replaces a typed slash command with its command
|
||||
// file's prose before this hook sees it. unwrapQuotes: the non-interactive
|
||||
// `run` path delivers the message wrapped in literal quote characters.
|
||||
'chat.message': async (_input, output) => {
|
||||
if (!output || !output.parts) return;
|
||||
for (const part of output.parts) {
|
||||
if (part && part.type === 'text' && part.text) {
|
||||
const change = parseModeChange(part.text, { getDefaultMode, expandedTpl: true, unwrapQuotes: true });
|
||||
if (change) applyModeChange(change);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Inject the reinforcement line into the system prompt when caveman is
|
||||
// active. opencode calls this before every LLM request and expects the hook
|
||||
// to mutate output.system (a string[]); the return value is discarded.
|
||||
'experimental.chat.system.transform': async (_input, output) => {
|
||||
if (!output || !Array.isArray(output.system)) return;
|
||||
const active = readFlag(flagPath);
|
||||
if (active && !INDEPENDENT_MODES.has(active)) {
|
||||
return { append: reinforcementLine(active) };
|
||||
output.system.push(reinforcementLine(active));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export default CavemanPlugin;
|
||||
|
||||
@@ -36,17 +36,17 @@ const SENTINEL = 'Respond terse like smart caveman';
|
||||
|
||||
// OpenClaw is a global workspace tool (not per-repo) and needs two write
|
||||
// targets — a skill folder + a SOUL.md bootstrap block. The shared helper
|
||||
// lives at bin/lib/openclaw.js; we require it lazily so caveman-init.js
|
||||
// lives at cli/lib/openclaw.js; we require it lazily so caveman-init.js
|
||||
// keeps working when run standalone (curl|node) without the helper on disk.
|
||||
function loadOpenclawHelper() {
|
||||
try {
|
||||
return require(path.join(__dirname, '..', '..', 'bin', 'lib', 'openclaw.js'));
|
||||
return require(path.join(__dirname, '..', '..', 'cli', 'lib', 'openclaw.js'));
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
const AGENTS = [
|
||||
{ id: 'cursor', file: '.cursor/rules/caveman.mdc',
|
||||
frontmatter: '---\ndescription: "Caveman mode — terse communication, ~75% fewer tokens, full technical accuracy"\nalwaysApply: true\n---\n\n',
|
||||
frontmatter: '---\ndescription: "Caveman mode — terse communication, 65% fewer output tokens (measured), full technical accuracy"\nalwaysApply: true\n---\n\n',
|
||||
mode: 'replace' },
|
||||
{ id: 'windsurf', file: '.windsurf/rules/caveman.md',
|
||||
frontmatter: '---\ntrigger: always_on\n---\n\n',
|
||||
@@ -201,6 +201,10 @@ function main() {
|
||||
if (opts.dryRun) console.log('(dry run — no files were written)');
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
// Run when executed directly AND when piped via `curl … | node -` (the
|
||||
// documented standalone path, #603): under stdin execution require.main is
|
||||
// undefined and module.id is '[stdin]', so the classic guard alone silently
|
||||
// no-ops with exit code 0 — the worst kind of failure.
|
||||
if (require.main === module || (!require.main && module.id === '[stdin]')) main();
|
||||
|
||||
module.exports = { processAgent, loadRuleBody, AGENTS, SENTINEL, RULE_BODY };
|
||||
|
||||
@@ -9,7 +9,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const INSTALLER = path.resolve(HERE, '..', '..', 'bin', 'install.js');
|
||||
const INSTALLER = path.resolve(HERE, '..', '..', 'cli', 'install.js');
|
||||
|
||||
function freshTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'cm-dryrun-'));
|
||||
@@ -18,7 +18,10 @@ function freshTmpDir() {
|
||||
test('dry-run --only claude prints plan and writes nothing', () => {
|
||||
const cfg = freshTmpDir();
|
||||
const r = spawnSync('node', [INSTALLER,
|
||||
'--dry-run', '--only', 'claude', '--no-mcp-shrink', '--non-interactive',
|
||||
// --with-hooks: since #392/#393 the default only wires standalone hooks
|
||||
// when the plugin install fails. Force the hook-planning path so the
|
||||
// "would install / would merge" assertions below are exercised.
|
||||
'--dry-run', '--only', 'claude', '--with-hooks', '--no-mcp-shrink', '--non-interactive',
|
||||
'--config-dir', cfg,
|
||||
], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } });
|
||||
assert.equal(r.status, 0);
|
||||
@@ -36,6 +39,17 @@ test('dry-run --only claude prints plan and writes nothing', () => {
|
||||
assert.equal(fs.existsSync(path.join(cfg, 'hooks')), false);
|
||||
});
|
||||
|
||||
test('dry-run --only gemini passes --consent (issue #676 — avoids the confirmation-prompt hang)', () => {
|
||||
const cfg = freshTmpDir();
|
||||
// --only forces installGemini to run regardless of whether `gemini` is
|
||||
// actually on PATH — safe to assert against on any CI runner.
|
||||
const r = spawnSync('node', [INSTALLER,
|
||||
'--dry-run', '--only', 'gemini', '--non-interactive', '--config-dir', cfg,
|
||||
], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } });
|
||||
assert.equal(r.status, 0);
|
||||
assert.match(r.stdout, /would run: gemini extensions install https:\/\/github\.com\/\S+ --consent/);
|
||||
});
|
||||
|
||||
test('dry-run --uninstall does not delete files', () => {
|
||||
const cfg = freshTmpDir();
|
||||
// Seed a fake installation
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
// strip those out of PATH in the uninstall test so the user's real
|
||||
// plugin/extension state is never touched.
|
||||
// - The plugin install step makes a network call (clones the marketplace).
|
||||
// We tolerate failure there — only the hook/settings assertions matter
|
||||
// because the hooks-installer runs regardless of plugin-install status.
|
||||
// We tolerate failure there — only the hook/settings assertions matter.
|
||||
// Since #392/#393, default install wires standalone hooks ONLY when the
|
||||
// plugin install fails (to avoid double-firing), so these tests pass
|
||||
// --with-hooks to force the standalone wiring path deterministically.
|
||||
// - Each fresh-install case spawns a real `claude plugin install` (~300MB
|
||||
// of git clone). Run the test runner with `--test-concurrency=1` to
|
||||
// avoid OOM on memory-constrained CI runners.
|
||||
@@ -33,9 +35,9 @@ import { createRequire } from 'node:module';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, '..', '..');
|
||||
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
const INSTALLER = path.join(REPO_ROOT, 'cli', 'install.js');
|
||||
const requireCjs = createRequire(import.meta.url);
|
||||
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'settings.js'));
|
||||
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'settings.js'));
|
||||
|
||||
function freshTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-freshinstall-'));
|
||||
@@ -69,7 +71,7 @@ function runInstaller(args, configDir, extraEnv = {}) {
|
||||
}
|
||||
|
||||
function hasClaudeCli() {
|
||||
// We can't import bin/install.js's hasCmd directly (CJS, not exported), but
|
||||
// We can't import cli/install.js's hasCmd directly (CJS, not exported), but
|
||||
// a plain `command -v` / `where` shell-out is equivalent for this purpose.
|
||||
if (process.platform === 'win32') {
|
||||
return spawnSync('where', ['claude'], { stdio: 'ignore' }).status === 0;
|
||||
@@ -98,9 +100,9 @@ function cavemanHookCommands(settings, event, marker) {
|
||||
test('fresh install populates hooks dir and settings.json (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH; the claude provider is the only path that wires hooks' }, () => {
|
||||
const dir = freshTmpDir();
|
||||
try {
|
||||
const r = runInstaller(['--only', 'claude'], dir);
|
||||
// The plugin install step may fail (network, auth) but the hooks step
|
||||
// runs regardless. We only require the hooks-side state.
|
||||
const r = runInstaller(['--only', 'claude', '--with-hooks'], dir);
|
||||
// The plugin install step may fail (network, auth); --with-hooks forces
|
||||
// the standalone hook wiring regardless. We only require the hooks-side state.
|
||||
assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);
|
||||
|
||||
const hooks = path.join(dir, 'hooks');
|
||||
@@ -131,9 +133,9 @@ test('fresh install populates hooks dir and settings.json (skipped without `clau
|
||||
test('idempotent install does not duplicate hook entries (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH' }, () => {
|
||||
const dir = freshTmpDir();
|
||||
try {
|
||||
const r1 = runInstaller(['--only', 'claude'], dir);
|
||||
const r1 = runInstaller(['--only', 'claude', '--with-hooks'], dir);
|
||||
assert.notEqual(r1.status, 2, `first install argv error: ${r1.stderr}`);
|
||||
const r2 = runInstaller(['--only', 'claude'], dir);
|
||||
const r2 = runInstaller(['--only', 'claude', '--with-hooks'], dir);
|
||||
assert.notEqual(r2.status, 2, `second install argv error: ${r2.stderr}`);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'));
|
||||
@@ -160,7 +162,7 @@ test('uninstall strips caveman hooks but preserves user-authored ones (skipped w
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
const r1 = runInstaller(['--only', 'claude'], dir);
|
||||
const r1 = runInstaller(['--only', 'claude', '--with-hooks'], dir);
|
||||
assert.notEqual(r1.status, 2, `install argv error: ${r1.stderr}`);
|
||||
|
||||
// Strip claude/gemini from PATH for uninstall so we don't touch the user's
|
||||
@@ -200,9 +202,75 @@ test('uninstall strips caveman hooks but preserves user-authored ones (skipped w
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test: uninstall removes stale per-session state, keeps lifetime history (#635) ──
|
||||
// Pre-fix, uninstall only ever removed `.caveman-active`, leaving
|
||||
// `.caveman-active.prev`, `.caveman-mode-log.jsonl`, `.caveman-statusline-suffix`,
|
||||
// and `.caveman-nudge-shown` behind forever. `.caveman-history.jsonl` is the
|
||||
// user's lifetime savings ledger and must be kept (with a "kept" note), not
|
||||
// treated as stale state. Runs unconditionally — no `claude` CLI needed, this
|
||||
// only exercises the file-cleanup part of uninstall.
|
||||
test('uninstall removes stale per-session state files but keeps lifetime history', () => {
|
||||
const dir = freshTmpDir();
|
||||
try {
|
||||
const staleFiles = [
|
||||
'.caveman-active',
|
||||
'.caveman-active.prev',
|
||||
'.caveman-mode-log.jsonl',
|
||||
'.caveman-statusline-suffix',
|
||||
'.caveman-nudge-shown',
|
||||
];
|
||||
for (const f of staleFiles) fs.writeFileSync(path.join(dir, f), 'x');
|
||||
const historyPath = path.join(dir, '.caveman-history.jsonl');
|
||||
fs.writeFileSync(historyPath, '{"ts":1}\n');
|
||||
|
||||
const cleanPath = pathWithout(['claude', 'gemini']);
|
||||
const r = runInstaller(['--uninstall'], dir, { PATH: cleanPath });
|
||||
assert.notEqual(r.status, 2, `uninstall argv error: ${r.stderr}`);
|
||||
|
||||
for (const f of staleFiles) {
|
||||
assert.equal(fs.existsSync(path.join(dir, f)), false, `${f} should be removed by uninstall`);
|
||||
}
|
||||
assert.ok(fs.existsSync(historyPath), '.caveman-history.jsonl must survive uninstall');
|
||||
assert.match(r.stdout, /kept .*caveman-history\.jsonl.*lifetime history/,
|
||||
'uninstall must explain why lifetime history was kept');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test: uninstall --dry-run is honest (doesn't claim removal, doesn't delete) ──
|
||||
test('uninstall --dry-run reports "would remove" and deletes nothing', () => {
|
||||
const dir = freshTmpDir();
|
||||
const hooksDir = path.join(dir, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, '.caveman-active'), 'full');
|
||||
fs.writeFileSync(path.join(dir, '.caveman-mode-log.jsonl'), '{}\n');
|
||||
fs.writeFileSync(path.join(hooksDir, 'caveman-activate.js'), '// stub\n');
|
||||
|
||||
const cleanPath = pathWithout(['claude', 'gemini']);
|
||||
const r = runInstaller(['--uninstall', '--dry-run'], dir, { PATH: cleanPath });
|
||||
assert.notEqual(r.status, 2, `uninstall dry-run argv error: ${r.stderr}`);
|
||||
|
||||
// Nothing actually deleted under --dry-run.
|
||||
assert.ok(fs.existsSync(path.join(dir, '.caveman-active')), 'dry-run must not delete .caveman-active');
|
||||
assert.ok(fs.existsSync(path.join(dir, '.caveman-mode-log.jsonl')), 'dry-run must not delete .caveman-mode-log.jsonl');
|
||||
assert.ok(fs.existsSync(path.join(hooksDir, 'caveman-activate.js')), 'dry-run must not delete hook files');
|
||||
|
||||
// Every per-file line for our files says "would remove", never a bare "removed".
|
||||
const lines = r.stdout.split('\n').filter(l => /caveman-active\b|caveman-mode-log\.jsonl|caveman-activate\.js/.test(l));
|
||||
assert.ok(lines.length > 0, 'expected at least one reported line for the seeded files');
|
||||
for (const line of lines) {
|
||||
assert.match(line, /would remove/, `dry-run line must say "would remove", got: ${line}`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test: settings.json with JSONC comments doesn't crash (#249) ───────────
|
||||
// Regression guard: the installer used to crash here because JSON.parse can't
|
||||
// eat // or /* */. bin/lib/settings.js now strips them before merging.
|
||||
// eat // or /* */. cli/lib/settings.js now strips them before merging.
|
||||
test('install tolerates JSONC settings.json (comments + trailing commas)', { skip: !hasClaudeCli() && 'claude CLI not on PATH' }, () => {
|
||||
const dir = freshTmpDir();
|
||||
try {
|
||||
@@ -215,7 +283,7 @@ test('install tolerates JSONC settings.json (comments + trailing commas)', { ski
|
||||
}
|
||||
`);
|
||||
|
||||
const r = runInstaller(['--only', 'claude'], dir);
|
||||
const r = runInstaller(['--only', 'claude', '--with-hooks'], dir);
|
||||
assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);
|
||||
|
||||
// After install, settings.json must be strict-JSON parseable.
|
||||
@@ -261,7 +329,7 @@ test('openclaw install writes skill folder + SOUL.md bootstrap', () => {
|
||||
assert.match(skillRaw, /\nalways:\s*true/, 'skill missing always: true frontmatter');
|
||||
|
||||
// Body after the merged frontmatter must match the source body.
|
||||
const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
|
||||
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
|
||||
const srcRaw = fs.readFileSync(SKILL_BODY_SRC, 'utf8');
|
||||
const srcBody = helper.splitFrontmatter(srcRaw).body;
|
||||
const installedBody = helper.splitFrontmatter(skillRaw).body;
|
||||
@@ -279,6 +347,64 @@ test('openclaw install writes skill folder + SOUL.md bootstrap', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('openclaw install stamps the skill version from PINNED_REF, not a hardcoded 1.0.0', () => {
|
||||
const dir = freshTmpDir();
|
||||
const ws = path.join(dir, 'ws');
|
||||
fs.mkdirSync(ws);
|
||||
try {
|
||||
const r = spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], {
|
||||
env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);
|
||||
|
||||
const { OPENCLAW_SKILL_VERSION } = requireCjs(INSTALLER);
|
||||
const skillRaw = fs.readFileSync(path.join(ws, 'skills', 'caveman', 'SKILL.md'), 'utf8');
|
||||
assert.match(skillRaw, new RegExp(`\\nversion:\\s*${OPENCLAW_SKILL_VERSION.replace(/\./g, '\\.')}\\b`),
|
||||
`expected version: ${OPENCLAW_SKILL_VERSION} threaded from PINNED_REF`);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('openclaw --no-always skips `always: true` frontmatter and the SOUL.md bootstrap append', () => {
|
||||
const dir = freshTmpDir();
|
||||
const ws = path.join(dir, 'ws');
|
||||
fs.mkdirSync(ws);
|
||||
try {
|
||||
const r = spawnSync('node', [INSTALLER, '--only', 'openclaw', '--no-always', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], {
|
||||
env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);
|
||||
|
||||
const skillRaw = fs.readFileSync(path.join(ws, 'skills', 'caveman', 'SKILL.md'), 'utf8');
|
||||
assert.doesNotMatch(skillRaw, /\nalways:\s*true/, '--no-always must omit the always: true frontmatter key');
|
||||
assert.match(skillRaw, /\nversion:\s*\d+\.\d+\.\d+/, '--no-always must still stamp a version key');
|
||||
assert.equal(fs.existsSync(path.join(ws, 'SOUL.md')), false, '--no-always must not create/append SOUL.md');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('openclaw default (no flag) behavior is unchanged: always: true + SOUL.md still written', () => {
|
||||
const dir = freshTmpDir();
|
||||
const ws = path.join(dir, 'ws');
|
||||
fs.mkdirSync(ws);
|
||||
try {
|
||||
const r = spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], {
|
||||
env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);
|
||||
const skillRaw = fs.readFileSync(path.join(ws, 'skills', 'caveman', 'SKILL.md'), 'utf8');
|
||||
assert.match(skillRaw, /\nalways:\s*true/, 'default install must still set always: true');
|
||||
assert.ok(fs.existsSync(path.join(ws, 'SOUL.md')), 'default install must still write SOUL.md');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('openclaw install is idempotent: skill frontmatter not double-prepended, SOUL.md has one marker block', () => {
|
||||
const dir = freshTmpDir();
|
||||
const ws = path.join(dir, 'ws');
|
||||
@@ -398,3 +524,178 @@ test('lib settings.addCommandHook is idempotent across two synthetic install pas
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test: --force migrates a mixed legacy AGENTS.md instead of wiping it (#594)
|
||||
// The old code replaced the whole file with the fenced block whenever the
|
||||
// legacy un-fenced sentinel was present — and the installer's own hint told
|
||||
// users with mixed files to run exactly that. User content must survive.
|
||||
test('opencode: --force on legacy AGENTS.md preserves user content and takes a backup', () => {
|
||||
const dir = freshTmpDir();
|
||||
const xdg = path.join(dir, 'xdg');
|
||||
const ocDir = path.join(xdg, 'opencode');
|
||||
fs.mkdirSync(ocDir, { recursive: true });
|
||||
const agentsMd = path.join(ocDir, 'AGENTS.md');
|
||||
const legacyBody = fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'src', 'rules', 'caveman-activate.md'), 'utf8').trimEnd() + '\n';
|
||||
const userRules = '# My precious user rules\n\nAlways use tabs.\n';
|
||||
fs.writeFileSync(agentsMd, userRules + '\n' + legacyBody);
|
||||
try {
|
||||
const r = spawnSync('node', [INSTALLER, '--only', 'opencode', '--force', '--non-interactive', '--no-mcp-shrink', '--config-dir', path.join(dir, 'claude')], {
|
||||
env: { ...process.env, XDG_CONFIG_HOME: xdg, NO_COLOR: '1' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(r.status, 2, `installer argv error: ${r.stderr}`);
|
||||
|
||||
const after = fs.readFileSync(agentsMd, 'utf8');
|
||||
assert.match(after, /My precious user rules/, 'user heading wiped by --force migration');
|
||||
assert.match(after, /Always use tabs\./, 'user rule wiped by --force migration');
|
||||
assert.match(after, /<!-- caveman-begin -->/, 'fenced block missing after migration');
|
||||
assert.match(after, /<!-- caveman-end -->/, 'fence end missing after migration');
|
||||
// Legacy un-fenced copy must be gone: sentinel appears only inside the fence.
|
||||
const beforeFence = after.slice(0, after.indexOf('<!-- caveman-begin -->'));
|
||||
assert.doesNotMatch(beforeFence, /Respond terse like smart caveman/,
|
||||
'legacy un-fenced block still present above the fence');
|
||||
assert.ok(fs.existsSync(agentsMd + '.bak'), 'backup missing after --force migration');
|
||||
assert.match(fs.readFileSync(agentsMd + '.bak', 'utf8'), /My precious user rules/,
|
||||
'backup does not contain the original content');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tests: mergeOpenclawFrontmatter version/always opts (lib-level, direct) ──
|
||||
test('mergeOpenclawFrontmatter: custom version opt overrides the SKILL_VERSION fallback', () => {
|
||||
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
|
||||
const out = helper.mergeOpenclawFrontmatter('---\ndescription: x\n---\nbody\n', { version: '1.9.1' });
|
||||
assert.match(out, /\nversion: 1\.9\.1\n/);
|
||||
assert.match(out, /\nalways: true\n/, 'always defaults to true when opts.always is omitted');
|
||||
});
|
||||
|
||||
test('mergeOpenclawFrontmatter: no opts falls back to SKILL_VERSION (default behavior unchanged)', () => {
|
||||
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
|
||||
const out = helper.mergeOpenclawFrontmatter('---\ndescription: x\n---\nbody\n');
|
||||
assert.match(out, new RegExp(`\\nversion: ${helper.SKILL_VERSION.replace(/\./g, '\\.')}\\n`));
|
||||
});
|
||||
|
||||
test('mergeOpenclawFrontmatter: always:false omits the always key entirely', () => {
|
||||
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
|
||||
const out = helper.mergeOpenclawFrontmatter('---\ndescription: x\n---\nbody\n', { version: '2.0.0', always: false });
|
||||
assert.match(out, /\nversion: 2\.0\.0\n/);
|
||||
assert.doesNotMatch(out, /always:/);
|
||||
});
|
||||
|
||||
// ── Tests: SOUL.md marker damage tolerance (#596) ──────────────────────────
|
||||
// A stray/truncated marker used to chain into data loss: append added a
|
||||
// second block, then strip cut from the FIRST begin to the FIRST end —
|
||||
// spanning all user content in between. These drive the helper directly.
|
||||
test('openclaw: truncated begin marker does not eat user content (issue #596 chain)', () => {
|
||||
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
|
||||
const dir = freshTmpDir();
|
||||
const soul = path.join(dir, 'SOUL.md');
|
||||
try {
|
||||
// Begin marker with no end (interrupted write), then user content.
|
||||
fs.writeFileSync(soul, helper.MARK_BEGIN + '\n\nUSER IMPORTANT CONTENT\n');
|
||||
const snippet = helper.loadBootstrapSnippet(REPO_ROOT);
|
||||
|
||||
const a = helper.appendBootstrapToSoul(soul, snippet);
|
||||
assert.equal(a.changed, true);
|
||||
const afterAppend = fs.readFileSync(soul, 'utf8');
|
||||
assert.match(afterAppend, /USER IMPORTANT CONTENT/, 'user content lost during repair-append');
|
||||
assert.equal(afterAppend.split(helper.MARK_BEGIN).length - 1, 1, 'repair must leave exactly one begin marker');
|
||||
|
||||
const s = helper.stripBootstrapFromSoul(soul);
|
||||
assert.equal(s.changed, true);
|
||||
assert.equal(s.removed, undefined, 'file with user content must not be deleted');
|
||||
const afterStrip = fs.readFileSync(soul, 'utf8');
|
||||
assert.match(afterStrip, /USER IMPORTANT CONTENT/, 'user content deleted by strip — the #596 data loss');
|
||||
assert.doesNotMatch(afterStrip, /caveman-begin/, 'marker survived strip');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('openclaw: strip removes multiple blocks pairwise, keeping user content between them', () => {
|
||||
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
|
||||
const dir = freshTmpDir();
|
||||
const soul = path.join(dir, 'SOUL.md');
|
||||
try {
|
||||
const block = helper.MARK_BEGIN + '\nrules v1\n' + helper.MARK_END;
|
||||
fs.writeFileSync(soul, block + '\n\nUSER KEEP ME\n\n' + block + '\n');
|
||||
const s = helper.stripBootstrapFromSoul(soul);
|
||||
assert.equal(s.changed, true);
|
||||
const after = fs.readFileSync(soul, 'utf8');
|
||||
assert.match(after, /USER KEEP ME/, 'user content between blocks deleted');
|
||||
assert.doesNotMatch(after, /caveman-(begin|end)/, 'markers survived');
|
||||
assert.doesNotMatch(after, /rules v1/, 'block bodies survived');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('openclaw: orphan end marker stripped without touching content', () => {
|
||||
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
|
||||
const dir = freshTmpDir();
|
||||
const soul = path.join(dir, 'SOUL.md');
|
||||
try {
|
||||
fs.writeFileSync(soul, 'before\n' + helper.MARK_END + '\nafter\n');
|
||||
const s = helper.stripBootstrapFromSoul(soul);
|
||||
assert.equal(s.changed, true);
|
||||
const after = fs.readFileSync(soul, 'utf8');
|
||||
assert.match(after, /before/);
|
||||
assert.match(after, /after/);
|
||||
assert.doesNotMatch(after, /caveman-end/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('openclaw: append on a well-formed block stays a no-op', () => {
|
||||
const helper = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'openclaw.js'));
|
||||
const dir = freshTmpDir();
|
||||
const soul = path.join(dir, 'SOUL.md');
|
||||
try {
|
||||
const snippet = helper.loadBootstrapSnippet(REPO_ROOT);
|
||||
helper.appendBootstrapToSoul(soul, snippet);
|
||||
const first = fs.readFileSync(soul, 'utf8');
|
||||
const again = helper.appendBootstrapToSoul(soul, snippet);
|
||||
assert.equal(again.changed, false);
|
||||
assert.equal(fs.readFileSync(soul, 'utf8'), first, 'no-op append must not modify the file');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Test: missing `claude` CLI must be a FAILURE, not silent success (#592)
|
||||
// spawnSync reports ENOENT as { status: null, error }; the old
|
||||
// `(r.status || 0) === 0` coerced that to success, so the installer printed
|
||||
// "installed: claude", skipped the standalone-hook fallback, and left the
|
||||
// machine with nothing installed. Always runs: an empty PATH guarantees the
|
||||
// claude lookup fails even on machines that do have the CLI.
|
||||
test('missing claude CLI: reports failure and falls back to standalone hook wiring', () => {
|
||||
const dir = freshTmpDir();
|
||||
const emptyBin = path.join(dir, 'empty-bin');
|
||||
fs.mkdirSync(emptyBin);
|
||||
const configDir = path.join(dir, 'claude-config');
|
||||
try {
|
||||
// process.execPath instead of 'node': the stripped PATH must not break
|
||||
// the test's own ability to launch the installer.
|
||||
const r = spawnSync(process.execPath, [
|
||||
INSTALLER, '--only', 'claude', '--skip-skills',
|
||||
'--config-dir', configDir, '--non-interactive', '--no-mcp-shrink',
|
||||
], {
|
||||
env: { ...process.env, PATH: emptyBin, CLAUDE_CONFIG_DIR: configDir, NO_COLOR: '1' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const out = (r.stdout || '') + (r.stderr || '');
|
||||
assert.match(out, /plugin install did not succeed; falling back to standalone wiring/,
|
||||
`fallback to standalone hooks did not trigger:\n${out}`);
|
||||
assert.match(out, /claude plugin install failed/, 'claude was not reported as failed');
|
||||
assert.ok(!/• claude\n/.test(out), 'claude must not be listed as installed');
|
||||
assert.ok(fs.existsSync(path.join(configDir, 'hooks', 'caveman-activate.js')),
|
||||
'standalone hooks were not written');
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(configDir, 'settings.json'), 'utf8'));
|
||||
assert.ok(settings.hooks && settings.hooks.SessionStart, 'SessionStart hook not wired');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Hermes Agent native install — fresh install lands skills, uninstall removes them.
|
||||
//
|
||||
// Hermes loads skills from <HERMES_HOME>/skills/<category>/<skill>/SKILL.md
|
||||
// (verified against a live `hermes skills list`). The installer copies the 7
|
||||
// caveman skill dirs into the `productivity/` category. `--only hermes` makes
|
||||
// the provider explicit, so no `hermes` binary needs to be on PATH for the
|
||||
// dispatch to run — we drive it purely through a throwaway HERMES_HOME.
|
||||
//
|
||||
// The uninstall test is the important one: PR #524 shipped installHermes with
|
||||
// NO matching uninstall block, so `--uninstall` silently orphaned all 7 skill
|
||||
// folders forever. This pins the symmetry so it cannot regress.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, '..', '..');
|
||||
const INSTALLER = path.join(REPO_ROOT, 'cli', 'install.js');
|
||||
|
||||
const SKILLS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew'];
|
||||
|
||||
function freshHome() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-hermes-'));
|
||||
}
|
||||
|
||||
function runInstaller(args, hermesHome) {
|
||||
return spawnSync('node', [INSTALLER, ...args, '--non-interactive', '--no-mcp-shrink'], {
|
||||
env: { ...process.env, HERMES_HOME: hermesHome, NO_COLOR: '1' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function productivityDir(hermesHome) {
|
||||
return path.join(hermesHome, 'skills', 'productivity');
|
||||
}
|
||||
|
||||
// ── 1. Fresh install drops all 7 skills with SKILL.md in the productivity category ──
|
||||
test('hermes fresh install lands 7 skill dirs with SKILL.md under skills/productivity/', () => {
|
||||
const home = freshHome();
|
||||
try {
|
||||
const r = runInstaller(['--only', 'hermes'], home);
|
||||
assert.notEqual(r.status, 2, `argv error: ${r.stderr}`);
|
||||
|
||||
const prod = productivityDir(home);
|
||||
for (const name of SKILLS) {
|
||||
assert.ok(fs.existsSync(path.join(prod, name, 'SKILL.md')), `skill ${name}/SKILL.md missing`);
|
||||
}
|
||||
// caveman-compress ships executable scripts — ensure the recursive copy kept them.
|
||||
assert.ok(fs.existsSync(path.join(prod, 'caveman-compress', 'scripts')), 'caveman-compress/scripts/ not copied');
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 2. Uninstall removes every skill we installed (regression guard for #524) ──
|
||||
test('hermes uninstall removes all installed caveman skills (no orphans)', () => {
|
||||
const home = freshHome();
|
||||
try {
|
||||
const r1 = runInstaller(['--only', 'hermes'], home);
|
||||
assert.notEqual(r1.status, 2);
|
||||
const prod = productivityDir(home);
|
||||
for (const name of SKILLS) {
|
||||
assert.ok(fs.existsSync(path.join(prod, name)), `precondition: ${name} should be installed`);
|
||||
}
|
||||
|
||||
const r2 = runInstaller(['--uninstall'], home);
|
||||
assert.notEqual(r2.status, 2);
|
||||
|
||||
for (const name of SKILLS) {
|
||||
assert.equal(fs.existsSync(path.join(prod, name)), false, `${name} survived uninstall (orphaned skill)`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 3. Dry-run uninstall must NOT delete anything ──
|
||||
test('hermes dry-run uninstall leaves skills in place', () => {
|
||||
const home = freshHome();
|
||||
try {
|
||||
runInstaller(['--only', 'hermes'], home);
|
||||
const r = runInstaller(['--uninstall', '--dry-run'], home);
|
||||
assert.notEqual(r.status, 2);
|
||||
|
||||
const prod = productivityDir(home);
|
||||
for (const name of SKILLS) {
|
||||
assert.ok(fs.existsSync(path.join(prod, name)), `${name} was deleted by a dry-run uninstall`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// Subagent frontmatter sanitizer for opencode (issue #386).
|
||||
//
|
||||
// opencode rejects the YAML array form `tools: [Read, Grep, Bash]` that
|
||||
// Claude Code accepts. Copying agents/cavecrew-*.md verbatim into
|
||||
// ~/.config/opencode/agents/ broke opencode startup with:
|
||||
// Configuration is invalid at .../cavecrew-reviewer.md
|
||||
// ↳ Expected object | undefined, got ["Read","Grep","Bash"] tools
|
||||
//
|
||||
// Fix: strip the `tools:` field on copy. These tests prove the helper
|
||||
// strips the field, preserves every other frontmatter key and the body,
|
||||
// and handles both the inline array form and the multi-line YAML list form.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, '..', '..');
|
||||
const requireCjs = createRequire(import.meta.url);
|
||||
const { stripOpencodeAgentTools } = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'opencode-agent.js'));
|
||||
|
||||
const SHIPPED_AGENT_FILES = ['cavecrew-investigator.md', 'cavecrew-builder.md', 'cavecrew-reviewer.md'];
|
||||
|
||||
function frontmatter(content) {
|
||||
const m = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
assert.ok(m, 'frontmatter present');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
// ── Inline array form (the exact bug reported in issue 386) ──────────────
|
||||
test('strips inline `tools: [...]` array from frontmatter', () => {
|
||||
const src = `---
|
||||
name: test-agent
|
||||
description: short description
|
||||
tools: [Read, Grep, Bash]
|
||||
model: haiku
|
||||
---
|
||||
body line one
|
||||
body line two
|
||||
`;
|
||||
const out = stripOpencodeAgentTools(src);
|
||||
const fm = frontmatter(out);
|
||||
assert.doesNotMatch(fm, /^tools:/m, '`tools` field must be absent');
|
||||
assert.match(fm, /^name: test-agent$/m, '`name` preserved');
|
||||
assert.match(fm, /^description: short description$/m, '`description` preserved');
|
||||
assert.match(fm, /^model: haiku$/m, '`model` preserved');
|
||||
assert.match(out, /^body line one$/m, 'body preserved');
|
||||
assert.match(out, /^body line two$/m, 'body preserved');
|
||||
});
|
||||
|
||||
// ── Multi-line YAML list form (defensive — future-proof for refactors) ───
|
||||
test('strips multi-line `tools:` list with indented continuation', () => {
|
||||
const src = `---
|
||||
name: test-agent
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Bash
|
||||
model: haiku
|
||||
---
|
||||
body
|
||||
`;
|
||||
const out = stripOpencodeAgentTools(src);
|
||||
const fm = frontmatter(out);
|
||||
assert.doesNotMatch(fm, /^tools:/m, '`tools` field must be absent');
|
||||
assert.doesNotMatch(fm, /^\s+- Read$/m, '`tools` list items must be absent');
|
||||
assert.match(fm, /^name: test-agent$/m, '`name` preserved');
|
||||
assert.match(fm, /^model: haiku$/m, '`model` preserved');
|
||||
});
|
||||
|
||||
// ── Folded `description: >` block must NOT be eaten ──────────────────────
|
||||
test('preserves folded `description: >` continuation lines when `tools:` follows', () => {
|
||||
const src = `---
|
||||
name: cavecrew-reviewer
|
||||
description: >
|
||||
Diff/branch/file reviewer. One line per finding, severity-tagged, no praise,
|
||||
no scope creep. Output format \`path:line: <emoji> <severity>: <problem>. <fix>.\`
|
||||
tools: [Read, Grep, Bash]
|
||||
model: haiku
|
||||
---
|
||||
body
|
||||
`;
|
||||
const out = stripOpencodeAgentTools(src);
|
||||
const fm = frontmatter(out);
|
||||
assert.doesNotMatch(fm, /^tools:/m);
|
||||
assert.match(fm, /^description: >$/m, 'folded scalar header preserved');
|
||||
assert.match(fm, /Diff\/branch\/file reviewer/, 'folded scalar body preserved');
|
||||
assert.match(fm, /no scope creep/, 'second folded line preserved');
|
||||
assert.match(fm, /^model: haiku$/m);
|
||||
});
|
||||
|
||||
// ── No frontmatter: pass content through untouched ───────────────────────
|
||||
test('returns input unchanged when no frontmatter fence', () => {
|
||||
const src = 'just body, no frontmatter\ntools: [Read]\n';
|
||||
assert.equal(stripOpencodeAgentTools(src), src);
|
||||
});
|
||||
|
||||
// ── No `tools:` field: pass content through untouched ────────────────────
|
||||
test('returns input unchanged when frontmatter has no `tools:` field', () => {
|
||||
const src = `---
|
||||
name: x
|
||||
model: haiku
|
||||
---
|
||||
body
|
||||
`;
|
||||
assert.equal(stripOpencodeAgentTools(src), src);
|
||||
});
|
||||
|
||||
// ── Non-string input: pass through (defensive) ───────────────────────────
|
||||
test('non-string input returns unchanged', () => {
|
||||
assert.equal(stripOpencodeAgentTools(null), null);
|
||||
assert.equal(stripOpencodeAgentTools(undefined), undefined);
|
||||
assert.deepEqual(stripOpencodeAgentTools({ x: 1 }), { x: 1 });
|
||||
});
|
||||
|
||||
// ── Real shipped agent files: every one must transform to opencode-safe ──
|
||||
// This is the RED-state proof: each `agents/cavecrew-*.md` in the repo today
|
||||
// contains the offending `tools: [...]` form, which is what broke opencode
|
||||
// startup in the reported bug. After transform, the field is gone.
|
||||
test('all shipped cavecrew agent files contain offending tools array (RED proof)', () => {
|
||||
for (const f of SHIPPED_AGENT_FILES) {
|
||||
const src = fs.readFileSync(path.join(REPO_ROOT, 'agents', f), 'utf8');
|
||||
const fm = frontmatter(src);
|
||||
assert.match(fm, /^tools:\s*\[/m, `source ${f} should contain inline array form (this is the bug)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('all shipped cavecrew agent files become opencode-safe after transform (GREEN proof)', () => {
|
||||
for (const f of SHIPPED_AGENT_FILES) {
|
||||
const src = fs.readFileSync(path.join(REPO_ROOT, 'agents', f), 'utf8');
|
||||
const out = stripOpencodeAgentTools(src);
|
||||
const fm = frontmatter(out);
|
||||
|
||||
assert.doesNotMatch(fm, /^tools:/m, `${f}: tools field still present after transform`);
|
||||
assert.match(fm, /^name: cavecrew-/m, `${f}: name field preserved`);
|
||||
assert.match(fm, /^description:/m, `${f}: description field preserved`);
|
||||
|
||||
const bodyOut = out.replace(/^---\n[\s\S]*?\n---\n/, '');
|
||||
const bodyIn = src.replace(/^---\n[\s\S]*?\n---\n/, '');
|
||||
assert.equal(bodyOut, bodyIn, `${f}: body must be byte-identical`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── End-to-end: installer's agent-copy step writes a sanitized file ──────
|
||||
// We re-enact section 3 of installOpencode() directly to avoid coupling to
|
||||
// the rest of the install pipeline (which depends on optional files outside
|
||||
// this fix's scope). The assertion is the same one opencode applies on
|
||||
// startup: `tools` must be absent (or an object), never an array.
|
||||
test('installer-equivalent copy writes opencode-safe agent file (issue 386 end-to-end)', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(REPO_ROOT, 'tests', '.tmp-opencode-agent-'));
|
||||
try {
|
||||
for (const f of SHIPPED_AGENT_FILES) {
|
||||
const src = path.join(REPO_ROOT, 'agents', f);
|
||||
const dest = path.join(tmpDir, f);
|
||||
fs.writeFileSync(dest, stripOpencodeAgentTools(fs.readFileSync(src, 'utf8')));
|
||||
|
||||
const installed = fs.readFileSync(dest, 'utf8');
|
||||
const fm = frontmatter(installed);
|
||||
assert.doesNotMatch(fm, /^tools:\s*\[/m, `${f}: array form survived in installed file`);
|
||||
assert.doesNotMatch(fm, /^tools:/m, `${f}: tools field survived in installed file`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -16,9 +16,9 @@ import { createRequire } from 'node:module';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, '..', '..');
|
||||
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
const INSTALLER = path.join(REPO_ROOT, 'cli', 'install.js');
|
||||
const requireCjs = createRequire(import.meta.url);
|
||||
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'settings.js'));
|
||||
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'cli', 'lib', 'settings.js'));
|
||||
|
||||
const IS_WIN = process.platform === 'win32';
|
||||
|
||||
@@ -247,10 +247,16 @@ test('opencode uninstall removes plugin dir, command/agent/skill files, prunes o
|
||||
}
|
||||
});
|
||||
|
||||
// ── 5. Plugin smoke: load installed plugin.js, fire fake hooks ────────────
|
||||
test('opencode plugin handles /caveman ultra and stop caveman via tui.prompt.append', async () => {
|
||||
// ── 5. Plugin smoke: load installed plugin.js, fire the real opencode hooks ──
|
||||
// opencode (>= 1.15) has no `tui.prompt.append` or top-level `session.created`
|
||||
// plugin-hook keys (#418/#421). The plugin now uses `chat.message` for mode
|
||||
// parsing, `experimental.chat.system.transform` for reinforcement, and the
|
||||
// `event` dispatcher (filtering event.type === 'session.created') for session
|
||||
// init. This test drives those real hooks.
|
||||
test('opencode plugin handles /caveman ultra, stop caveman, and session init via real hooks', async () => {
|
||||
const xdg = freshTmpDir();
|
||||
const shimDir = shimOpencode();
|
||||
const origDefault = process.env.CAVEMAN_DEFAULT_MODE;
|
||||
try {
|
||||
const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
|
||||
const r = runInstaller(['--only', 'opencode'], env);
|
||||
@@ -259,28 +265,73 @@ test('opencode plugin handles /caveman ultra and stop caveman via tui.prompt.app
|
||||
const pluginPath = path.join(xdg, 'opencode', 'plugins', 'caveman', 'plugin.js');
|
||||
const flagPath = path.join(xdg, 'opencode', '.caveman-active');
|
||||
|
||||
// Set XDG_CONFIG_HOME for the plugin so flagPath resolves to our temp dir.
|
||||
// Set XDG_CONFIG_HOME so the plugin's flagPath resolves to our temp dir,
|
||||
// and pin the default mode so session-init is deterministic regardless of
|
||||
// any ambient user/repo-local caveman config.
|
||||
process.env.XDG_CONFIG_HOME = xdg;
|
||||
process.env.CAVEMAN_DEFAULT_MODE = 'full';
|
||||
|
||||
const mod = await import(pathToFileURL(pluginPath).href);
|
||||
const factory = mod.default || mod.CavemanPlugin;
|
||||
const handlers = await factory({});
|
||||
|
||||
// Slash command activates ultra
|
||||
const out1 = await handlers['tui.prompt.append']({ prompt: '/caveman ultra' });
|
||||
// The dead direct-key hooks must NOT be registered.
|
||||
assert.equal(handlers['tui.prompt.append'], undefined, 'tui.prompt.append should not exist');
|
||||
assert.equal(handlers['session.created'], undefined, 'session.created direct key should not exist');
|
||||
assert.equal(typeof handlers.event, 'function', 'event dispatcher should be a function');
|
||||
assert.equal(typeof handlers['chat.message'], 'function', 'chat.message should be a function');
|
||||
assert.equal(typeof handlers['experimental.chat.system.transform'], 'function',
|
||||
'system.transform should be a function');
|
||||
|
||||
// Slash command in a chat.message text part activates ultra.
|
||||
await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] });
|
||||
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');
|
||||
assert.ok(out1 && typeof out1.append === 'string', 'expected reinforcement append');
|
||||
assert.match(out1.append, /CAVEMAN MODE ACTIVE \(ultra\)/);
|
||||
|
||||
// Natural-language deactivation removes flag
|
||||
const out2 = await handlers['tui.prompt.append']({ prompt: 'stop caveman please' });
|
||||
// opencode expands "/caveman <level>" into the command template before
|
||||
// chat.message fires — the level must be recovered from the expanded text.
|
||||
await handlers['chat.message']({}, { parts: [{ type: 'text', text:
|
||||
'Activate caveman mode: wenyan-lite\n\nIf no level given, use full. If "off", deactivate.' }] });
|
||||
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'wenyan-lite');
|
||||
await handlers['chat.message']({}, { parts: [{ type: 'text', text:
|
||||
'Activate caveman mode: off\n\nIf no level given, use full. If "off", deactivate.' }] });
|
||||
assert.equal(fs.existsSync(flagPath), false, 'expanded template with off should delete the flag');
|
||||
await handlers['chat.message']({}, { parts: [{ type: 'text', text:
|
||||
'Activate caveman mode: \n\nIf no level given, use full. If "off", deactivate.' }] });
|
||||
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'full', 'expanded template without level uses default');
|
||||
await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] });
|
||||
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');
|
||||
|
||||
// opencode's non-interactive `run` path wraps the message in literal
|
||||
// quotes ("/caveman lite"\n) — the parser must unwrap them.
|
||||
await handlers['chat.message']({}, { parts: [{ type: 'text', text: '"/caveman lite"\n' }] });
|
||||
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'lite');
|
||||
await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] });
|
||||
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');
|
||||
|
||||
// system.transform injects the reinforcement line while active.
|
||||
const sys1 = { system: [] };
|
||||
await handlers['experimental.chat.system.transform']({}, sys1);
|
||||
assert.equal(sys1.system.length, 1, 'expected one reinforcement line');
|
||||
assert.match(sys1.system[0], /CAVEMAN MODE ACTIVE \(ultra\)/);
|
||||
|
||||
// Natural-language deactivation removes the flag.
|
||||
await handlers['chat.message']({}, { parts: [{ type: 'text', text: 'stop caveman please' }] });
|
||||
assert.equal(fs.existsSync(flagPath), false, 'flag should be deleted after deactivation');
|
||||
assert.equal(out2, undefined, 'no reinforcement when flag absent');
|
||||
|
||||
// session.created writes default mode
|
||||
await handlers['session.created']();
|
||||
// No reinforcement injected when inactive.
|
||||
const sys2 = { system: [] };
|
||||
await handlers['experimental.chat.system.transform']({}, sys2);
|
||||
assert.equal(sys2.system.length, 0, 'no reinforcement when flag absent');
|
||||
|
||||
// The `event` dispatcher writes the default mode on session.created, and
|
||||
// ignores unrelated event types.
|
||||
await handlers.event({ event: { type: 'session.idle' } });
|
||||
assert.equal(fs.existsSync(flagPath), false, 'non-session.created event must not write the flag');
|
||||
await handlers.event({ event: { type: 'session.created' } });
|
||||
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'full');
|
||||
} finally {
|
||||
if (origDefault === undefined) delete process.env.CAVEMAN_DEFAULT_MODE;
|
||||
else process.env.CAVEMAN_DEFAULT_MODE = origDefault;
|
||||
fs.rmSync(xdg, { recursive: true, force: true });
|
||||
fs.rmSync(shimDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Regression for #565: `irm .../install.ps1 | iex` crashed with
|
||||
// "Cannot bind argument to parameter 'Path' because it is null."
|
||||
//
|
||||
// Two pipe-execution rules for install.ps1 (static checks — CI has no pwsh):
|
||||
// 1. No top-level param() block. iex executes the file as a string, so a
|
||||
// top-level param can never receive arguments and (depending on host)
|
||||
// trips parsing. All logic lives in a function invoked at the bottom.
|
||||
// 2. Script-path variables ($PSCommandPath / $MyInvocation.MyCommand.Path)
|
||||
// are $null under iex — any use must be guarded, never passed straight
|
||||
// into Split-Path (that was the #565 crash).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, '..', '..');
|
||||
const PS1 = fs.readFileSync(path.join(REPO_ROOT, 'install.ps1'), 'utf8');
|
||||
|
||||
// Strip comment lines so doc mentions of param()/path vars don't false-positive.
|
||||
const code = PS1.split('\n').filter(l => !/^\s*#/.test(l)).join('\n');
|
||||
|
||||
test('#565 install.ps1 has no top-level param block (everything inside a function)', () => {
|
||||
const beforeFunction = code.slice(0, code.indexOf('function '));
|
||||
assert.ok(code.includes('function '), 'install.ps1 must wrap its logic in a function for iex piping');
|
||||
assert.ok(
|
||||
!/param\s*\(/i.test(beforeFunction),
|
||||
'install.ps1 must not declare a top-level param() — it cannot receive args under `irm | iex` (issue #565)',
|
||||
);
|
||||
});
|
||||
|
||||
test('#565 install.ps1 never uses $MyInvocation.MyCommand.Path (null under iex)', () => {
|
||||
assert.ok(
|
||||
!/\$MyInvocation\.MyCommand\.Path/i.test(code),
|
||||
'install.ps1 must not rely on $MyInvocation.MyCommand.Path — it is $null when piped to iex (issue #565)',
|
||||
);
|
||||
});
|
||||
|
||||
test('#565 install.ps1 guards $PSCommandPath before Split-Path', () => {
|
||||
if (/\$PSCommandPath/i.test(code)) {
|
||||
assert.match(
|
||||
code,
|
||||
/if\s*\(\s*\$PSCommandPath\s*\)/i,
|
||||
'$PSCommandPath is $null under `irm | iex` — it must be truthiness-guarded before use (issue #565)',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('#565 install.ps1 invokes its function at the bottom (script still does something)', () => {
|
||||
const lastLines = code.trim().split('\n').slice(-3).join('\n');
|
||||
assert.match(
|
||||
lastLines,
|
||||
/Install-Caveman/,
|
||||
'install.ps1 must actually invoke Install-Caveman after defining it',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Regression for #470 + #571: /caveman-* reports 'Unknown command' in Claude Code.
|
||||
//
|
||||
// Claude Code resolves a slash command by scanning commands/*.md (YAML
|
||||
// frontmatter) BEFORE the UserPromptSubmit hook ever sees the prompt — it
|
||||
// ignores commands/*.toml entirely (#571; TOML is the Gemini extension
|
||||
// format). With no commands/<name>.md on disk, the chat input is rejected as
|
||||
// "Unknown command" — the mode tracker's handlers in
|
||||
// src/hooks/caveman-mode-tracker.js never get a chance to intercept.
|
||||
//
|
||||
// README.md and INSTALL.md advertise the /caveman-* slash commands, so every
|
||||
// documented command MUST ship BOTH formats:
|
||||
// commands/<name>.md — Claude Code plugin commands (#571)
|
||||
// commands/<name>.toml — Gemini CLI extension commands (#470)
|
||||
// This test pins that contract, plus checks the caveman-stats bodies actually
|
||||
// trigger the hook regex (a description-only stub would still leave the
|
||||
// feature broken).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, '..', '..');
|
||||
const COMMANDS_DIR = path.join(REPO_ROOT, 'commands');
|
||||
const STATS_TOML = path.join(COMMANDS_DIR, 'caveman-stats.toml');
|
||||
|
||||
// Mirrors the live regex in src/hooks/caveman-mode-tracker.js (the
|
||||
// `statsMatch` line). Anything that fails this here would also fail in
|
||||
// production, so the test stays representative if the hook regex shifts.
|
||||
const HOOK_STATS_REGEX = /^\/caveman(?::caveman)?-stats(?:\s+(.*))?$/m;
|
||||
|
||||
test('#470 commands/caveman-stats.toml exists so Claude Code registers /caveman-stats', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(STATS_TOML),
|
||||
`Missing ${path.relative(REPO_ROOT, STATS_TOML)} — Claude Code rejects /caveman-stats as "Unknown command" before the UserPromptSubmit hook can intercept (issue #470).`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#470 caveman-stats.toml declares a non-empty description for the slash-command picker', () => {
|
||||
const body = fs.readFileSync(STATS_TOML, 'utf8');
|
||||
const descMatch = body.match(/^\s*description\s*=\s*"([^"\n]+)"/m);
|
||||
assert.ok(descMatch, 'caveman-stats.toml must declare a description = "..." line');
|
||||
assert.ok(descMatch[1].trim().length > 0, 'description must not be empty');
|
||||
});
|
||||
|
||||
test('#470 caveman-stats.toml prompt is intercepted by the mode-tracker regex', () => {
|
||||
const body = fs.readFileSync(STATS_TOML, 'utf8');
|
||||
const promptMatch = body.match(/^\s*prompt\s*=\s*"([^"\n]+)"/m);
|
||||
assert.ok(promptMatch, 'caveman-stats.toml must declare a prompt = "..." line');
|
||||
const prompt = promptMatch[1].replace(/\{\{args\}\}/g, '').trim();
|
||||
assert.match(
|
||||
prompt,
|
||||
HOOK_STATS_REGEX,
|
||||
`Resolved prompt ${JSON.stringify(prompt)} must match the UserPromptSubmit handler regex in src/hooks/caveman-mode-tracker.js; otherwise the stats output is never injected.`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── #571: Claude Code only discovers commands/*.md ─────────────────────────
|
||||
|
||||
// Every command documented for Claude Code. Each needs a .md (Claude Code)
|
||||
// AND a .toml (Gemini extension) sibling — the formats coexist in commands/.
|
||||
const DOCUMENTED_COMMANDS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-stats', 'caveman-init'];
|
||||
|
||||
for (const name of DOCUMENTED_COMMANDS) {
|
||||
test(`#571 commands/${name}.md exists so Claude Code registers /${name}`, () => {
|
||||
const mdPath = path.join(COMMANDS_DIR, `${name}.md`);
|
||||
assert.ok(
|
||||
fs.existsSync(mdPath),
|
||||
`Missing ${path.relative(REPO_ROOT, mdPath)} — Claude Code only scans commands/*.md, so /${name} is "Unknown command" without it (issue #571).`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`#571 commands/${name}.md has YAML frontmatter with a non-empty description`, () => {
|
||||
const body = fs.readFileSync(path.join(COMMANDS_DIR, `${name}.md`), 'utf8');
|
||||
assert.ok(body.startsWith('---\n'), `${name}.md must start with YAML frontmatter (---)`);
|
||||
const fm = body.match(/^---\n([\s\S]*?)\n---/);
|
||||
assert.ok(fm, `${name}.md frontmatter must be closed with ---`);
|
||||
const desc = fm[1].match(/^description:\s*(.+)$/m);
|
||||
assert.ok(desc && desc[1].trim().length > 0, `${name}.md must declare a non-empty description`);
|
||||
});
|
||||
|
||||
test(`#571 commands/${name}.toml still ships for Gemini`, () => {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(COMMANDS_DIR, `${name}.toml`)),
|
||||
`commands/${name}.toml missing — Gemini CLI extensions only read TOML commands.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('#571 caveman-stats.md body is intercepted by the mode-tracker regex', () => {
|
||||
const body = fs.readFileSync(path.join(COMMANDS_DIR, 'caveman-stats.md'), 'utf8');
|
||||
const prompt = body.replace(/^---\n[\s\S]*?\n---\n/, '').replace(/\$ARGUMENTS/g, '').trim();
|
||||
assert.match(
|
||||
prompt,
|
||||
HOOK_STATS_REGEX,
|
||||
`Resolved body ${JSON.stringify(prompt)} must match the UserPromptSubmit handler regex in src/hooks/caveman-mode-tracker.js; otherwise the stats output is never injected.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#571 command .md bodies use $ARGUMENTS, never the TOML {{args}} placeholder', () => {
|
||||
for (const name of DOCUMENTED_COMMANDS) {
|
||||
const body = fs.readFileSync(path.join(COMMANDS_DIR, `${name}.md`), 'utf8');
|
||||
assert.ok(
|
||||
!body.includes('{{args}}'),
|
||||
`commands/${name}.md contains {{args}} — Claude Code substitutes $ARGUMENTS, {{args}} would reach the model verbatim.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// #603: the init command must not depend on a repo-relative path — installed
|
||||
// users run it from their own project, where src/tools/ does not exist.
|
||||
test('#603 caveman-init command bodies do not run src/tools blindly', () => {
|
||||
for (const ext of ['md', 'toml']) {
|
||||
const body = fs.readFileSync(path.join(COMMANDS_DIR, `caveman-init.${ext}`), 'utf8');
|
||||
if (body.includes('src/tools/caveman-init.js')) {
|
||||
assert.ok(
|
||||
/raw\.githubusercontent\.com.*caveman-init\.js/.test(body),
|
||||
`commands/caveman-init.${ext} references the repo-relative src/tools path without a standalone fallback (curl | node) — fails for every installed user (issue #603).`,
|
||||
);
|
||||
assert.match(
|
||||
body,
|
||||
/if .*exists|exists in the current repo/i,
|
||||
`commands/caveman-init.${ext} must gate the repo-relative path on the file actually existing (issue #603).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Unit tests for the argv parser embedded in bin/install.js.
|
||||
// Unit tests for the argv parser embedded in cli/install.js.
|
||||
// We don't import parseArgs (it's not exported) — instead we shell out to the
|
||||
// installer with --help / --list / unknown flags and assert the framing.
|
||||
// For deeper coverage of flag-resolution semantics, exec --dry-run --list and
|
||||
@@ -9,9 +9,12 @@ import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const INSTALLER = path.resolve(HERE, '..', '..', 'bin', 'install.js');
|
||||
const INSTALLER = path.resolve(HERE, '..', '..', 'cli', 'install.js');
|
||||
const requireCjs = createRequire(import.meta.url);
|
||||
const { winQuoteIfNeeded } = requireCjs(INSTALLER);
|
||||
|
||||
function run(...args) {
|
||||
return spawnSync('node', [INSTALLER, ...args], { encoding: 'utf8' });
|
||||
@@ -86,7 +89,11 @@ test('--config-dir expands ~ to home directory', async () => {
|
||||
// Pass `~/cm-test-…` and assert the dry-run plan resolves it relative to $HOME.
|
||||
// Use a unique suffix so the assertion is unambiguous.
|
||||
const suffix = `cm-test-${process.pid}`;
|
||||
const r = run('--dry-run', '--only', 'claude', '--non-interactive', '--config-dir', `~/${suffix}`);
|
||||
// --with-hooks: since #392/#393 the hooks plan (which echoes the resolved
|
||||
// config-dir) is only emitted when the plugin install fails OR hooks are
|
||||
// forced. Force it so the path-expansion assertion below has something to
|
||||
// match even when the caveman plugin is already installed.
|
||||
const r = run('--dry-run', '--only', 'claude', '--with-hooks', '--non-interactive', '--config-dir', `~/${suffix}`);
|
||||
assert.equal(r.status, 0);
|
||||
// If the literal `~` had survived, we'd see `~/cm-test-…/hooks` in the plan.
|
||||
// The fix expands it in parseArgs, so we expect the absolute home path.
|
||||
@@ -106,6 +113,82 @@ test('bare -- (POSIX end-of-options) is accepted and ignored', () => {
|
||||
assert.equal(r.status, 0);
|
||||
});
|
||||
|
||||
test('bare --with-mcp-shrink (no upstream) exits 2 with hint', () => {
|
||||
// Regression for issue where --with-mcp-shrink registered a stub MCP entry
|
||||
// that crashed on every Claude Code startup. caveman-shrink is a proxy and
|
||||
// requires an upstream command — we now refuse the bare flag (#474).
|
||||
const r = run('--with-mcp-shrink', '--non-interactive', '--dry-run');
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.stderr, /requires an upstream command/);
|
||||
assert.match(r.stderr, /server-filesystem/);
|
||||
});
|
||||
|
||||
test('--with-mcp-shrink followed by another flag (no value) exits 2', () => {
|
||||
// The next-token form must distinguish "no value" from "value happens to
|
||||
// start with --". A user typing `--with-mcp-shrink --dry-run` clearly
|
||||
// forgot the upstream; refuse.
|
||||
const r = run('--with-mcp-shrink', '--dry-run', '--non-interactive');
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.stderr, /requires an upstream command/);
|
||||
});
|
||||
|
||||
test('--with-mcp-shrink="<cmd>" registers wrapping that upstream', () => {
|
||||
const r = run(
|
||||
'--with-mcp-shrink=npx @modelcontextprotocol/server-filesystem /tmp',
|
||||
'--only', 'claude', '--dry-run', '--non-interactive',
|
||||
'--config-dir', '/tmp/__cm_shrink_test'
|
||||
);
|
||||
assert.equal(r.status, 0);
|
||||
// Dry-run only emits the planned `claude mcp add` line when claude is on
|
||||
// PATH (installMcpShrink probes `claude mcp --help` first). Assert the
|
||||
// wrapping content only when that line is actually present.
|
||||
if (/would run: claude mcp add caveman-shrink/.test(r.stdout)) {
|
||||
assert.match(r.stdout, /claude mcp add caveman-shrink .* npx -y caveman-shrink npx @modelcontextprotocol\/server-filesystem \/tmp/);
|
||||
}
|
||||
});
|
||||
|
||||
test('--with-mcp-shrink "<cmd>" (space-separated) also accepted', () => {
|
||||
const r = run(
|
||||
'--with-mcp-shrink', 'npx @modelcontextprotocol/server-filesystem /tmp',
|
||||
'--only', 'claude', '--dry-run', '--non-interactive',
|
||||
'--config-dir', '/tmp/__cm_shrink_space'
|
||||
);
|
||||
assert.equal(r.status, 0);
|
||||
if (/would run: claude mcp add caveman-shrink/.test(r.stdout)) {
|
||||
assert.match(r.stdout, /caveman-shrink npx @modelcontextprotocol\/server-filesystem \/tmp/);
|
||||
}
|
||||
});
|
||||
|
||||
test('--all does NOT auto-enable mcp-shrink (no sensible default upstream)', () => {
|
||||
const r = run('--all', '--only', 'claude', '--dry-run', '--non-interactive', '--config-dir', '/tmp/__cm_all_no_shrink');
|
||||
assert.equal(r.status, 0);
|
||||
// Whether or not claude is on PATH, the wiring banner should not appear
|
||||
// because withMcpShrink stays false under --all alone.
|
||||
assert.doesNotMatch(r.stdout, /wiring caveman-shrink MCP proxy/);
|
||||
});
|
||||
|
||||
test('winQuoteIfNeeded leaves a plain argument untouched', () => {
|
||||
assert.equal(winQuoteIfNeeded('claude'), 'claude');
|
||||
assert.equal(winQuoteIfNeeded('/tmp/plain-path'), '/tmp/plain-path');
|
||||
});
|
||||
|
||||
test('winQuoteIfNeeded quotes whitespace and embedded quotes (pre-existing behavior)', () => {
|
||||
assert.equal(winQuoteIfNeeded('has space'), '"has space"');
|
||||
assert.equal(winQuoteIfNeeded(''), '""');
|
||||
});
|
||||
|
||||
test('winQuoteIfNeeded quotes cmd.exe metacharacters (Windows command-injection fix)', () => {
|
||||
// Pre-fix, the trigger regex only matched /[\s"]/ — none of these contain
|
||||
// whitespace or a quote, so they reached cmd.exe (via spawnXplat's
|
||||
// `shell: true`) completely unquoted. An attacker-influenced arg like a
|
||||
// --with-mcp-shrink value or --with-init cwd containing one of these could
|
||||
// chain a second command (e.g. `foo & calc.exe`).
|
||||
for (const ch of ['&', '|', '^', '<', '>', '%', '(', ')']) {
|
||||
const arg = `foo${ch}bar`;
|
||||
assert.equal(winQuoteIfNeeded(arg), `"${arg}"`, `metacharacter ${JSON.stringify(ch)} must trigger quoting`);
|
||||
}
|
||||
});
|
||||
|
||||
test('--help discloses --config-dir scope', () => {
|
||||
const r = run('--help');
|
||||
assert.equal(r.status, 0);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Unit tests for bin/lib/settings.js — the JSONC-tolerant settings helper.
|
||||
// Unit tests for cli/lib/settings.js — the JSONC-tolerant settings helper.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -8,7 +8,7 @@ import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const SETTINGS = require('../../bin/lib/settings.js');
|
||||
const SETTINGS = require('../../cli/lib/settings.js');
|
||||
|
||||
function tmpFile(name, contents) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-settings-'));
|
||||
@@ -22,6 +22,27 @@ test('stripJsonComments strips // line comments', () => {
|
||||
assert.equal(out.trim(), '{"a":1}');
|
||||
});
|
||||
|
||||
test('stripJsonComments preserves ,} and ,] inside string values (issue #595)', () => {
|
||||
// Trailing-comma removal must be string-aware: a hook command like
|
||||
// `echo ,}` or shell brace expansion `cp file{,.bak}` must survive.
|
||||
const src = '{"cmd": "echo ,}", // comment\n"glob": "cp file{,]x", }';
|
||||
const parsed = JSON.parse(SETTINGS.stripJsonComments(src));
|
||||
assert.equal(parsed.cmd, 'echo ,}');
|
||||
assert.equal(parsed.glob, 'cp file{,]x');
|
||||
});
|
||||
|
||||
test('stripJsonComments still removes real trailing commas after strings', () => {
|
||||
const src = '{"a": [1, 2, 3,], "b": {"c": 1,},}';
|
||||
const parsed = JSON.parse(SETTINGS.stripJsonComments(src));
|
||||
assert.deepEqual(parsed, { a: [1, 2, 3], b: { c: 1 } });
|
||||
});
|
||||
|
||||
test('stripJsonComments handles escaped quotes before ,} in strings', () => {
|
||||
const src = '{"cmd": "say \\",}\\" done", }';
|
||||
const parsed = JSON.parse(SETTINGS.stripJsonComments(src));
|
||||
assert.equal(parsed.cmd, 'say ",}" done');
|
||||
});
|
||||
|
||||
test('stripJsonComments strips /* block */ comments', () => {
|
||||
const out = SETTINGS.stripJsonComments('{/* leading */"a":1/* mid */, "b":2}');
|
||||
assert.match(out, /"a":1/);
|
||||
@@ -119,27 +140,70 @@ test('removeCavemanHooks tolerates malformed hook event values without throwing'
|
||||
// validateHookFields first + adds Array.isArray guard.
|
||||
const s = { hooks: { SessionStart: "oops", UserPromptSubmit: { not: 'an array either' } } };
|
||||
let removed;
|
||||
assert.doesNotThrow(() => { removed = SETTINGS.removeCavemanHooks(s, 'caveman'); });
|
||||
assert.doesNotThrow(() => { removed = SETTINGS.removeCavemanHooks(s); });
|
||||
assert.equal(removed, 0);
|
||||
assert.equal(s.hooks, undefined);
|
||||
});
|
||||
|
||||
test('removeCavemanHooks strips by marker and cleans empties', () => {
|
||||
test('removeCavemanHooks strips managed scripts and cleans empties', () => {
|
||||
const s = {
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{ hooks: [{ type: 'command', command: 'caveman-x' }] },
|
||||
{ hooks: [{ type: 'command', command: 'node /x/hooks/caveman-activate.js' }] },
|
||||
{ hooks: [{ type: 'command', command: 'other' }] },
|
||||
],
|
||||
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'caveman-y' }] }],
|
||||
UserPromptSubmit: [{ hooks: [{ type: 'command', command: '"/usr/bin/node" "/x/hooks/caveman-mode-tracker.js"' }] }],
|
||||
},
|
||||
};
|
||||
const removed = SETTINGS.removeCavemanHooks(s, 'caveman');
|
||||
const removed = SETTINGS.removeCavemanHooks(s);
|
||||
assert.equal(removed, 2);
|
||||
assert.equal(s.hooks.SessionStart.length, 1);
|
||||
assert.equal(s.hooks.UserPromptSubmit, undefined);
|
||||
});
|
||||
|
||||
test('removeCavemanHooks leaves user hooks that merely mention caveman (issue #593)', () => {
|
||||
const s = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
// Path contains the word "caveman" but targets a user-authored script.
|
||||
{ hooks: [{ type: 'command', command: 'node /Users/me/Projects/caveman-notes/my-hook.js' }] },
|
||||
// Basename is a superstring of a managed name — still not ours.
|
||||
{ hooks: [{ type: 'command', command: 'node /x/mycaveman-activate.js' }] },
|
||||
],
|
||||
SessionStart: [
|
||||
{ hooks: [{ type: 'command', command: '"/usr/bin/node" "/x/hooks/caveman-activate.js"' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const removed = SETTINGS.removeCavemanHooks(s);
|
||||
assert.equal(removed, 1, 'only the managed SessionStart hook should be removed');
|
||||
assert.equal(s.hooks.PreToolUse.length, 2, 'user hooks mentioning caveman must survive uninstall');
|
||||
assert.equal(s.hooks.SessionStart, undefined);
|
||||
});
|
||||
|
||||
test('removeCavemanHooks removes the Windows statusline-stats wiring (caveman-stats.js / .ps1)', () => {
|
||||
const s = {
|
||||
hooks: {
|
||||
Stop: [{ hooks: [{ type: 'command', command: '"C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\me\\.claude\\hooks\\caveman-stats.js"' }] }],
|
||||
},
|
||||
};
|
||||
const removed = SETTINGS.removeCavemanHooks(s);
|
||||
assert.equal(removed, 1);
|
||||
assert.equal(s.hooks, undefined);
|
||||
});
|
||||
|
||||
test('rewriteLegacyManagedHookCommands tolerates malformed hook event values without throwing', () => {
|
||||
// installHooks calls this BEFORE validateHookFields, so a hook event value
|
||||
// that survives JSONC parse as an object/string (not an array) must not
|
||||
// throw here — pre-fix, `for (const entry of settings.hooks[ev])` blew up
|
||||
// with a TypeError on a non-iterable object, killing the installer mid-run.
|
||||
// Mirror of the guard removeCavemanHooks already has.
|
||||
const s = { hooks: { SessionStart: { not: 'an array' }, UserPromptSubmit: 'oops' } };
|
||||
let n;
|
||||
assert.doesNotThrow(() => { n = SETTINGS.rewriteLegacyManagedHookCommands(s, '/usr/local/bin/node'); });
|
||||
assert.equal(n, 0);
|
||||
});
|
||||
|
||||
test('rewriteLegacyManagedHookCommands rewrites bare-node managed scripts', () => {
|
||||
const s = {
|
||||
hooks: {
|
||||
@@ -167,6 +231,113 @@ test('rewriteLegacyManagedHookCommands ignores already-absolute node commands',
|
||||
assert.equal(n, 0);
|
||||
});
|
||||
|
||||
test('pruneOrphanedManagedHooks removes managed hook whose target is missing (absolute-node)', () => {
|
||||
const s = {
|
||||
hooks: {
|
||||
SessionStart: [{ hooks: [
|
||||
{ type: 'command', command: '"/opt/node/bin/node" "/no/such/dir/caveman-activate.js"' },
|
||||
] }],
|
||||
},
|
||||
};
|
||||
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
|
||||
assert.equal(removed, 1);
|
||||
assert.equal(s.hooks, undefined);
|
||||
});
|
||||
|
||||
test('pruneOrphanedManagedHooks removes orphan bare-node managed hook', () => {
|
||||
const s = {
|
||||
hooks: {
|
||||
UserPromptSubmit: [{ hooks: [
|
||||
{ type: 'command', command: 'node /no/such/dir/caveman-mode-tracker.js' },
|
||||
] }],
|
||||
},
|
||||
};
|
||||
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
|
||||
assert.equal(removed, 1);
|
||||
assert.equal(s.hooks, undefined);
|
||||
});
|
||||
|
||||
test('pruneOrphanedManagedHooks keeps managed hook whose target exists', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-prune-'));
|
||||
const script = path.join(dir, 'caveman-activate.js');
|
||||
fs.writeFileSync(script, '// real');
|
||||
const s = {
|
||||
hooks: {
|
||||
SessionStart: [{ hooks: [
|
||||
{ type: 'command', command: `"/opt/node/bin/node" "${script}"` },
|
||||
] }],
|
||||
},
|
||||
};
|
||||
const removed = SETTINGS.pruneOrphanedManagedHooks(s, dir);
|
||||
assert.equal(removed, 0);
|
||||
assert.equal(s.hooks.SessionStart.length, 1);
|
||||
});
|
||||
|
||||
test('pruneOrphanedManagedHooks leaves non-managed hooks alone even if missing', () => {
|
||||
const s = {
|
||||
hooks: {
|
||||
SessionStart: [{ hooks: [
|
||||
{ type: 'command', command: 'node /no/such/dir/some-user-hook.js' },
|
||||
{ type: 'command', command: '[ -n "$SUPERSET_HOME_DIR" ] && "$SUPERSET_HOME_DIR/hooks/notify.sh" || true' },
|
||||
] }],
|
||||
},
|
||||
};
|
||||
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
|
||||
assert.equal(removed, 0);
|
||||
assert.equal(s.hooks.SessionStart[0].hooks.length, 2);
|
||||
});
|
||||
|
||||
test('pruneOrphanedManagedHooks resolves relative target against configDir', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-prune-rel-'));
|
||||
// hooks/caveman-activate.js intentionally NOT created → missing
|
||||
const s = {
|
||||
hooks: {
|
||||
SessionStart: [{ hooks: [
|
||||
{ type: 'command', command: 'node hooks/caveman-activate.js' },
|
||||
] }],
|
||||
},
|
||||
};
|
||||
const removed = SETTINGS.pruneOrphanedManagedHooks(s, dir);
|
||||
assert.equal(removed, 1);
|
||||
assert.equal(s.hooks, undefined);
|
||||
});
|
||||
|
||||
test('pruneOrphanedManagedHooks does NOT match a user script whose name merely contains a managed basename', () => {
|
||||
const s = {
|
||||
hooks: {
|
||||
SessionStart: [{ hooks: [
|
||||
// basename is "mycaveman-activate.js" — not an exact managed basename
|
||||
{ type: 'command', command: 'node /no/such/dir/mycaveman-activate.js' },
|
||||
] }],
|
||||
},
|
||||
};
|
||||
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
|
||||
assert.equal(removed, 0);
|
||||
assert.equal(s.hooks.SessionStart[0].hooks.length, 1);
|
||||
});
|
||||
|
||||
test('pruneOrphanedManagedHooks handles quoted paths containing spaces', () => {
|
||||
const s = {
|
||||
hooks: {
|
||||
SessionStart: [{ hooks: [
|
||||
{ type: 'command', command: '"/opt/node/bin/node" "/no such dir/caveman-activate.js"' },
|
||||
] }],
|
||||
},
|
||||
};
|
||||
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
|
||||
assert.equal(removed, 1);
|
||||
assert.equal(s.hooks, undefined);
|
||||
});
|
||||
|
||||
test('pruneOrphanedManagedHooks drops orphaned managed statusLine', () => {
|
||||
const s = {
|
||||
statusLine: { type: 'command', command: 'bash /no/such/dir/caveman-statusline.sh' },
|
||||
};
|
||||
const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing');
|
||||
assert.equal(removed, 1);
|
||||
assert.equal(s.statusLine, undefined);
|
||||
});
|
||||
|
||||
test('claudeConfigDir honors CLAUDE_CONFIG_DIR env', () => {
|
||||
const orig = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = '/tmp/__cm_test_cfg';
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for src/hooks/cavecrew-model-overrides.js
|
||||
// Run: node tests/test_cavecrew_model_overrides.js
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const assert = require('assert');
|
||||
|
||||
const { patchFrontmatterModel, resolvePluginRoot, applyOverrides, AGENT_ENV_MAP } =
|
||||
require('../src/hooks/cavecrew-model-overrides');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(' ✓ ' + name);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.error(' ✗ ' + name);
|
||||
console.error(' ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── patchFrontmatterModel ──────────────────────────────────────────────────
|
||||
|
||||
console.log('\npatchFrontmatterModel\n');
|
||||
|
||||
const REVIEWER_FM = [
|
||||
'---',
|
||||
'name: cavecrew-reviewer',
|
||||
'description: >',
|
||||
' Reviewer subagent.',
|
||||
'tools: [Read, Grep, Bash]',
|
||||
'model: haiku',
|
||||
'---',
|
||||
'',
|
||||
'Body text.',
|
||||
].join('\n');
|
||||
|
||||
test('replaces existing model: haiku with sonnet in reviewer', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, 'sonnet');
|
||||
assert.ok(out.includes('model: sonnet'), 'new model line missing');
|
||||
assert.ok(!out.includes('model: haiku'), 'old model line still present');
|
||||
assert.ok(out.includes('Body text.'), 'body missing');
|
||||
});
|
||||
|
||||
test('preserves all other frontmatter lines', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, 'opus');
|
||||
assert.ok(out.includes('name: cavecrew-reviewer'), 'name line lost');
|
||||
assert.ok(out.includes('tools: [Read, Grep, Bash]'), 'tools line lost');
|
||||
assert.ok(out.includes('description: >'), 'description block lost');
|
||||
});
|
||||
|
||||
const INVESTIGATOR_FM = [
|
||||
'---',
|
||||
'name: cavecrew-investigator',
|
||||
'tools: [Read, Grep, Glob, Bash]',
|
||||
'model: haiku',
|
||||
'---',
|
||||
'',
|
||||
'Investigator body.',
|
||||
].join('\n');
|
||||
|
||||
test('replaces existing model: haiku with opus in investigator', () => {
|
||||
const out = patchFrontmatterModel(INVESTIGATOR_FM, 'opus');
|
||||
assert.ok(out.includes('model: opus'), 'new model missing');
|
||||
assert.ok(!out.includes('model: haiku'), 'old model still present');
|
||||
assert.ok(out.includes('Investigator body.'), 'body lost');
|
||||
});
|
||||
|
||||
const BUILDER_FM = [
|
||||
'---',
|
||||
'name: cavecrew-builder',
|
||||
'description: >',
|
||||
' Builder subagent.',
|
||||
'tools: [Read, Edit, Write, Grep, Glob]',
|
||||
'---',
|
||||
'',
|
||||
'Builder body.',
|
||||
].join('\n');
|
||||
|
||||
test('inserts model: after tools: when no model line exists (builder)', () => {
|
||||
const out = patchFrontmatterModel(BUILDER_FM, 'sonnet');
|
||||
assert.ok(out.includes('model: sonnet'), 'model line not inserted');
|
||||
// Must be inside frontmatter (before body)
|
||||
const fmClose = out.indexOf('\n---', 3);
|
||||
const modelPos = out.indexOf('model: sonnet');
|
||||
assert.ok(modelPos < fmClose, 'model line is outside frontmatter');
|
||||
// Inserted right after tools: line
|
||||
const toolsPos = out.indexOf('tools:');
|
||||
const toolsEnd = out.indexOf('\n', toolsPos);
|
||||
assert.strictEqual(out.slice(toolsEnd + 1, toolsEnd + 1 + 'model: sonnet'.length), 'model: sonnet',
|
||||
'model not inserted immediately after tools: line');
|
||||
assert.ok(out.includes('Builder body.'), 'body lost');
|
||||
});
|
||||
|
||||
test('no-op when content has no frontmatter', () => {
|
||||
const plain = 'Just some text\nno frontmatter\n';
|
||||
const out = patchFrontmatterModel(plain, 'sonnet');
|
||||
assert.strictEqual(out, plain);
|
||||
});
|
||||
|
||||
test('empty model value is no-op (defense-in-depth guard)', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, '');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'empty value should leave file unchanged');
|
||||
});
|
||||
|
||||
test('ignores model value with newline', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, 'so\nnnet');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'should return original unchanged');
|
||||
});
|
||||
|
||||
test('ignores model value with control character', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, 'so\x01nnet');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'should return original unchanged');
|
||||
});
|
||||
|
||||
test('model line already identical → content unchanged', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, 'haiku');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'should be byte-identical when value unchanged');
|
||||
});
|
||||
|
||||
test('no model line and no tools line → inserts before closing ---', () => {
|
||||
const fm = '---\nname: test\n---\n\nbody\n';
|
||||
const out = patchFrontmatterModel(fm, 'sonnet');
|
||||
assert.ok(out.includes('model: sonnet'), 'model line missing');
|
||||
const fmClose = out.indexOf('\n---', 3);
|
||||
const modelPos = out.indexOf('model: sonnet');
|
||||
assert.ok(modelPos < fmClose, 'model line outside frontmatter');
|
||||
});
|
||||
|
||||
test('CRLF files: inserted model line uses CRLF, no mixed endings', () => {
|
||||
const crlf = REVIEWER_FM.replace(/\n/g, '\r\n');
|
||||
const out = patchFrontmatterModel(crlf, 'sonnet');
|
||||
assert.ok(out.includes('model: sonnet'), 'model line missing in CRLF file');
|
||||
// No bare LF should appear outside CRLF sequences
|
||||
const strippedCR = out.replace(/\r\n/g, '');
|
||||
assert.ok(!strippedCR.includes('\n'), 'mixed line endings detected after patch');
|
||||
});
|
||||
|
||||
test('CRLF builder (no model line): inserted model line uses CRLF', () => {
|
||||
const crlf = BUILDER_FM.replace(/\n/g, '\r\n');
|
||||
const out = patchFrontmatterModel(crlf, 'sonnet');
|
||||
assert.ok(out.includes('model: sonnet'), 'model line missing in CRLF builder file');
|
||||
const strippedCR = out.replace(/\r\n/g, '');
|
||||
assert.ok(!strippedCR.includes('\n'), 'mixed line endings in CRLF builder patch');
|
||||
});
|
||||
|
||||
// ── resolvePluginRoot ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\nresolvePluginRoot\n');
|
||||
|
||||
test('resolves to parent of hooks dir', () => {
|
||||
const hooksDir = path.join(os.tmpdir(), 'fake-plugin', 'hooks');
|
||||
const root = resolvePluginRoot(hooksDir);
|
||||
assert.strictEqual(path.basename(root), 'fake-plugin');
|
||||
});
|
||||
|
||||
// ── applyOverrides ─────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\napplyOverrides\n');
|
||||
|
||||
function withTmpPlugin(fn) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-override-test-'));
|
||||
const agentsDir = path.join(tmp, 'agents');
|
||||
fs.mkdirSync(agentsDir);
|
||||
try {
|
||||
fn(tmp, agentsDir);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('replaces reviewer model when CAVECREW_REVIEWER_MODEL set', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.ok(out.includes('model: sonnet'), 'reviewer model not patched');
|
||||
});
|
||||
});
|
||||
|
||||
test('replaces investigator model when CAVECREW_INVESTIGATOR_MODEL set', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-investigator.md'), INVESTIGATOR_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_INVESTIGATOR_MODEL: 'opus' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-investigator.md'), 'utf8');
|
||||
assert.ok(out.includes('model: opus'), 'investigator model not patched');
|
||||
});
|
||||
});
|
||||
|
||||
test('inserts builder model when CAVECREW_BUILDER_MODEL set and no model line', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-builder.md'), BUILDER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_BUILDER_MODEL: 'sonnet' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-builder.md'), 'utf8');
|
||||
assert.ok(out.includes('model: sonnet'), 'builder model not inserted');
|
||||
});
|
||||
});
|
||||
|
||||
test('blank env var is no-op', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: '' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'blank env var should be no-op');
|
||||
});
|
||||
});
|
||||
|
||||
test('whitespace-only env var is no-op', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: ' ' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'whitespace env var should be no-op');
|
||||
});
|
||||
});
|
||||
|
||||
test('env var with newline in value is ignored', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'so\nnnet' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'newline in value should be ignored');
|
||||
});
|
||||
});
|
||||
|
||||
test('env var with control character in value is ignored', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'son\x00net' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'control char in value should be ignored');
|
||||
});
|
||||
});
|
||||
|
||||
test('missing agent file is silent no-op', () => {
|
||||
withTmpPlugin((root) => {
|
||||
// agents dir exists but reviewer file does not
|
||||
assert.doesNotThrow(() => {
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('missing agents dir (non-plugin layout) is silent no-op', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-nolayout-'));
|
||||
try {
|
||||
assert.doesNotThrow(() => {
|
||||
applyOverrides(tmp, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('unset env vars → files untouched', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, {});
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'file should be unchanged when env unset');
|
||||
});
|
||||
});
|
||||
|
||||
test('body content preserved after model patch', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
const content = REVIEWER_FM + '\n\n## Extra\n\nExtra section body.\n';
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), content, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.ok(out.includes('## Extra'), 'extra body section lost');
|
||||
assert.ok(out.includes('Extra section body.'), 'body text lost');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('');
|
||||
if (failed === 0) {
|
||||
console.log('All ' + (passed + failed) + ' tests passed.');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(failed + ' test(s) failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
+25
-11
@@ -14,6 +14,16 @@ const INIT = path.join(ROOT, 'src', 'tools', 'caveman-init.js');
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Point OPENCLAW_WORKSPACE at a nonexistent dir inside the fixture so the
|
||||
// openclaw target reports skipped-workspace-missing instead of writing to
|
||||
// the developer's real ~/.openclaw/workspace.
|
||||
function runInit(tmp, ...args) {
|
||||
return execFileSync(process.execPath, [INIT, tmp, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, OPENCLAW_WORKSPACE: path.join(tmp, 'no-openclaw') },
|
||||
});
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-init-test-'));
|
||||
try {
|
||||
@@ -31,7 +41,7 @@ function test(name, fn) {
|
||||
console.log('caveman-init tests\n');
|
||||
|
||||
test('greenfield: creates all rule files with proper frontmatter', (tmp) => {
|
||||
execFileSync(process.execPath, [INIT, tmp], { encoding: 'utf8' });
|
||||
runInit(tmp);
|
||||
const cursor = fs.readFileSync(path.join(tmp, '.cursor/rules/caveman.mdc'), 'utf8');
|
||||
assert.match(cursor, /alwaysApply: true/);
|
||||
assert.match(cursor, /Respond terse like smart caveman/);
|
||||
@@ -43,18 +53,21 @@ test('greenfield: creates all rule files with proper frontmatter', (tmp) => {
|
||||
assert.match(copilot, /Respond terse/);
|
||||
const agents = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8');
|
||||
assert.match(agents, /Respond terse/);
|
||||
const opencode = fs.readFileSync(path.join(tmp, '.opencode/AGENTS.md'), 'utf8');
|
||||
assert.match(opencode, /Respond terse/);
|
||||
});
|
||||
|
||||
test('idempotent: re-running on a clean install skips all', (tmp) => {
|
||||
execFileSync(process.execPath, [INIT, tmp], { encoding: 'utf8' });
|
||||
const out = execFileSync(process.execPath, [INIT, tmp], { encoding: 'utf8' });
|
||||
assert.match(out, /5 skipped/);
|
||||
runInit(tmp);
|
||||
const out = runInit(tmp);
|
||||
// 6 repo rule files skipped-already-installed + openclaw skipped (no workspace)
|
||||
assert.match(out, /7 skipped/);
|
||||
assert.doesNotMatch(out, /[1-9]\d* added/);
|
||||
});
|
||||
|
||||
test('append mode: existing AGENTS.md gets caveman appended (not replaced)', (tmp) => {
|
||||
fs.writeFileSync(path.join(tmp, 'AGENTS.md'), '# My project\n\nDo not delete me.\n');
|
||||
execFileSync(process.execPath, [INIT, tmp], { encoding: 'utf8' });
|
||||
runInit(tmp);
|
||||
const agents = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8');
|
||||
assert.match(agents, /Do not delete me/);
|
||||
assert.match(agents, /Respond terse like smart caveman/);
|
||||
@@ -64,7 +77,7 @@ test('skip mode: existing .cursor rule is not overwritten without --force', (tmp
|
||||
const dir = path.join(tmp, '.cursor/rules');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'caveman.mdc'), '# original\nDo not delete me.\n');
|
||||
const out = execFileSync(process.execPath, [INIT, tmp], { encoding: 'utf8' });
|
||||
const out = runInit(tmp);
|
||||
assert.match(out, /\? .*\.cursor\/rules\/caveman\.mdc/);
|
||||
const after = fs.readFileSync(path.join(dir, 'caveman.mdc'), 'utf8');
|
||||
assert.strictEqual(after, '# original\nDo not delete me.\n');
|
||||
@@ -74,25 +87,26 @@ test('--force overwrites existing rule files', (tmp) => {
|
||||
const dir = path.join(tmp, '.cursor/rules');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'caveman.mdc'), '# original\n');
|
||||
execFileSync(process.execPath, [INIT, tmp, '--force'], { encoding: 'utf8' });
|
||||
runInit(tmp, '--force');
|
||||
const after = fs.readFileSync(path.join(dir, 'caveman.mdc'), 'utf8');
|
||||
assert.match(after, /alwaysApply: true/);
|
||||
assert.match(after, /Respond terse/);
|
||||
});
|
||||
|
||||
test('--dry-run: announces but writes nothing', (tmp) => {
|
||||
const out = execFileSync(process.execPath, [INIT, tmp, '--dry-run'], { encoding: 'utf8' });
|
||||
const out = runInit(tmp, '--dry-run');
|
||||
assert.match(out, /\(dry run\)/);
|
||||
assert.match(out, /5 added/);
|
||||
assert.match(out, /6 added/);
|
||||
assert.ok(!fs.existsSync(path.join(tmp, '.cursor')));
|
||||
assert.ok(!fs.existsSync(path.join(tmp, '.windsurf')));
|
||||
assert.ok(!fs.existsSync(path.join(tmp, '.clinerules')));
|
||||
assert.ok(!fs.existsSync(path.join(tmp, '.github/copilot-instructions.md')));
|
||||
assert.ok(!fs.existsSync(path.join(tmp, '.opencode')));
|
||||
assert.ok(!fs.existsSync(path.join(tmp, 'AGENTS.md')));
|
||||
});
|
||||
|
||||
test('--only filters to one target', (tmp) => {
|
||||
const out = execFileSync(process.execPath, [INIT, tmp, '--only', 'cline'], { encoding: 'utf8' });
|
||||
const out = runInit(tmp, '--only', 'cline');
|
||||
assert.match(out, /1 added/);
|
||||
assert.ok(fs.existsSync(path.join(tmp, '.clinerules/caveman.md')));
|
||||
assert.ok(!fs.existsSync(path.join(tmp, '.cursor')));
|
||||
@@ -104,7 +118,7 @@ test('detects sentinel and skips files that already have caveman content', (tmp)
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'caveman.md'),
|
||||
'# Existing\n\nRespond terse like smart caveman. Hello.\n');
|
||||
const out = execFileSync(process.execPath, [INIT, tmp, '--only', 'cline'], { encoding: 'utf8' });
|
||||
const out = runInit(tmp, '--only', 'cline');
|
||||
assert.match(out, /skipped-already-installed/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for the shared mode-change parser (#602), src/hooks/caveman-parse.js.
|
||||
// caveman-mode-tracker.js and the opencode plugin both consume this module —
|
||||
// these tests exercise it directly (unit-level) and also check that its
|
||||
// verdicts line up with what the real tracker.js hook does for the same
|
||||
// prompts (parity), so the two callers can't silently drift apart again.
|
||||
//
|
||||
// Run: node tests/test_caveman_parse.js
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const assert = require('assert');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const { parseModeChange, INDEPENDENT_MODES } = require('../src/hooks/caveman-parse');
|
||||
|
||||
const HOOK_PATH = path.resolve(__dirname, '..', 'src', 'hooks', 'caveman-mode-tracker.js');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.error(` ✗ ${name}`);
|
||||
console.error(` ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('caveman-parse (shared mode-change parser) tests\n');
|
||||
|
||||
const defaultFull = { getDefaultMode: () => 'full' };
|
||||
const defaultOff = { getDefaultMode: () => 'off' };
|
||||
|
||||
// ---------- basic unit coverage ----------
|
||||
|
||||
test('empty/whitespace prompt is a no-op', () => {
|
||||
assert.strictEqual(parseModeChange('', defaultFull), null);
|
||||
assert.strictEqual(parseModeChange(' ', defaultFull), null);
|
||||
});
|
||||
|
||||
test('slash level switch', () => {
|
||||
assert.deepStrictEqual(parseModeChange('/caveman ultra', defaultFull), { action: 'set', mode: 'ultra' });
|
||||
});
|
||||
|
||||
test('bare /caveman activates at the configured default', () => {
|
||||
assert.deepStrictEqual(parseModeChange('/caveman', defaultFull), { action: 'set', mode: 'full' });
|
||||
});
|
||||
|
||||
test('bare /caveman with an off default clears instead of setting mode "off"', () => {
|
||||
assert.deepStrictEqual(parseModeChange('/caveman', defaultOff), { action: 'clear' });
|
||||
});
|
||||
|
||||
test('/caveman off|stop|disable all clear', () => {
|
||||
assert.deepStrictEqual(parseModeChange('/caveman off', defaultFull), { action: 'clear' });
|
||||
assert.deepStrictEqual(parseModeChange('/caveman stop', defaultFull), { action: 'clear' });
|
||||
assert.deepStrictEqual(parseModeChange('/caveman disable', defaultFull), { action: 'clear' });
|
||||
});
|
||||
|
||||
test('wenyan-full alias stores as "wenyan"', () => {
|
||||
assert.deepStrictEqual(parseModeChange('/caveman wenyan-full', defaultFull), { action: 'set', mode: 'wenyan' });
|
||||
});
|
||||
|
||||
test('bogus level returns null — never falls through to the default', () => {
|
||||
assert.strictEqual(parseModeChange('/caveman not-a-real-level', defaultFull), null);
|
||||
});
|
||||
|
||||
test('independent modes are not reachable via /caveman <arg>', () => {
|
||||
assert.strictEqual(parseModeChange('/caveman commit', defaultFull), null);
|
||||
});
|
||||
|
||||
test('/caveman-commit, /caveman-review, /caveman-compress set independent modes', () => {
|
||||
assert.deepStrictEqual(parseModeChange('/caveman-commit', defaultFull), { action: 'set', mode: 'commit' });
|
||||
assert.deepStrictEqual(parseModeChange('/caveman-review', defaultFull), { action: 'set', mode: 'review' });
|
||||
assert.deepStrictEqual(parseModeChange('/caveman-compress', defaultFull), { action: 'set', mode: 'compress' });
|
||||
});
|
||||
|
||||
test('namespaced /caveman:caveman-* variants are recognized', () => {
|
||||
assert.deepStrictEqual(parseModeChange('/caveman:caveman-commit', defaultFull), { action: 'set', mode: 'commit' });
|
||||
assert.deepStrictEqual(parseModeChange('/caveman:caveman-review', defaultFull), { action: 'set', mode: 'review' });
|
||||
assert.deepStrictEqual(parseModeChange('/caveman:caveman', defaultFull), { action: 'set', mode: 'full' });
|
||||
});
|
||||
|
||||
test('natural-language activation', () => {
|
||||
assert.deepStrictEqual(parseModeChange('activate caveman', defaultFull), { action: 'set', mode: 'full' });
|
||||
assert.deepStrictEqual(parseModeChange('talk like a caveman', defaultFull), { action: 'set', mode: 'full' });
|
||||
});
|
||||
|
||||
test('brevity triggers activate', () => {
|
||||
assert.deepStrictEqual(parseModeChange('be brief', defaultFull), { action: 'set', mode: 'full' });
|
||||
assert.deepStrictEqual(parseModeChange('fewer tokens please', defaultFull), { action: 'set', mode: 'full' });
|
||||
});
|
||||
|
||||
test('scoped brevity ("be brief in the summary") does not activate', () => {
|
||||
assert.strictEqual(parseModeChange('be brief in the summary section', defaultFull), null);
|
||||
});
|
||||
|
||||
test('questions about caveman do not activate', () => {
|
||||
assert.strictEqual(parseModeChange('what is caveman mode?', defaultFull), null);
|
||||
});
|
||||
|
||||
test('natural-language deactivation', () => {
|
||||
assert.deepStrictEqual(parseModeChange('turn caveman mode off', defaultFull), { action: 'clear' });
|
||||
assert.deepStrictEqual(parseModeChange('normal mode', defaultFull), { action: 'clear' });
|
||||
});
|
||||
|
||||
test('vim "normal mode" (no caveman context) does not deactivate', () => {
|
||||
assert.strictEqual(parseModeChange('how do I exit vim normal mode', defaultFull), null);
|
||||
});
|
||||
|
||||
test('INDEPENDENT_MODES is exported and matches the known set', () => {
|
||||
assert.deepStrictEqual([...INDEPENDENT_MODES].sort(), ['commit', 'compress', 'review']);
|
||||
});
|
||||
|
||||
// ---------- skipNaturalLanguage (foreign command envelopes, #537) ----------
|
||||
|
||||
test('skipNaturalLanguage suppresses activation/deactivation matching entirely', () => {
|
||||
assert.strictEqual(
|
||||
parseModeChange('please activate caveman mode now', { ...defaultFull, skipNaturalLanguage: true }),
|
||||
null
|
||||
);
|
||||
assert.strictEqual(
|
||||
parseModeChange('stop caveman', { ...defaultFull, skipNaturalLanguage: true }),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('skipNaturalLanguage still lets literal slash commands through', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseModeChange('/caveman ultra', { ...defaultFull, skipNaturalLanguage: true }),
|
||||
{ action: 'set', mode: 'ultra' }
|
||||
);
|
||||
});
|
||||
|
||||
// ---------- unwrapQuotes (opencode `run` path) ----------
|
||||
|
||||
test('unwrapQuotes strips a symmetric quote wrapper before matching', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseModeChange('"/caveman lite"', { ...defaultFull, unwrapQuotes: true }),
|
||||
{ action: 'set', mode: 'lite' }
|
||||
);
|
||||
});
|
||||
|
||||
test('without unwrapQuotes, a quoted command does not match', () => {
|
||||
assert.strictEqual(parseModeChange('"/caveman lite"', defaultFull), null);
|
||||
});
|
||||
|
||||
// ---------- expandedTpl (opencode's expanded command-template bodies) ----------
|
||||
|
||||
test('expandedTpl recognizes the generic "/caveman <level>" template', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseModeChange('Activate caveman mode: ultra', { ...defaultFull, expandedTpl: true }),
|
||||
{ action: 'set', mode: 'ultra' }
|
||||
);
|
||||
});
|
||||
|
||||
test('expandedTpl: empty level (bare "/caveman", multi-line template head) uses the default', () => {
|
||||
// The real commands/caveman.md template puts `Activate caveman mode:
|
||||
// $ARGUMENTS` on its own line, followed by a blank line and then fixed
|
||||
// boilerplate ("If no level given, use full. If \"off\", deactivate.").
|
||||
// With $ARGUMENTS empty, whitespace-collapse used to merge that boilerplate
|
||||
// directly onto the same line as the (empty) argument, so the word "if"
|
||||
// (from "If no level given ...") was captured as the level and rejected as
|
||||
// bogus — a bare `/caveman` in opencode silently never activated.
|
||||
// Regression guard for that (matches the shape exercised by
|
||||
// tests/installer/opencode.test.mjs's real-hooks test).
|
||||
const templateNoArgs =
|
||||
'Activate caveman mode: \n\n' +
|
||||
'If no level given, use full. If "off", deactivate.';
|
||||
assert.deepStrictEqual(
|
||||
parseModeChange(templateNoArgs, { ...defaultFull, expandedTpl: true }),
|
||||
{ action: 'set', mode: 'full' }
|
||||
);
|
||||
});
|
||||
|
||||
test('expandedTpl: bogus level in the template returns null, not the default (#602 drift)', () => {
|
||||
assert.strictEqual(
|
||||
parseModeChange('Activate caveman mode: not-a-real-level', { ...defaultFull, expandedTpl: true }),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('expandedTpl recognizes the independent-mode command templates (#602 drift)', () => {
|
||||
assert.deepStrictEqual(
|
||||
parseModeChange('Generate a commit message for the current staged changes.', { ...defaultFull, expandedTpl: true }),
|
||||
{ action: 'set', mode: 'commit' }
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
parseModeChange('Review the current diff (or files: ).', { ...defaultFull, expandedTpl: true }),
|
||||
{ action: 'set', mode: 'review' }
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
parseModeChange('Compress the file at: notes.md', { ...defaultFull, expandedTpl: true }),
|
||||
{ action: 'set', mode: 'compress' }
|
||||
);
|
||||
});
|
||||
|
||||
test('without expandedTpl, template bodies are inert plain text', () => {
|
||||
assert.strictEqual(
|
||||
parseModeChange('Generate a commit message for the current staged changes.', defaultFull),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
// ---------- parity with the real tracker hook ----------
|
||||
// The tracker collapses whitespace/case and applies the same option set this
|
||||
// module expects (getDefaultMode, skipNaturalLanguage). For a representative
|
||||
// set of raw prompts, verify the flag-file outcome the tracker produces
|
||||
// matches what parseModeChange's verdict implies — proving the two stay in
|
||||
// sync rather than just "both look right in isolation".
|
||||
|
||||
function runTracker(prompt, presetFlag) {
|
||||
const cfg = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-parse-parity-'));
|
||||
try {
|
||||
if (presetFlag) fs.writeFileSync(path.join(cfg, '.caveman-active'), presetFlag);
|
||||
spawnSync(process.execPath, [HOOK_PATH], {
|
||||
input: JSON.stringify({ prompt }),
|
||||
env: { ...process.env, CLAUDE_CONFIG_DIR: cfg },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const flagPath = path.join(cfg, '.caveman-active');
|
||||
return fs.existsSync(flagPath) ? fs.readFileSync(flagPath, 'utf8') : null;
|
||||
} finally {
|
||||
fs.rmSync(cfg, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const parityCases = [
|
||||
{ prompt: '/caveman ultra', preset: null },
|
||||
{ prompt: '/caveman off', preset: 'full' },
|
||||
{ prompt: '/caveman not-a-real-level', preset: 'ultra' },
|
||||
{ prompt: 'be brief', preset: null },
|
||||
{ prompt: 'activate caveman', preset: null },
|
||||
{ prompt: 'stop caveman', preset: 'full' },
|
||||
{ prompt: 'what is caveman mode?', preset: null },
|
||||
];
|
||||
|
||||
for (const { prompt, preset } of parityCases) {
|
||||
test(`parity: "${prompt}" (preset=${preset}) matches shared-parser verdict`, () => {
|
||||
const normalized = prompt.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
const verdict = parseModeChange(normalized, { getDefaultMode: () => 'full' });
|
||||
const expected =
|
||||
verdict === null ? (preset || null) :
|
||||
verdict.action === 'clear' ? null :
|
||||
verdict.mode;
|
||||
assert.strictEqual(runTracker(prompt, preset), expected);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user