@hasna/terminal 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +29 -12
- package/package.json +1 -1
- package/src/ai.ts +50 -36
- package/src/cli.tsx +29 -12
- package/src/context-hints.ts +89 -0
- package/src/discover.ts +238 -0
- package/src/economy.ts +53 -0
- package/src/output-store.ts +65 -0
- package/src/providers/index.ts +4 -4
- package/src/sessions-db.ts +81 -0
- package/temp/rtk/.claude/agents/code-reviewer.md +221 -0
- package/temp/rtk/.claude/agents/debugger.md +519 -0
- package/temp/rtk/.claude/agents/rtk-testing-specialist.md +461 -0
- package/temp/rtk/.claude/agents/rust-rtk.md +511 -0
- package/temp/rtk/.claude/agents/technical-writer.md +355 -0
- package/temp/rtk/.claude/commands/diagnose.md +352 -0
- package/temp/rtk/.claude/commands/test-routing.md +362 -0
- package/temp/rtk/.claude/hooks/bash/pre-commit-format.sh +16 -0
- package/temp/rtk/.claude/hooks/rtk-rewrite.sh +70 -0
- package/temp/rtk/.claude/hooks/rtk-suggest.sh +152 -0
- package/temp/rtk/.claude/rules/cli-testing.md +526 -0
- package/temp/rtk/.claude/skills/issue-triage/SKILL.md +348 -0
- package/temp/rtk/.claude/skills/issue-triage/templates/issue-comment.md +134 -0
- package/temp/rtk/.claude/skills/performance.md +435 -0
- package/temp/rtk/.claude/skills/pr-triage/SKILL.md +315 -0
- package/temp/rtk/.claude/skills/pr-triage/templates/review-comment.md +71 -0
- package/temp/rtk/.claude/skills/repo-recap.md +206 -0
- package/temp/rtk/.claude/skills/rtk-tdd/SKILL.md +78 -0
- package/temp/rtk/.claude/skills/rtk-tdd/references/testing-patterns.md +124 -0
- package/temp/rtk/.claude/skills/security-guardian.md +503 -0
- package/temp/rtk/.claude/skills/ship.md +404 -0
- package/temp/rtk/.github/workflows/benchmark.yml +34 -0
- package/temp/rtk/.github/workflows/dco-check.yaml +12 -0
- package/temp/rtk/.github/workflows/release-please.yml +51 -0
- package/temp/rtk/.github/workflows/release.yml +343 -0
- package/temp/rtk/.github/workflows/security-check.yml +135 -0
- package/temp/rtk/.github/workflows/validate-docs.yml +78 -0
- package/temp/rtk/.release-please-manifest.json +3 -0
- package/temp/rtk/ARCHITECTURE.md +1491 -0
- package/temp/rtk/CHANGELOG.md +640 -0
- package/temp/rtk/CLAUDE.md +605 -0
- package/temp/rtk/CONTRIBUTING.md +199 -0
- package/temp/rtk/Cargo.lock +1668 -0
- package/temp/rtk/Cargo.toml +64 -0
- package/temp/rtk/Formula/rtk.rb +43 -0
- package/temp/rtk/INSTALL.md +390 -0
- package/temp/rtk/LICENSE +21 -0
- package/temp/rtk/README.md +386 -0
- package/temp/rtk/README_es.md +159 -0
- package/temp/rtk/README_fr.md +197 -0
- package/temp/rtk/README_ja.md +159 -0
- package/temp/rtk/README_ko.md +159 -0
- package/temp/rtk/README_zh.md +167 -0
- package/temp/rtk/ROADMAP.md +15 -0
- package/temp/rtk/SECURITY.md +217 -0
- package/temp/rtk/TEST_EXEC_TIME.md +102 -0
- package/temp/rtk/build.rs +57 -0
- package/temp/rtk/docs/AUDIT_GUIDE.md +432 -0
- package/temp/rtk/docs/FEATURES.md +1410 -0
- package/temp/rtk/docs/TROUBLESHOOTING.md +309 -0
- package/temp/rtk/docs/filter-workflow.md +102 -0
- package/temp/rtk/docs/images/gain-dashboard.jpg +0 -0
- package/temp/rtk/docs/tracking.md +583 -0
- package/temp/rtk/hooks/opencode-rtk.ts +39 -0
- package/temp/rtk/hooks/rtk-awareness.md +29 -0
- package/temp/rtk/hooks/rtk-rewrite.sh +61 -0
- package/temp/rtk/hooks/test-rtk-rewrite.sh +442 -0
- package/temp/rtk/install.sh +124 -0
- package/temp/rtk/release-please-config.json +10 -0
- package/temp/rtk/scripts/benchmark.sh +592 -0
- package/temp/rtk/scripts/check-installation.sh +162 -0
- package/temp/rtk/scripts/install-local.sh +37 -0
- package/temp/rtk/scripts/rtk-economics.sh +137 -0
- package/temp/rtk/scripts/test-all.sh +561 -0
- package/temp/rtk/scripts/test-aristote.sh +227 -0
- package/temp/rtk/scripts/test-tracking.sh +79 -0
- package/temp/rtk/scripts/update-readme-metrics.sh +32 -0
- package/temp/rtk/scripts/validate-docs.sh +73 -0
- package/temp/rtk/src/aws_cmd.rs +880 -0
- package/temp/rtk/src/binlog.rs +1645 -0
- package/temp/rtk/src/cargo_cmd.rs +1727 -0
- package/temp/rtk/src/cc_economics.rs +1157 -0
- package/temp/rtk/src/ccusage.rs +340 -0
- package/temp/rtk/src/config.rs +187 -0
- package/temp/rtk/src/container.rs +855 -0
- package/temp/rtk/src/curl_cmd.rs +134 -0
- package/temp/rtk/src/deps.rs +268 -0
- package/temp/rtk/src/diff_cmd.rs +367 -0
- package/temp/rtk/src/discover/mod.rs +274 -0
- package/temp/rtk/src/discover/provider.rs +388 -0
- package/temp/rtk/src/discover/registry.rs +2022 -0
- package/temp/rtk/src/discover/report.rs +202 -0
- package/temp/rtk/src/discover/rules.rs +667 -0
- package/temp/rtk/src/display_helpers.rs +402 -0
- package/temp/rtk/src/dotnet_cmd.rs +1771 -0
- package/temp/rtk/src/dotnet_format_report.rs +133 -0
- package/temp/rtk/src/dotnet_trx.rs +593 -0
- package/temp/rtk/src/env_cmd.rs +204 -0
- package/temp/rtk/src/filter.rs +462 -0
- package/temp/rtk/src/filters/README.md +52 -0
- package/temp/rtk/src/filters/ansible-playbook.toml +34 -0
- package/temp/rtk/src/filters/basedpyright.toml +47 -0
- package/temp/rtk/src/filters/biome.toml +45 -0
- package/temp/rtk/src/filters/brew-install.toml +37 -0
- package/temp/rtk/src/filters/composer-install.toml +40 -0
- package/temp/rtk/src/filters/df.toml +16 -0
- package/temp/rtk/src/filters/dotnet-build.toml +64 -0
- package/temp/rtk/src/filters/du.toml +16 -0
- package/temp/rtk/src/filters/fail2ban-client.toml +15 -0
- package/temp/rtk/src/filters/gcc.toml +49 -0
- package/temp/rtk/src/filters/gcloud.toml +22 -0
- package/temp/rtk/src/filters/hadolint.toml +24 -0
- package/temp/rtk/src/filters/helm.toml +29 -0
- package/temp/rtk/src/filters/iptables.toml +27 -0
- package/temp/rtk/src/filters/jj.toml +28 -0
- package/temp/rtk/src/filters/jq.toml +24 -0
- package/temp/rtk/src/filters/make.toml +41 -0
- package/temp/rtk/src/filters/markdownlint.toml +24 -0
- package/temp/rtk/src/filters/mix-compile.toml +27 -0
- package/temp/rtk/src/filters/mix-format.toml +15 -0
- package/temp/rtk/src/filters/mvn-build.toml +44 -0
- package/temp/rtk/src/filters/oxlint.toml +43 -0
- package/temp/rtk/src/filters/ping.toml +63 -0
- package/temp/rtk/src/filters/pio-run.toml +40 -0
- package/temp/rtk/src/filters/poetry-install.toml +50 -0
- package/temp/rtk/src/filters/pre-commit.toml +35 -0
- package/temp/rtk/src/filters/ps.toml +16 -0
- package/temp/rtk/src/filters/quarto-render.toml +41 -0
- package/temp/rtk/src/filters/rsync.toml +48 -0
- package/temp/rtk/src/filters/shellcheck.toml +27 -0
- package/temp/rtk/src/filters/shopify-theme.toml +29 -0
- package/temp/rtk/src/filters/skopeo.toml +45 -0
- package/temp/rtk/src/filters/sops.toml +16 -0
- package/temp/rtk/src/filters/ssh.toml +44 -0
- package/temp/rtk/src/filters/stat.toml +34 -0
- package/temp/rtk/src/filters/swift-build.toml +41 -0
- package/temp/rtk/src/filters/systemctl-status.toml +33 -0
- package/temp/rtk/src/filters/terraform-plan.toml +35 -0
- package/temp/rtk/src/filters/tofu-fmt.toml +16 -0
- package/temp/rtk/src/filters/tofu-init.toml +38 -0
- package/temp/rtk/src/filters/tofu-plan.toml +35 -0
- package/temp/rtk/src/filters/tofu-validate.toml +17 -0
- package/temp/rtk/src/filters/trunk-build.toml +39 -0
- package/temp/rtk/src/filters/ty.toml +50 -0
- package/temp/rtk/src/filters/uv-sync.toml +37 -0
- package/temp/rtk/src/filters/xcodebuild.toml +99 -0
- package/temp/rtk/src/filters/yamllint.toml +25 -0
- package/temp/rtk/src/find_cmd.rs +598 -0
- package/temp/rtk/src/format_cmd.rs +386 -0
- package/temp/rtk/src/gain.rs +723 -0
- package/temp/rtk/src/gh_cmd.rs +1651 -0
- package/temp/rtk/src/git.rs +2012 -0
- package/temp/rtk/src/go_cmd.rs +592 -0
- package/temp/rtk/src/golangci_cmd.rs +254 -0
- package/temp/rtk/src/grep_cmd.rs +288 -0
- package/temp/rtk/src/gt_cmd.rs +810 -0
- package/temp/rtk/src/hook_audit_cmd.rs +283 -0
- package/temp/rtk/src/hook_check.rs +171 -0
- package/temp/rtk/src/init.rs +1859 -0
- package/temp/rtk/src/integrity.rs +537 -0
- package/temp/rtk/src/json_cmd.rs +231 -0
- package/temp/rtk/src/learn/detector.rs +628 -0
- package/temp/rtk/src/learn/mod.rs +119 -0
- package/temp/rtk/src/learn/report.rs +184 -0
- package/temp/rtk/src/lint_cmd.rs +694 -0
- package/temp/rtk/src/local_llm.rs +316 -0
- package/temp/rtk/src/log_cmd.rs +248 -0
- package/temp/rtk/src/ls.rs +324 -0
- package/temp/rtk/src/main.rs +2482 -0
- package/temp/rtk/src/mypy_cmd.rs +389 -0
- package/temp/rtk/src/next_cmd.rs +241 -0
- package/temp/rtk/src/npm_cmd.rs +236 -0
- package/temp/rtk/src/parser/README.md +267 -0
- package/temp/rtk/src/parser/error.rs +46 -0
- package/temp/rtk/src/parser/formatter.rs +336 -0
- package/temp/rtk/src/parser/mod.rs +311 -0
- package/temp/rtk/src/parser/types.rs +119 -0
- package/temp/rtk/src/pip_cmd.rs +302 -0
- package/temp/rtk/src/playwright_cmd.rs +479 -0
- package/temp/rtk/src/pnpm_cmd.rs +573 -0
- package/temp/rtk/src/prettier_cmd.rs +221 -0
- package/temp/rtk/src/prisma_cmd.rs +482 -0
- package/temp/rtk/src/psql_cmd.rs +382 -0
- package/temp/rtk/src/pytest_cmd.rs +384 -0
- package/temp/rtk/src/read.rs +217 -0
- package/temp/rtk/src/rewrite_cmd.rs +50 -0
- package/temp/rtk/src/ruff_cmd.rs +402 -0
- package/temp/rtk/src/runner.rs +271 -0
- package/temp/rtk/src/summary.rs +297 -0
- package/temp/rtk/src/tee.rs +405 -0
- package/temp/rtk/src/telemetry.rs +248 -0
- package/temp/rtk/src/toml_filter.rs +1655 -0
- package/temp/rtk/src/tracking.rs +1416 -0
- package/temp/rtk/src/tree.rs +209 -0
- package/temp/rtk/src/tsc_cmd.rs +259 -0
- package/temp/rtk/src/utils.rs +432 -0
- package/temp/rtk/src/verify_cmd.rs +47 -0
- package/temp/rtk/src/vitest_cmd.rs +385 -0
- package/temp/rtk/src/wc_cmd.rs +401 -0
- package/temp/rtk/src/wget_cmd.rs +260 -0
- package/temp/rtk/tests/fixtures/dotnet/build_failed.txt +11 -0
- package/temp/rtk/tests/fixtures/dotnet/format_changes.json +31 -0
- package/temp/rtk/tests/fixtures/dotnet/format_empty.json +1 -0
- package/temp/rtk/tests/fixtures/dotnet/format_success.json +12 -0
- package/temp/rtk/tests/fixtures/dotnet/test_failed.txt +18 -0
|
@@ -0,0 +1,1859 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::io::Write;
|
|
4
|
+
use std::path::{Path, PathBuf};
|
|
5
|
+
use tempfile::NamedTempFile;
|
|
6
|
+
|
|
7
|
+
use crate::integrity;
|
|
8
|
+
|
|
9
|
+
// Embedded hook script (guards before set -euo pipefail)
|
|
10
|
+
const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh");
|
|
11
|
+
|
|
12
|
+
// Embedded OpenCode plugin (auto-rewrite)
|
|
13
|
+
const OPENCODE_PLUGIN: &str = include_str!("../hooks/opencode-rtk.ts");
|
|
14
|
+
|
|
15
|
+
// Embedded slim RTK awareness instructions
|
|
16
|
+
const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md");
|
|
17
|
+
|
|
18
|
+
/// Template written by `rtk init` when no filters.toml exists yet.
|
|
19
|
+
const FILTERS_TEMPLATE: &str = r#"# Project-local RTK filters — commit this file with your repo.
|
|
20
|
+
# Filters here override user-global and built-in filters.
|
|
21
|
+
# Docs: https://github.com/rtk-ai/rtk#custom-filters
|
|
22
|
+
schema_version = 1
|
|
23
|
+
|
|
24
|
+
# Example: suppress build noise from a custom tool
|
|
25
|
+
# [filters.my-tool]
|
|
26
|
+
# description = "Compact my-tool output"
|
|
27
|
+
# match_command = "^my-tool\\s+build"
|
|
28
|
+
# strip_ansi = true
|
|
29
|
+
# strip_lines_matching = ["^\\s*$", "^Downloading", "^Installing"]
|
|
30
|
+
# max_lines = 30
|
|
31
|
+
# on_empty = "my-tool: ok"
|
|
32
|
+
"#;
|
|
33
|
+
|
|
34
|
+
/// Template for user-global filters (~/.config/rtk/filters.toml).
|
|
35
|
+
const FILTERS_GLOBAL_TEMPLATE: &str = r#"# User-global RTK filters — apply to all your projects.
|
|
36
|
+
# Project-local .rtk/filters.toml takes precedence over these.
|
|
37
|
+
# Docs: https://github.com/rtk-ai/rtk#custom-filters
|
|
38
|
+
schema_version = 1
|
|
39
|
+
|
|
40
|
+
# Example: suppress noise from a tool you use everywhere
|
|
41
|
+
# [filters.my-global-tool]
|
|
42
|
+
# description = "Compact my-global-tool output"
|
|
43
|
+
# match_command = "^my-global-tool\\b"
|
|
44
|
+
# strip_ansi = true
|
|
45
|
+
# strip_lines_matching = ["^\\s*$"]
|
|
46
|
+
# max_lines = 40
|
|
47
|
+
"#;
|
|
48
|
+
|
|
49
|
+
/// Control flow for settings.json patching
|
|
50
|
+
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
51
|
+
pub enum PatchMode {
|
|
52
|
+
Ask, // Default: prompt user [y/N]
|
|
53
|
+
Auto, // --auto-patch: no prompt
|
|
54
|
+
Skip, // --no-patch: manual instructions
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Result of settings.json patching operation
|
|
58
|
+
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
59
|
+
pub enum PatchResult {
|
|
60
|
+
Patched, // Hook was added successfully
|
|
61
|
+
AlreadyPresent, // Hook was already in settings.json
|
|
62
|
+
Declined, // User declined when prompted
|
|
63
|
+
Skipped, // --no-patch flag used
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Legacy full instructions for backward compatibility (--claude-md mode)
|
|
67
|
+
const RTK_INSTRUCTIONS: &str = r##"<!-- rtk-instructions v2 -->
|
|
68
|
+
# RTK (Rust Token Killer) - Token-Optimized Commands
|
|
69
|
+
|
|
70
|
+
## Golden Rule
|
|
71
|
+
|
|
72
|
+
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
|
|
73
|
+
|
|
74
|
+
**Important**: Even in command chains with `&&`, use `rtk`:
|
|
75
|
+
```bash
|
|
76
|
+
# ❌ Wrong
|
|
77
|
+
git add . && git commit -m "msg" && git push
|
|
78
|
+
|
|
79
|
+
# ✅ Correct
|
|
80
|
+
rtk git add . && rtk git commit -m "msg" && rtk git push
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## RTK Commands by Workflow
|
|
84
|
+
|
|
85
|
+
### Build & Compile (80-90% savings)
|
|
86
|
+
```bash
|
|
87
|
+
rtk cargo build # Cargo build output
|
|
88
|
+
rtk cargo check # Cargo check output
|
|
89
|
+
rtk cargo clippy # Clippy warnings grouped by file (80%)
|
|
90
|
+
rtk tsc # TypeScript errors grouped by file/code (83%)
|
|
91
|
+
rtk lint # ESLint/Biome violations grouped (84%)
|
|
92
|
+
rtk prettier --check # Files needing format only (70%)
|
|
93
|
+
rtk next build # Next.js build with route metrics (87%)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Test (90-99% savings)
|
|
97
|
+
```bash
|
|
98
|
+
rtk cargo test # Cargo test failures only (90%)
|
|
99
|
+
rtk vitest run # Vitest failures only (99.5%)
|
|
100
|
+
rtk playwright test # Playwright failures only (94%)
|
|
101
|
+
rtk test <cmd> # Generic test wrapper - failures only
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Git (59-80% savings)
|
|
105
|
+
```bash
|
|
106
|
+
rtk git status # Compact status
|
|
107
|
+
rtk git log # Compact log (works with all git flags)
|
|
108
|
+
rtk git diff # Compact diff (80%)
|
|
109
|
+
rtk git show # Compact show (80%)
|
|
110
|
+
rtk git add # Ultra-compact confirmations (59%)
|
|
111
|
+
rtk git commit # Ultra-compact confirmations (59%)
|
|
112
|
+
rtk git push # Ultra-compact confirmations
|
|
113
|
+
rtk git pull # Ultra-compact confirmations
|
|
114
|
+
rtk git branch # Compact branch list
|
|
115
|
+
rtk git fetch # Compact fetch
|
|
116
|
+
rtk git stash # Compact stash
|
|
117
|
+
rtk git worktree # Compact worktree
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
|
|
121
|
+
|
|
122
|
+
### GitHub (26-87% savings)
|
|
123
|
+
```bash
|
|
124
|
+
rtk gh pr view <num> # Compact PR view (87%)
|
|
125
|
+
rtk gh pr checks # Compact PR checks (79%)
|
|
126
|
+
rtk gh run list # Compact workflow runs (82%)
|
|
127
|
+
rtk gh issue list # Compact issue list (80%)
|
|
128
|
+
rtk gh api # Compact API responses (26%)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### JavaScript/TypeScript Tooling (70-90% savings)
|
|
132
|
+
```bash
|
|
133
|
+
rtk pnpm list # Compact dependency tree (70%)
|
|
134
|
+
rtk pnpm outdated # Compact outdated packages (80%)
|
|
135
|
+
rtk pnpm install # Compact install output (90%)
|
|
136
|
+
rtk npm run <script> # Compact npm script output
|
|
137
|
+
rtk npx <cmd> # Compact npx command output
|
|
138
|
+
rtk prisma # Prisma without ASCII art (88%)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Files & Search (60-75% savings)
|
|
142
|
+
```bash
|
|
143
|
+
rtk ls <path> # Tree format, compact (65%)
|
|
144
|
+
rtk read <file> # Code reading with filtering (60%)
|
|
145
|
+
rtk grep <pattern> # Search grouped by file (75%)
|
|
146
|
+
rtk find <pattern> # Find grouped by directory (70%)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Analysis & Debug (70-90% savings)
|
|
150
|
+
```bash
|
|
151
|
+
rtk err <cmd> # Filter errors only from any command
|
|
152
|
+
rtk log <file> # Deduplicated logs with counts
|
|
153
|
+
rtk json <file> # JSON structure without values
|
|
154
|
+
rtk deps # Dependency overview
|
|
155
|
+
rtk env # Environment variables compact
|
|
156
|
+
rtk summary <cmd> # Smart summary of command output
|
|
157
|
+
rtk diff # Ultra-compact diffs
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Infrastructure (85% savings)
|
|
161
|
+
```bash
|
|
162
|
+
rtk docker ps # Compact container list
|
|
163
|
+
rtk docker images # Compact image list
|
|
164
|
+
rtk docker logs <c> # Deduplicated logs
|
|
165
|
+
rtk kubectl get # Compact resource list
|
|
166
|
+
rtk kubectl logs # Deduplicated pod logs
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Network (65-70% savings)
|
|
170
|
+
```bash
|
|
171
|
+
rtk curl <url> # Compact HTTP responses (70%)
|
|
172
|
+
rtk wget <url> # Compact download output (65%)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Meta Commands
|
|
176
|
+
```bash
|
|
177
|
+
rtk gain # View token savings statistics
|
|
178
|
+
rtk gain --history # View command history with savings
|
|
179
|
+
rtk discover # Analyze Claude Code sessions for missed RTK usage
|
|
180
|
+
rtk proxy <cmd> # Run command without filtering (for debugging)
|
|
181
|
+
rtk init # Add RTK instructions to CLAUDE.md
|
|
182
|
+
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Token Savings Overview
|
|
186
|
+
|
|
187
|
+
| Category | Commands | Typical Savings |
|
|
188
|
+
|----------|----------|-----------------|
|
|
189
|
+
| Tests | vitest, playwright, cargo test | 90-99% |
|
|
190
|
+
| Build | next, tsc, lint, prettier | 70-87% |
|
|
191
|
+
| Git | status, log, diff, add, commit | 59-80% |
|
|
192
|
+
| GitHub | gh pr, gh run, gh issue | 26-87% |
|
|
193
|
+
| Package Managers | pnpm, npm, npx | 70-90% |
|
|
194
|
+
| Files | ls, read, grep, find | 60-75% |
|
|
195
|
+
| Infrastructure | docker, kubectl | 85% |
|
|
196
|
+
| Network | curl, wget | 65-70% |
|
|
197
|
+
|
|
198
|
+
Overall average: **60-90% token reduction** on common development operations.
|
|
199
|
+
<!-- /rtk-instructions -->
|
|
200
|
+
"##;
|
|
201
|
+
|
|
202
|
+
/// Main entry point for `rtk init`
|
|
203
|
+
pub fn run(
|
|
204
|
+
global: bool,
|
|
205
|
+
install_claude: bool,
|
|
206
|
+
install_opencode: bool,
|
|
207
|
+
claude_md: bool,
|
|
208
|
+
hook_only: bool,
|
|
209
|
+
patch_mode: PatchMode,
|
|
210
|
+
verbose: u8,
|
|
211
|
+
) -> Result<()> {
|
|
212
|
+
if install_opencode && !global {
|
|
213
|
+
anyhow::bail!("OpenCode plugin is global-only. Use: rtk init -g --opencode");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Mode selection
|
|
217
|
+
match (install_claude, install_opencode, claude_md, hook_only) {
|
|
218
|
+
(false, true, _, _) => run_opencode_only_mode(verbose),
|
|
219
|
+
(true, opencode, true, _) => run_claude_md_mode(global, verbose, opencode),
|
|
220
|
+
(true, opencode, false, true) => run_hook_only_mode(global, patch_mode, verbose, opencode),
|
|
221
|
+
(true, opencode, false, false) => run_default_mode(global, patch_mode, verbose, opencode),
|
|
222
|
+
(false, false, _, _) => {
|
|
223
|
+
anyhow::bail!("at least one of install_claude or install_opencode must be true")
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// Prepare hook directory and return paths (hook_dir, hook_path)
|
|
229
|
+
fn prepare_hook_paths() -> Result<(PathBuf, PathBuf)> {
|
|
230
|
+
let claude_dir = resolve_claude_dir()?;
|
|
231
|
+
let hook_dir = claude_dir.join("hooks");
|
|
232
|
+
fs::create_dir_all(&hook_dir)
|
|
233
|
+
.with_context(|| format!("Failed to create hook directory: {}", hook_dir.display()))?;
|
|
234
|
+
let hook_path = hook_dir.join("rtk-rewrite.sh");
|
|
235
|
+
Ok((hook_dir, hook_path))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Write hook file if missing or outdated, return true if changed
|
|
239
|
+
#[cfg(unix)]
|
|
240
|
+
fn ensure_hook_installed(hook_path: &Path, verbose: u8) -> Result<bool> {
|
|
241
|
+
let changed = if hook_path.exists() {
|
|
242
|
+
let existing = fs::read_to_string(hook_path)
|
|
243
|
+
.with_context(|| format!("Failed to read existing hook: {}", hook_path.display()))?;
|
|
244
|
+
|
|
245
|
+
if existing == REWRITE_HOOK {
|
|
246
|
+
if verbose > 0 {
|
|
247
|
+
eprintln!("Hook already up to date: {}", hook_path.display());
|
|
248
|
+
}
|
|
249
|
+
false
|
|
250
|
+
} else {
|
|
251
|
+
fs::write(hook_path, REWRITE_HOOK)
|
|
252
|
+
.with_context(|| format!("Failed to write hook to {}", hook_path.display()))?;
|
|
253
|
+
if verbose > 0 {
|
|
254
|
+
eprintln!("Updated hook: {}", hook_path.display());
|
|
255
|
+
}
|
|
256
|
+
true
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
fs::write(hook_path, REWRITE_HOOK)
|
|
260
|
+
.with_context(|| format!("Failed to write hook to {}", hook_path.display()))?;
|
|
261
|
+
if verbose > 0 {
|
|
262
|
+
eprintln!("Created hook: {}", hook_path.display());
|
|
263
|
+
}
|
|
264
|
+
true
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Set executable permissions
|
|
268
|
+
use std::os::unix::fs::PermissionsExt;
|
|
269
|
+
fs::set_permissions(hook_path, fs::Permissions::from_mode(0o755))
|
|
270
|
+
.with_context(|| format!("Failed to set hook permissions: {}", hook_path.display()))?;
|
|
271
|
+
|
|
272
|
+
// Store SHA-256 hash for runtime integrity verification.
|
|
273
|
+
// Always store (idempotent) to ensure baseline exists even for
|
|
274
|
+
// hooks installed before integrity checks were added.
|
|
275
|
+
integrity::store_hash(hook_path)
|
|
276
|
+
.with_context(|| format!("Failed to store integrity hash for {}", hook_path.display()))?;
|
|
277
|
+
if verbose > 0 && changed {
|
|
278
|
+
eprintln!("Stored integrity hash for hook");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
Ok(changed)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/// Idempotent file write: create or update if content differs
|
|
285
|
+
fn write_if_changed(path: &Path, content: &str, name: &str, verbose: u8) -> Result<bool> {
|
|
286
|
+
if path.exists() {
|
|
287
|
+
let existing = fs::read_to_string(path)
|
|
288
|
+
.with_context(|| format!("Failed to read {}: {}", name, path.display()))?;
|
|
289
|
+
|
|
290
|
+
if existing == content {
|
|
291
|
+
if verbose > 0 {
|
|
292
|
+
eprintln!("{} already up to date: {}", name, path.display());
|
|
293
|
+
}
|
|
294
|
+
Ok(false)
|
|
295
|
+
} else {
|
|
296
|
+
fs::write(path, content)
|
|
297
|
+
.with_context(|| format!("Failed to write {}: {}", name, path.display()))?;
|
|
298
|
+
if verbose > 0 {
|
|
299
|
+
eprintln!("Updated {}: {}", name, path.display());
|
|
300
|
+
}
|
|
301
|
+
Ok(true)
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
fs::write(path, content)
|
|
305
|
+
.with_context(|| format!("Failed to write {}: {}", name, path.display()))?;
|
|
306
|
+
if verbose > 0 {
|
|
307
|
+
eprintln!("Created {}: {}", name, path.display());
|
|
308
|
+
}
|
|
309
|
+
Ok(true)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/// Atomic write using tempfile + rename
|
|
314
|
+
/// Prevents corruption on crash/interrupt
|
|
315
|
+
fn atomic_write(path: &Path, content: &str) -> Result<()> {
|
|
316
|
+
let parent = path.parent().with_context(|| {
|
|
317
|
+
format!(
|
|
318
|
+
"Cannot write to {}: path has no parent directory",
|
|
319
|
+
path.display()
|
|
320
|
+
)
|
|
321
|
+
})?;
|
|
322
|
+
|
|
323
|
+
// Create temp file in same directory (ensures same filesystem for atomic rename)
|
|
324
|
+
let mut temp_file = NamedTempFile::new_in(parent)
|
|
325
|
+
.with_context(|| format!("Failed to create temp file in {}", parent.display()))?;
|
|
326
|
+
|
|
327
|
+
// Write content
|
|
328
|
+
temp_file
|
|
329
|
+
.write_all(content.as_bytes())
|
|
330
|
+
.with_context(|| format!("Failed to write {} bytes to temp file", content.len()))?;
|
|
331
|
+
|
|
332
|
+
// Atomic rename
|
|
333
|
+
temp_file.persist(path).with_context(|| {
|
|
334
|
+
format!(
|
|
335
|
+
"Failed to atomically replace {} (disk full?)",
|
|
336
|
+
path.display()
|
|
337
|
+
)
|
|
338
|
+
})?;
|
|
339
|
+
|
|
340
|
+
Ok(())
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// Prompt user for consent to patch settings.json
|
|
344
|
+
/// Prints to stderr (stdout may be piped), reads from stdin
|
|
345
|
+
/// Default is No (capital N)
|
|
346
|
+
fn prompt_user_consent(settings_path: &Path) -> Result<bool> {
|
|
347
|
+
use std::io::{self, BufRead, IsTerminal};
|
|
348
|
+
|
|
349
|
+
eprintln!("\nPatch existing {}? [y/N] ", settings_path.display());
|
|
350
|
+
|
|
351
|
+
// If stdin is not a terminal (piped), default to No
|
|
352
|
+
if !io::stdin().is_terminal() {
|
|
353
|
+
eprintln!("(non-interactive mode, defaulting to N)");
|
|
354
|
+
return Ok(false);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let stdin = io::stdin();
|
|
358
|
+
let mut line = String::new();
|
|
359
|
+
stdin
|
|
360
|
+
.lock()
|
|
361
|
+
.read_line(&mut line)
|
|
362
|
+
.context("Failed to read user input")?;
|
|
363
|
+
|
|
364
|
+
let response = line.trim().to_lowercase();
|
|
365
|
+
Ok(response == "y" || response == "yes")
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/// Print manual instructions for settings.json patching
|
|
369
|
+
fn print_manual_instructions(hook_path: &Path, include_opencode: bool) {
|
|
370
|
+
println!("\n MANUAL STEP: Add this to ~/.claude/settings.json:");
|
|
371
|
+
println!(" {{");
|
|
372
|
+
println!(" \"hooks\": {{ \"PreToolUse\": [{{");
|
|
373
|
+
println!(" \"matcher\": \"Bash\",");
|
|
374
|
+
println!(" \"hooks\": [{{ \"type\": \"command\",");
|
|
375
|
+
println!(" \"command\": \"{}\"", hook_path.display());
|
|
376
|
+
println!(" }}]");
|
|
377
|
+
println!(" }}]}}");
|
|
378
|
+
println!(" }}");
|
|
379
|
+
if include_opencode {
|
|
380
|
+
println!("\n Then restart Claude Code and OpenCode. Test with: git status\n");
|
|
381
|
+
} else {
|
|
382
|
+
println!("\n Then restart Claude Code. Test with: git status\n");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/// Remove RTK hook entry from settings.json
|
|
387
|
+
/// Returns true if hook was found and removed
|
|
388
|
+
fn remove_hook_from_json(root: &mut serde_json::Value) -> bool {
|
|
389
|
+
let hooks = match root.get_mut("hooks").and_then(|h| h.get_mut("PreToolUse")) {
|
|
390
|
+
Some(pre_tool_use) => pre_tool_use,
|
|
391
|
+
None => return false,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
let pre_tool_use_array = match hooks.as_array_mut() {
|
|
395
|
+
Some(arr) => arr,
|
|
396
|
+
None => return false,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// Find and remove RTK entry
|
|
400
|
+
let original_len = pre_tool_use_array.len();
|
|
401
|
+
pre_tool_use_array.retain(|entry| {
|
|
402
|
+
if let Some(hooks_array) = entry.get("hooks").and_then(|h| h.as_array()) {
|
|
403
|
+
for hook in hooks_array {
|
|
404
|
+
if let Some(command) = hook.get("command").and_then(|c| c.as_str()) {
|
|
405
|
+
if command.contains("rtk-rewrite.sh") {
|
|
406
|
+
return false; // Remove this entry
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
true // Keep this entry
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
pre_tool_use_array.len() < original_len
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/// Remove RTK hook from settings.json file
|
|
418
|
+
/// Backs up before modification, returns true if hook was found and removed
|
|
419
|
+
fn remove_hook_from_settings(verbose: u8) -> Result<bool> {
|
|
420
|
+
let claude_dir = resolve_claude_dir()?;
|
|
421
|
+
let settings_path = claude_dir.join("settings.json");
|
|
422
|
+
|
|
423
|
+
if !settings_path.exists() {
|
|
424
|
+
if verbose > 0 {
|
|
425
|
+
eprintln!("settings.json not found, nothing to remove");
|
|
426
|
+
}
|
|
427
|
+
return Ok(false);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let content = fs::read_to_string(&settings_path)
|
|
431
|
+
.with_context(|| format!("Failed to read {}", settings_path.display()))?;
|
|
432
|
+
|
|
433
|
+
if content.trim().is_empty() {
|
|
434
|
+
return Ok(false);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let mut root: serde_json::Value = serde_json::from_str(&content)
|
|
438
|
+
.with_context(|| format!("Failed to parse {} as JSON", settings_path.display()))?;
|
|
439
|
+
|
|
440
|
+
let removed = remove_hook_from_json(&mut root);
|
|
441
|
+
|
|
442
|
+
if removed {
|
|
443
|
+
// Backup original
|
|
444
|
+
let backup_path = settings_path.with_extension("json.bak");
|
|
445
|
+
fs::copy(&settings_path, &backup_path)
|
|
446
|
+
.with_context(|| format!("Failed to backup to {}", backup_path.display()))?;
|
|
447
|
+
|
|
448
|
+
// Atomic write
|
|
449
|
+
let serialized =
|
|
450
|
+
serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?;
|
|
451
|
+
atomic_write(&settings_path, &serialized)?;
|
|
452
|
+
|
|
453
|
+
if verbose > 0 {
|
|
454
|
+
eprintln!("Removed RTK hook from settings.json");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
Ok(removed)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/// Full uninstall: remove hook, RTK.md, @RTK.md reference, settings.json entry
|
|
462
|
+
pub fn uninstall(global: bool, verbose: u8) -> Result<()> {
|
|
463
|
+
if !global {
|
|
464
|
+
anyhow::bail!("Uninstall only works with --global flag. For local projects, manually remove RTK from CLAUDE.md");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let claude_dir = resolve_claude_dir()?;
|
|
468
|
+
let mut removed = Vec::new();
|
|
469
|
+
|
|
470
|
+
// 1. Remove hook file
|
|
471
|
+
let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh");
|
|
472
|
+
if hook_path.exists() {
|
|
473
|
+
fs::remove_file(&hook_path)
|
|
474
|
+
.with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?;
|
|
475
|
+
removed.push(format!("Hook: {}", hook_path.display()));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 1b. Remove integrity hash file
|
|
479
|
+
if integrity::remove_hash(&hook_path)? {
|
|
480
|
+
removed.push("Integrity hash: removed".to_string());
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 2. Remove RTK.md
|
|
484
|
+
let rtk_md_path = claude_dir.join("RTK.md");
|
|
485
|
+
if rtk_md_path.exists() {
|
|
486
|
+
fs::remove_file(&rtk_md_path)
|
|
487
|
+
.with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?;
|
|
488
|
+
removed.push(format!("RTK.md: {}", rtk_md_path.display()));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 3. Remove @RTK.md reference from CLAUDE.md
|
|
492
|
+
let claude_md_path = claude_dir.join("CLAUDE.md");
|
|
493
|
+
if claude_md_path.exists() {
|
|
494
|
+
let content = fs::read_to_string(&claude_md_path)
|
|
495
|
+
.with_context(|| format!("Failed to read CLAUDE.md: {}", claude_md_path.display()))?;
|
|
496
|
+
|
|
497
|
+
if content.contains("@RTK.md") {
|
|
498
|
+
let new_content = content
|
|
499
|
+
.lines()
|
|
500
|
+
.filter(|line| !line.trim().starts_with("@RTK.md"))
|
|
501
|
+
.collect::<Vec<_>>()
|
|
502
|
+
.join("\n");
|
|
503
|
+
|
|
504
|
+
// Clean up double blanks
|
|
505
|
+
let cleaned = clean_double_blanks(&new_content);
|
|
506
|
+
|
|
507
|
+
fs::write(&claude_md_path, cleaned).with_context(|| {
|
|
508
|
+
format!("Failed to write CLAUDE.md: {}", claude_md_path.display())
|
|
509
|
+
})?;
|
|
510
|
+
removed.push(format!("CLAUDE.md: removed @RTK.md reference"));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 4. Remove hook entry from settings.json
|
|
515
|
+
if remove_hook_from_settings(verbose)? {
|
|
516
|
+
removed.push("settings.json: removed RTK hook entry".to_string());
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 5. Remove OpenCode plugin
|
|
520
|
+
let opencode_removed = remove_opencode_plugin(verbose)?;
|
|
521
|
+
for path in opencode_removed {
|
|
522
|
+
removed.push(format!("OpenCode plugin: {}", path.display()));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Report results
|
|
526
|
+
if removed.is_empty() {
|
|
527
|
+
println!("RTK was not installed (nothing to remove)");
|
|
528
|
+
} else {
|
|
529
|
+
println!("RTK uninstalled:");
|
|
530
|
+
for item in removed {
|
|
531
|
+
println!(" - {}", item);
|
|
532
|
+
}
|
|
533
|
+
println!("\nRestart Claude Code and OpenCode (if used) to apply changes.");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
Ok(())
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/// Orchestrator: patch settings.json with RTK hook
|
|
540
|
+
/// Handles reading, checking, prompting, merging, backing up, and atomic writing
|
|
541
|
+
fn patch_settings_json(
|
|
542
|
+
hook_path: &Path,
|
|
543
|
+
mode: PatchMode,
|
|
544
|
+
verbose: u8,
|
|
545
|
+
include_opencode: bool,
|
|
546
|
+
) -> Result<PatchResult> {
|
|
547
|
+
let claude_dir = resolve_claude_dir()?;
|
|
548
|
+
let settings_path = claude_dir.join("settings.json");
|
|
549
|
+
let hook_command = hook_path
|
|
550
|
+
.to_str()
|
|
551
|
+
.context("Hook path contains invalid UTF-8")?;
|
|
552
|
+
|
|
553
|
+
// Read or create settings.json
|
|
554
|
+
let mut root = if settings_path.exists() {
|
|
555
|
+
let content = fs::read_to_string(&settings_path)
|
|
556
|
+
.with_context(|| format!("Failed to read {}", settings_path.display()))?;
|
|
557
|
+
|
|
558
|
+
if content.trim().is_empty() {
|
|
559
|
+
serde_json::json!({})
|
|
560
|
+
} else {
|
|
561
|
+
serde_json::from_str(&content)
|
|
562
|
+
.with_context(|| format!("Failed to parse {} as JSON", settings_path.display()))?
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
serde_json::json!({})
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// Check idempotency
|
|
569
|
+
if hook_already_present(&root, &hook_command) {
|
|
570
|
+
if verbose > 0 {
|
|
571
|
+
eprintln!("settings.json: hook already present");
|
|
572
|
+
}
|
|
573
|
+
return Ok(PatchResult::AlreadyPresent);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Handle mode
|
|
577
|
+
match mode {
|
|
578
|
+
PatchMode::Skip => {
|
|
579
|
+
print_manual_instructions(hook_path, include_opencode);
|
|
580
|
+
return Ok(PatchResult::Skipped);
|
|
581
|
+
}
|
|
582
|
+
PatchMode::Ask => {
|
|
583
|
+
if !prompt_user_consent(&settings_path)? {
|
|
584
|
+
print_manual_instructions(hook_path, include_opencode);
|
|
585
|
+
return Ok(PatchResult::Declined);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
PatchMode::Auto => {
|
|
589
|
+
// Proceed without prompting
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Deep-merge hook
|
|
594
|
+
insert_hook_entry(&mut root, &hook_command);
|
|
595
|
+
|
|
596
|
+
// Backup original
|
|
597
|
+
if settings_path.exists() {
|
|
598
|
+
let backup_path = settings_path.with_extension("json.bak");
|
|
599
|
+
fs::copy(&settings_path, &backup_path)
|
|
600
|
+
.with_context(|| format!("Failed to backup to {}", backup_path.display()))?;
|
|
601
|
+
if verbose > 0 {
|
|
602
|
+
eprintln!("Backup: {}", backup_path.display());
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Atomic write
|
|
607
|
+
let serialized =
|
|
608
|
+
serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?;
|
|
609
|
+
atomic_write(&settings_path, &serialized)?;
|
|
610
|
+
|
|
611
|
+
println!("\n settings.json: hook added");
|
|
612
|
+
if settings_path.with_extension("json.bak").exists() {
|
|
613
|
+
println!(
|
|
614
|
+
" Backup: {}",
|
|
615
|
+
settings_path.with_extension("json.bak").display()
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
if include_opencode {
|
|
619
|
+
println!(" Restart Claude Code and OpenCode. Test with: git status");
|
|
620
|
+
} else {
|
|
621
|
+
println!(" Restart Claude Code. Test with: git status");
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
Ok(PatchResult::Patched)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/// Clean up consecutive blank lines (collapse 3+ to 2)
|
|
628
|
+
/// Used when removing @RTK.md line from CLAUDE.md
|
|
629
|
+
fn clean_double_blanks(content: &str) -> String {
|
|
630
|
+
let lines: Vec<&str> = content.lines().collect();
|
|
631
|
+
let mut result = Vec::new();
|
|
632
|
+
let mut i = 0;
|
|
633
|
+
|
|
634
|
+
while i < lines.len() {
|
|
635
|
+
let line = lines[i];
|
|
636
|
+
|
|
637
|
+
if line.trim().is_empty() {
|
|
638
|
+
// Count consecutive blank lines
|
|
639
|
+
let mut blank_count = 0;
|
|
640
|
+
let start = i;
|
|
641
|
+
while i < lines.len() && lines[i].trim().is_empty() {
|
|
642
|
+
blank_count += 1;
|
|
643
|
+
i += 1;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Keep at most 2 blank lines
|
|
647
|
+
let keep = blank_count.min(2);
|
|
648
|
+
for _ in 0..keep {
|
|
649
|
+
result.push("");
|
|
650
|
+
}
|
|
651
|
+
} else {
|
|
652
|
+
result.push(line);
|
|
653
|
+
i += 1;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
result.join("\n")
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/// Deep-merge RTK hook entry into settings.json
|
|
661
|
+
/// Creates hooks.PreToolUse structure if missing, preserves existing hooks
|
|
662
|
+
fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) {
|
|
663
|
+
// Ensure root is an object
|
|
664
|
+
let root_obj = match root.as_object_mut() {
|
|
665
|
+
Some(obj) => obj,
|
|
666
|
+
None => {
|
|
667
|
+
*root = serde_json::json!({});
|
|
668
|
+
root.as_object_mut()
|
|
669
|
+
.expect("Just created object, must succeed")
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
// Use entry() API for idiomatic insertion
|
|
674
|
+
let hooks = root_obj
|
|
675
|
+
.entry("hooks")
|
|
676
|
+
.or_insert_with(|| serde_json::json!({}))
|
|
677
|
+
.as_object_mut()
|
|
678
|
+
.expect("hooks must be an object");
|
|
679
|
+
|
|
680
|
+
let pre_tool_use = hooks
|
|
681
|
+
.entry("PreToolUse")
|
|
682
|
+
.or_insert_with(|| serde_json::json!([]))
|
|
683
|
+
.as_array_mut()
|
|
684
|
+
.expect("PreToolUse must be an array");
|
|
685
|
+
|
|
686
|
+
// Append RTK hook entry
|
|
687
|
+
pre_tool_use.push(serde_json::json!({
|
|
688
|
+
"matcher": "Bash",
|
|
689
|
+
"hooks": [{
|
|
690
|
+
"type": "command",
|
|
691
|
+
"command": hook_command
|
|
692
|
+
}]
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/// Check if RTK hook is already present in settings.json
|
|
697
|
+
/// Matches on rtk-rewrite.sh substring to handle different path formats
|
|
698
|
+
fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool {
|
|
699
|
+
let pre_tool_use_array = match root
|
|
700
|
+
.get("hooks")
|
|
701
|
+
.and_then(|h| h.get("PreToolUse"))
|
|
702
|
+
.and_then(|p| p.as_array())
|
|
703
|
+
{
|
|
704
|
+
Some(arr) => arr,
|
|
705
|
+
None => return false,
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
pre_tool_use_array
|
|
709
|
+
.iter()
|
|
710
|
+
.filter_map(|entry| entry.get("hooks")?.as_array())
|
|
711
|
+
.flatten()
|
|
712
|
+
.filter_map(|hook| hook.get("command")?.as_str())
|
|
713
|
+
.any(|cmd| {
|
|
714
|
+
// Exact match OR both contain rtk-rewrite.sh
|
|
715
|
+
cmd == hook_command
|
|
716
|
+
|| (cmd.contains("rtk-rewrite.sh") && hook_command.contains("rtk-rewrite.sh"))
|
|
717
|
+
})
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/// Default mode: hook + slim RTK.md + @RTK.md reference
|
|
721
|
+
#[cfg(not(unix))]
|
|
722
|
+
fn run_default_mode(
|
|
723
|
+
_global: bool,
|
|
724
|
+
_patch_mode: PatchMode,
|
|
725
|
+
_verbose: u8,
|
|
726
|
+
_install_opencode: bool,
|
|
727
|
+
) -> Result<()> {
|
|
728
|
+
eprintln!("⚠️ Hook-based mode requires Unix (macOS/Linux).");
|
|
729
|
+
eprintln!(" Windows: use --claude-md mode for full injection.");
|
|
730
|
+
eprintln!(" Falling back to --claude-md mode.");
|
|
731
|
+
run_claude_md_mode(_global, _verbose, _install_opencode)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
#[cfg(unix)]
|
|
735
|
+
fn run_default_mode(
|
|
736
|
+
global: bool,
|
|
737
|
+
patch_mode: PatchMode,
|
|
738
|
+
verbose: u8,
|
|
739
|
+
install_opencode: bool,
|
|
740
|
+
) -> Result<()> {
|
|
741
|
+
if !global {
|
|
742
|
+
// Local init: inject CLAUDE.md + generate project-local filters template
|
|
743
|
+
run_claude_md_mode(false, verbose, install_opencode)?;
|
|
744
|
+
generate_project_filters_template(verbose)?;
|
|
745
|
+
return Ok(());
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
let claude_dir = resolve_claude_dir()?;
|
|
749
|
+
let rtk_md_path = claude_dir.join("RTK.md");
|
|
750
|
+
let claude_md_path = claude_dir.join("CLAUDE.md");
|
|
751
|
+
|
|
752
|
+
// 1. Prepare hook directory and install hook
|
|
753
|
+
let (_hook_dir, hook_path) = prepare_hook_paths()?;
|
|
754
|
+
let hook_changed = ensure_hook_installed(&hook_path, verbose)?;
|
|
755
|
+
|
|
756
|
+
// 2. Write RTK.md
|
|
757
|
+
write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?;
|
|
758
|
+
|
|
759
|
+
let opencode_plugin_path = if install_opencode {
|
|
760
|
+
let path = prepare_opencode_plugin_path()?;
|
|
761
|
+
ensure_opencode_plugin_installed(&path, verbose)?;
|
|
762
|
+
Some(path)
|
|
763
|
+
} else {
|
|
764
|
+
None
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
// 3. Patch CLAUDE.md (add @RTK.md, migrate if needed)
|
|
768
|
+
let migrated = patch_claude_md(&claude_md_path, verbose)?;
|
|
769
|
+
|
|
770
|
+
// 4. Print success message
|
|
771
|
+
let hook_status = if hook_changed {
|
|
772
|
+
"installed/updated"
|
|
773
|
+
} else {
|
|
774
|
+
"already up to date"
|
|
775
|
+
};
|
|
776
|
+
println!("\nRTK hook {} (global).\n", hook_status);
|
|
777
|
+
println!(" Hook: {}", hook_path.display());
|
|
778
|
+
println!(" RTK.md: {} (10 lines)", rtk_md_path.display());
|
|
779
|
+
if let Some(path) = &opencode_plugin_path {
|
|
780
|
+
println!(" OpenCode: {}", path.display());
|
|
781
|
+
}
|
|
782
|
+
println!(" CLAUDE.md: @RTK.md reference added");
|
|
783
|
+
|
|
784
|
+
if migrated {
|
|
785
|
+
println!("\n ✅ Migrated: removed 137-line RTK block from CLAUDE.md");
|
|
786
|
+
println!(" replaced with @RTK.md (10 lines)");
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// 5. Patch settings.json
|
|
790
|
+
let patch_result = patch_settings_json(&hook_path, patch_mode, verbose, install_opencode)?;
|
|
791
|
+
|
|
792
|
+
// Report result
|
|
793
|
+
match patch_result {
|
|
794
|
+
PatchResult::Patched => {
|
|
795
|
+
// Already printed by patch_settings_json
|
|
796
|
+
}
|
|
797
|
+
PatchResult::AlreadyPresent => {
|
|
798
|
+
println!("\n settings.json: hook already present");
|
|
799
|
+
if install_opencode {
|
|
800
|
+
println!(" Restart Claude Code and OpenCode. Test with: git status");
|
|
801
|
+
} else {
|
|
802
|
+
println!(" Restart Claude Code. Test with: git status");
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
PatchResult::Declined | PatchResult::Skipped => {
|
|
806
|
+
// Manual instructions already printed by patch_settings_json
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// 6. Generate user-global filters template (~/.config/rtk/filters.toml)
|
|
811
|
+
generate_global_filters_template(verbose)?;
|
|
812
|
+
|
|
813
|
+
println!(); // Final newline
|
|
814
|
+
|
|
815
|
+
Ok(())
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/// Generate .rtk/filters.toml template in the current directory if not present.
|
|
819
|
+
fn generate_project_filters_template(verbose: u8) -> Result<()> {
|
|
820
|
+
let rtk_dir = std::path::Path::new(".rtk");
|
|
821
|
+
let path = rtk_dir.join("filters.toml");
|
|
822
|
+
|
|
823
|
+
if path.exists() {
|
|
824
|
+
if verbose > 0 {
|
|
825
|
+
eprintln!(".rtk/filters.toml already exists, skipping template");
|
|
826
|
+
}
|
|
827
|
+
return Ok(());
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
fs::create_dir_all(rtk_dir)
|
|
831
|
+
.with_context(|| format!("Failed to create directory: {}", rtk_dir.display()))?;
|
|
832
|
+
fs::write(&path, FILTERS_TEMPLATE)
|
|
833
|
+
.with_context(|| format!("Failed to write {}", path.display()))?;
|
|
834
|
+
|
|
835
|
+
println!(
|
|
836
|
+
" filters: {} (template, edit to add project filters)",
|
|
837
|
+
path.display()
|
|
838
|
+
);
|
|
839
|
+
Ok(())
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/// Generate ~/.config/rtk/filters.toml template if not present.
|
|
843
|
+
fn generate_global_filters_template(verbose: u8) -> Result<()> {
|
|
844
|
+
let config_dir = dirs::config_dir().unwrap_or_else(|| std::path::PathBuf::from(".config"));
|
|
845
|
+
let rtk_dir = config_dir.join("rtk");
|
|
846
|
+
let path = rtk_dir.join("filters.toml");
|
|
847
|
+
|
|
848
|
+
if path.exists() {
|
|
849
|
+
if verbose > 0 {
|
|
850
|
+
eprintln!("{} already exists, skipping template", path.display());
|
|
851
|
+
}
|
|
852
|
+
return Ok(());
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
fs::create_dir_all(&rtk_dir)
|
|
856
|
+
.with_context(|| format!("Failed to create directory: {}", rtk_dir.display()))?;
|
|
857
|
+
fs::write(&path, FILTERS_GLOBAL_TEMPLATE)
|
|
858
|
+
.with_context(|| format!("Failed to write {}", path.display()))?;
|
|
859
|
+
|
|
860
|
+
println!(
|
|
861
|
+
" filters: {} (template, edit to add user-global filters)",
|
|
862
|
+
path.display()
|
|
863
|
+
);
|
|
864
|
+
Ok(())
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/// Hook-only mode: just the hook, no RTK.md
|
|
868
|
+
#[cfg(not(unix))]
|
|
869
|
+
fn run_hook_only_mode(
|
|
870
|
+
_global: bool,
|
|
871
|
+
_patch_mode: PatchMode,
|
|
872
|
+
_verbose: u8,
|
|
873
|
+
_install_opencode: bool,
|
|
874
|
+
) -> Result<()> {
|
|
875
|
+
anyhow::bail!("Hook install requires Unix (macOS/Linux). Use WSL or --claude-md mode.")
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
#[cfg(unix)]
|
|
879
|
+
fn run_hook_only_mode(
|
|
880
|
+
global: bool,
|
|
881
|
+
patch_mode: PatchMode,
|
|
882
|
+
verbose: u8,
|
|
883
|
+
install_opencode: bool,
|
|
884
|
+
) -> Result<()> {
|
|
885
|
+
if !global {
|
|
886
|
+
eprintln!("⚠️ Warning: --hook-only only makes sense with --global");
|
|
887
|
+
eprintln!(" For local projects, use default mode or --claude-md");
|
|
888
|
+
return Ok(());
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Prepare and install hook
|
|
892
|
+
let (_hook_dir, hook_path) = prepare_hook_paths()?;
|
|
893
|
+
let hook_changed = ensure_hook_installed(&hook_path, verbose)?;
|
|
894
|
+
|
|
895
|
+
let opencode_plugin_path = if install_opencode {
|
|
896
|
+
let path = prepare_opencode_plugin_path()?;
|
|
897
|
+
ensure_opencode_plugin_installed(&path, verbose)?;
|
|
898
|
+
Some(path)
|
|
899
|
+
} else {
|
|
900
|
+
None
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
let hook_status = if hook_changed {
|
|
904
|
+
"installed/updated"
|
|
905
|
+
} else {
|
|
906
|
+
"already up to date"
|
|
907
|
+
};
|
|
908
|
+
println!("\nRTK hook {} (hook-only mode).\n", hook_status);
|
|
909
|
+
println!(" Hook: {}", hook_path.display());
|
|
910
|
+
if let Some(path) = &opencode_plugin_path {
|
|
911
|
+
println!(" OpenCode: {}", path.display());
|
|
912
|
+
}
|
|
913
|
+
println!(
|
|
914
|
+
" Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)."
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
// Patch settings.json
|
|
918
|
+
let patch_result = patch_settings_json(&hook_path, patch_mode, verbose, install_opencode)?;
|
|
919
|
+
|
|
920
|
+
// Report result
|
|
921
|
+
match patch_result {
|
|
922
|
+
PatchResult::Patched => {
|
|
923
|
+
// Already printed by patch_settings_json
|
|
924
|
+
}
|
|
925
|
+
PatchResult::AlreadyPresent => {
|
|
926
|
+
println!("\n settings.json: hook already present");
|
|
927
|
+
if install_opencode {
|
|
928
|
+
println!(" Restart Claude Code and OpenCode. Test with: git status");
|
|
929
|
+
} else {
|
|
930
|
+
println!(" Restart Claude Code. Test with: git status");
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
PatchResult::Declined | PatchResult::Skipped => {
|
|
934
|
+
// Manual instructions already printed by patch_settings_json
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
println!(); // Final newline
|
|
939
|
+
|
|
940
|
+
Ok(())
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/// Legacy mode: full 137-line injection into CLAUDE.md
|
|
944
|
+
fn run_claude_md_mode(global: bool, verbose: u8, install_opencode: bool) -> Result<()> {
|
|
945
|
+
let path = if global {
|
|
946
|
+
resolve_claude_dir()?.join("CLAUDE.md")
|
|
947
|
+
} else {
|
|
948
|
+
PathBuf::from("CLAUDE.md")
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
if global {
|
|
952
|
+
if let Some(parent) = path.parent() {
|
|
953
|
+
fs::create_dir_all(parent)?;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if verbose > 0 {
|
|
958
|
+
eprintln!("Writing rtk instructions to: {}", path.display());
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if path.exists() {
|
|
962
|
+
let existing = fs::read_to_string(&path)?;
|
|
963
|
+
// upsert_rtk_block handles all 4 cases: add, update, unchanged, malformed
|
|
964
|
+
let (new_content, action) = upsert_rtk_block(&existing, RTK_INSTRUCTIONS);
|
|
965
|
+
|
|
966
|
+
match action {
|
|
967
|
+
RtkBlockUpsert::Added => {
|
|
968
|
+
fs::write(&path, new_content)?;
|
|
969
|
+
println!("✅ Added rtk instructions to existing {}", path.display());
|
|
970
|
+
}
|
|
971
|
+
RtkBlockUpsert::Updated => {
|
|
972
|
+
fs::write(&path, new_content)?;
|
|
973
|
+
println!("✅ Updated rtk instructions in {}", path.display());
|
|
974
|
+
}
|
|
975
|
+
RtkBlockUpsert::Unchanged => {
|
|
976
|
+
println!(
|
|
977
|
+
"✅ {} already contains up-to-date rtk instructions",
|
|
978
|
+
path.display()
|
|
979
|
+
);
|
|
980
|
+
return Ok(());
|
|
981
|
+
}
|
|
982
|
+
RtkBlockUpsert::Malformed => {
|
|
983
|
+
eprintln!(
|
|
984
|
+
"⚠️ Warning: Found '<!-- rtk-instructions' without closing marker in {}",
|
|
985
|
+
path.display()
|
|
986
|
+
);
|
|
987
|
+
|
|
988
|
+
if let Some((line_num, _)) = existing
|
|
989
|
+
.lines()
|
|
990
|
+
.enumerate()
|
|
991
|
+
.find(|(_, line)| line.contains("<!-- rtk-instructions"))
|
|
992
|
+
{
|
|
993
|
+
eprintln!(" Location: line {}", line_num + 1);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
eprintln!(" Action: Manually remove the incomplete block, then re-run:");
|
|
997
|
+
if global {
|
|
998
|
+
eprintln!(" rtk init -g --claude-md");
|
|
999
|
+
} else {
|
|
1000
|
+
eprintln!(" rtk init --claude-md");
|
|
1001
|
+
}
|
|
1002
|
+
return Ok(());
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
} else {
|
|
1006
|
+
fs::write(&path, RTK_INSTRUCTIONS)?;
|
|
1007
|
+
println!("✅ Created {} with rtk instructions", path.display());
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if global {
|
|
1011
|
+
if install_opencode {
|
|
1012
|
+
let opencode_plugin_path = prepare_opencode_plugin_path()?;
|
|
1013
|
+
ensure_opencode_plugin_installed(&opencode_plugin_path, verbose)?;
|
|
1014
|
+
println!(
|
|
1015
|
+
"✅ OpenCode plugin installed: {}",
|
|
1016
|
+
opencode_plugin_path.display()
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
println!(" Claude Code will now use rtk in all sessions");
|
|
1020
|
+
} else {
|
|
1021
|
+
println!(" Claude Code will use rtk in this project");
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
Ok(())
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// --- upsert_rtk_block: idempotent RTK block management ---
|
|
1028
|
+
|
|
1029
|
+
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
1030
|
+
enum RtkBlockUpsert {
|
|
1031
|
+
/// No existing block found — appended new block
|
|
1032
|
+
Added,
|
|
1033
|
+
/// Existing block found with different content — replaced
|
|
1034
|
+
Updated,
|
|
1035
|
+
/// Existing block found with identical content — no-op
|
|
1036
|
+
Unchanged,
|
|
1037
|
+
/// Opening marker found without closing marker — not safe to rewrite
|
|
1038
|
+
Malformed,
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/// Insert or replace the RTK instructions block in `content`.
|
|
1042
|
+
///
|
|
1043
|
+
/// Returns `(new_content, action)` describing what happened.
|
|
1044
|
+
/// The caller decides whether to write `new_content` based on `action`.
|
|
1045
|
+
fn upsert_rtk_block(content: &str, block: &str) -> (String, RtkBlockUpsert) {
|
|
1046
|
+
let start_marker = "<!-- rtk-instructions";
|
|
1047
|
+
let end_marker = "<!-- /rtk-instructions -->";
|
|
1048
|
+
|
|
1049
|
+
if let Some(start) = content.find(start_marker) {
|
|
1050
|
+
if let Some(relative_end) = content[start..].find(end_marker) {
|
|
1051
|
+
let end = start + relative_end;
|
|
1052
|
+
let end_pos = end + end_marker.len();
|
|
1053
|
+
let current_block = content[start..end_pos].trim();
|
|
1054
|
+
let desired_block = block.trim();
|
|
1055
|
+
|
|
1056
|
+
if current_block == desired_block {
|
|
1057
|
+
return (content.to_string(), RtkBlockUpsert::Unchanged);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Replace stale block with desired block
|
|
1061
|
+
let before = content[..start].trim_end();
|
|
1062
|
+
let after = content[end_pos..].trim_start();
|
|
1063
|
+
|
|
1064
|
+
let result = match (before.is_empty(), after.is_empty()) {
|
|
1065
|
+
(true, true) => desired_block.to_string(),
|
|
1066
|
+
(true, false) => format!("{desired_block}\n\n{after}"),
|
|
1067
|
+
(false, true) => format!("{before}\n\n{desired_block}"),
|
|
1068
|
+
(false, false) => format!("{before}\n\n{desired_block}\n\n{after}"),
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
return (result, RtkBlockUpsert::Updated);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Opening marker without closing marker — malformed
|
|
1075
|
+
return (content.to_string(), RtkBlockUpsert::Malformed);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// No existing block — append
|
|
1079
|
+
let trimmed = content.trim();
|
|
1080
|
+
if trimmed.is_empty() {
|
|
1081
|
+
(block.to_string(), RtkBlockUpsert::Added)
|
|
1082
|
+
} else {
|
|
1083
|
+
(
|
|
1084
|
+
format!("{trimmed}\n\n{}", block.trim()),
|
|
1085
|
+
RtkBlockUpsert::Added,
|
|
1086
|
+
)
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/// Patch CLAUDE.md: add @RTK.md, migrate if old block exists
|
|
1091
|
+
fn patch_claude_md(path: &Path, verbose: u8) -> Result<bool> {
|
|
1092
|
+
let mut content = if path.exists() {
|
|
1093
|
+
fs::read_to_string(path)?
|
|
1094
|
+
} else {
|
|
1095
|
+
String::new()
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
let mut migrated = false;
|
|
1099
|
+
|
|
1100
|
+
// Check for old block and migrate
|
|
1101
|
+
if content.contains("<!-- rtk-instructions") {
|
|
1102
|
+
let (new_content, did_migrate) = remove_rtk_block(&content);
|
|
1103
|
+
if did_migrate {
|
|
1104
|
+
content = new_content;
|
|
1105
|
+
migrated = true;
|
|
1106
|
+
if verbose > 0 {
|
|
1107
|
+
eprintln!("Migrated: removed old RTK block from CLAUDE.md");
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Check if @RTK.md already present
|
|
1113
|
+
if content.contains("@RTK.md") {
|
|
1114
|
+
if verbose > 0 {
|
|
1115
|
+
eprintln!("@RTK.md reference already present in CLAUDE.md");
|
|
1116
|
+
}
|
|
1117
|
+
if migrated {
|
|
1118
|
+
fs::write(path, content)?;
|
|
1119
|
+
}
|
|
1120
|
+
return Ok(migrated);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Add @RTK.md
|
|
1124
|
+
let new_content = if content.is_empty() {
|
|
1125
|
+
"@RTK.md\n".to_string()
|
|
1126
|
+
} else {
|
|
1127
|
+
format!("{}\n\n@RTK.md\n", content.trim())
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
fs::write(path, new_content)?;
|
|
1131
|
+
|
|
1132
|
+
if verbose > 0 {
|
|
1133
|
+
eprintln!("Added @RTK.md reference to CLAUDE.md");
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
Ok(migrated)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/// Remove old RTK block from CLAUDE.md (migration helper)
|
|
1140
|
+
fn remove_rtk_block(content: &str) -> (String, bool) {
|
|
1141
|
+
if let (Some(start), Some(end)) = (
|
|
1142
|
+
content.find("<!-- rtk-instructions"),
|
|
1143
|
+
content.find("<!-- /rtk-instructions -->"),
|
|
1144
|
+
) {
|
|
1145
|
+
let end_pos = end + "<!-- /rtk-instructions -->".len();
|
|
1146
|
+
let before = content[..start].trim_end();
|
|
1147
|
+
let after = content[end_pos..].trim_start();
|
|
1148
|
+
|
|
1149
|
+
let result = if after.is_empty() {
|
|
1150
|
+
before.to_string()
|
|
1151
|
+
} else {
|
|
1152
|
+
format!("{}\n\n{}", before, after)
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
(result, true) // migrated
|
|
1156
|
+
} else if content.contains("<!-- rtk-instructions") {
|
|
1157
|
+
eprintln!("⚠️ Warning: Found '<!-- rtk-instructions' without closing marker.");
|
|
1158
|
+
eprintln!(" This can happen if CLAUDE.md was manually edited.");
|
|
1159
|
+
|
|
1160
|
+
// Find line number
|
|
1161
|
+
if let Some((line_num, _)) = content
|
|
1162
|
+
.lines()
|
|
1163
|
+
.enumerate()
|
|
1164
|
+
.find(|(_, line)| line.contains("<!-- rtk-instructions"))
|
|
1165
|
+
{
|
|
1166
|
+
eprintln!(" Location: line {}", line_num + 1);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
eprintln!(" Action: Manually remove the incomplete block, then re-run:");
|
|
1170
|
+
eprintln!(" rtk init -g");
|
|
1171
|
+
(content.to_string(), false)
|
|
1172
|
+
} else {
|
|
1173
|
+
(content.to_string(), false)
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/// Resolve ~/.claude directory with proper home expansion
|
|
1178
|
+
fn resolve_claude_dir() -> Result<PathBuf> {
|
|
1179
|
+
dirs::home_dir()
|
|
1180
|
+
.map(|h| h.join(".claude"))
|
|
1181
|
+
.context("Cannot determine home directory. Is $HOME set?")
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/// Resolve OpenCode config directory (~/.config/opencode)
|
|
1185
|
+
/// OpenCode uses ~/.config/opencode on all platforms (XDG convention),
|
|
1186
|
+
/// NOT the macOS-native ~/Library/Application Support/.
|
|
1187
|
+
fn resolve_opencode_dir() -> Result<PathBuf> {
|
|
1188
|
+
dirs::home_dir()
|
|
1189
|
+
.map(|h| h.join(".config").join("opencode"))
|
|
1190
|
+
.context("Cannot determine home directory. Is $HOME set?")
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/// Return OpenCode plugin path: ~/.config/opencode/plugins/rtk.ts
|
|
1194
|
+
fn opencode_plugin_path(opencode_dir: &Path) -> PathBuf {
|
|
1195
|
+
opencode_dir.join("plugins").join("rtk.ts")
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/// Prepare OpenCode plugin directory and return install path
|
|
1199
|
+
fn prepare_opencode_plugin_path() -> Result<PathBuf> {
|
|
1200
|
+
let opencode_dir = resolve_opencode_dir()?;
|
|
1201
|
+
let path = opencode_plugin_path(&opencode_dir);
|
|
1202
|
+
if let Some(parent) = path.parent() {
|
|
1203
|
+
fs::create_dir_all(parent).with_context(|| {
|
|
1204
|
+
format!(
|
|
1205
|
+
"Failed to create OpenCode plugin directory: {}",
|
|
1206
|
+
parent.display()
|
|
1207
|
+
)
|
|
1208
|
+
})?;
|
|
1209
|
+
}
|
|
1210
|
+
Ok(path)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/// Write OpenCode plugin file if missing or outdated
|
|
1214
|
+
fn ensure_opencode_plugin_installed(path: &Path, verbose: u8) -> Result<bool> {
|
|
1215
|
+
write_if_changed(path, OPENCODE_PLUGIN, "OpenCode plugin", verbose)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/// Remove OpenCode plugin file
|
|
1219
|
+
fn remove_opencode_plugin(verbose: u8) -> Result<Vec<PathBuf>> {
|
|
1220
|
+
let opencode_dir = resolve_opencode_dir()?;
|
|
1221
|
+
let path = opencode_plugin_path(&opencode_dir);
|
|
1222
|
+
let mut removed = Vec::new();
|
|
1223
|
+
|
|
1224
|
+
if path.exists() {
|
|
1225
|
+
fs::remove_file(&path)
|
|
1226
|
+
.with_context(|| format!("Failed to remove OpenCode plugin: {}", path.display()))?;
|
|
1227
|
+
if verbose > 0 {
|
|
1228
|
+
eprintln!("Removed OpenCode plugin: {}", path.display());
|
|
1229
|
+
}
|
|
1230
|
+
removed.push(path);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
Ok(removed)
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/// Show current rtk configuration
|
|
1237
|
+
pub fn show_config() -> Result<()> {
|
|
1238
|
+
let claude_dir = resolve_claude_dir()?;
|
|
1239
|
+
let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh");
|
|
1240
|
+
let rtk_md_path = claude_dir.join("RTK.md");
|
|
1241
|
+
let global_claude_md = claude_dir.join("CLAUDE.md");
|
|
1242
|
+
let local_claude_md = PathBuf::from("CLAUDE.md");
|
|
1243
|
+
|
|
1244
|
+
println!("📋 rtk Configuration:\n");
|
|
1245
|
+
|
|
1246
|
+
// Check hook
|
|
1247
|
+
if hook_path.exists() {
|
|
1248
|
+
#[cfg(unix)]
|
|
1249
|
+
{
|
|
1250
|
+
use std::os::unix::fs::PermissionsExt;
|
|
1251
|
+
let metadata = fs::metadata(&hook_path)?;
|
|
1252
|
+
let perms = metadata.permissions();
|
|
1253
|
+
let is_executable = perms.mode() & 0o111 != 0;
|
|
1254
|
+
|
|
1255
|
+
let hook_content = fs::read_to_string(&hook_path)?;
|
|
1256
|
+
let has_guards =
|
|
1257
|
+
hook_content.contains("command -v rtk") && hook_content.contains("command -v jq");
|
|
1258
|
+
let is_thin_delegator = hook_content.contains("rtk rewrite");
|
|
1259
|
+
let hook_version = crate::hook_check::parse_hook_version(&hook_content);
|
|
1260
|
+
|
|
1261
|
+
if !is_executable {
|
|
1262
|
+
println!(
|
|
1263
|
+
"⚠️ Hook: {} (NOT executable - run: chmod +x)",
|
|
1264
|
+
hook_path.display()
|
|
1265
|
+
);
|
|
1266
|
+
} else if !is_thin_delegator {
|
|
1267
|
+
println!(
|
|
1268
|
+
"⚠️ Hook: {} (outdated — inline logic, not thin delegator)",
|
|
1269
|
+
hook_path.display()
|
|
1270
|
+
);
|
|
1271
|
+
println!(
|
|
1272
|
+
" → Run `rtk init --global` to upgrade to the single source of truth hook"
|
|
1273
|
+
);
|
|
1274
|
+
} else if is_executable && has_guards {
|
|
1275
|
+
println!(
|
|
1276
|
+
"✅ Hook: {} (thin delegator, version {})",
|
|
1277
|
+
hook_path.display(),
|
|
1278
|
+
hook_version
|
|
1279
|
+
);
|
|
1280
|
+
} else {
|
|
1281
|
+
println!("⚠️ Hook: {} (no guards - outdated)", hook_path.display());
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
#[cfg(not(unix))]
|
|
1286
|
+
{
|
|
1287
|
+
println!("✅ Hook: {} (exists)", hook_path.display());
|
|
1288
|
+
}
|
|
1289
|
+
} else {
|
|
1290
|
+
println!("⚪ Hook: not found");
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Check RTK.md
|
|
1294
|
+
if rtk_md_path.exists() {
|
|
1295
|
+
println!("✅ RTK.md: {} (slim mode)", rtk_md_path.display());
|
|
1296
|
+
} else {
|
|
1297
|
+
println!("⚪ RTK.md: not found");
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Check hook integrity
|
|
1301
|
+
match integrity::verify_hook_at(&hook_path) {
|
|
1302
|
+
Ok(integrity::IntegrityStatus::Verified) => {
|
|
1303
|
+
println!("✅ Integrity: hook hash verified");
|
|
1304
|
+
}
|
|
1305
|
+
Ok(integrity::IntegrityStatus::Tampered { .. }) => {
|
|
1306
|
+
println!("❌ Integrity: hook modified outside rtk init (run: rtk verify)");
|
|
1307
|
+
}
|
|
1308
|
+
Ok(integrity::IntegrityStatus::NoBaseline) => {
|
|
1309
|
+
println!("⚠️ Integrity: no baseline hash (run: rtk init -g to establish)");
|
|
1310
|
+
}
|
|
1311
|
+
Ok(integrity::IntegrityStatus::NotInstalled)
|
|
1312
|
+
| Ok(integrity::IntegrityStatus::OrphanedHash) => {
|
|
1313
|
+
// Don't show integrity line if hook isn't installed
|
|
1314
|
+
}
|
|
1315
|
+
Err(_) => {
|
|
1316
|
+
println!("⚠️ Integrity: check failed");
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Check global CLAUDE.md
|
|
1321
|
+
if global_claude_md.exists() {
|
|
1322
|
+
let content = fs::read_to_string(&global_claude_md)?;
|
|
1323
|
+
if content.contains("@RTK.md") {
|
|
1324
|
+
println!("✅ Global (~/.claude/CLAUDE.md): @RTK.md reference");
|
|
1325
|
+
} else if content.contains("<!-- rtk-instructions") {
|
|
1326
|
+
println!(
|
|
1327
|
+
"⚠️ Global (~/.claude/CLAUDE.md): old RTK block (run: rtk init -g to migrate)"
|
|
1328
|
+
);
|
|
1329
|
+
} else {
|
|
1330
|
+
println!("⚪ Global (~/.claude/CLAUDE.md): exists but rtk not configured");
|
|
1331
|
+
}
|
|
1332
|
+
} else {
|
|
1333
|
+
println!("⚪ Global (~/.claude/CLAUDE.md): not found");
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Check local CLAUDE.md
|
|
1337
|
+
if local_claude_md.exists() {
|
|
1338
|
+
let content = fs::read_to_string(&local_claude_md)?;
|
|
1339
|
+
if content.contains("rtk") {
|
|
1340
|
+
println!("✅ Local (./CLAUDE.md): rtk enabled");
|
|
1341
|
+
} else {
|
|
1342
|
+
println!("⚪ Local (./CLAUDE.md): exists but rtk not configured");
|
|
1343
|
+
}
|
|
1344
|
+
} else {
|
|
1345
|
+
println!("⚪ Local (./CLAUDE.md): not found");
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Check settings.json
|
|
1349
|
+
let settings_path = claude_dir.join("settings.json");
|
|
1350
|
+
if settings_path.exists() {
|
|
1351
|
+
let content = fs::read_to_string(&settings_path)?;
|
|
1352
|
+
if !content.trim().is_empty() {
|
|
1353
|
+
if let Ok(root) = serde_json::from_str::<serde_json::Value>(&content) {
|
|
1354
|
+
let hook_command = hook_path.display().to_string();
|
|
1355
|
+
if hook_already_present(&root, &hook_command) {
|
|
1356
|
+
println!("✅ settings.json: RTK hook configured");
|
|
1357
|
+
} else {
|
|
1358
|
+
println!("⚠️ settings.json: exists but RTK hook not configured");
|
|
1359
|
+
println!(" Run: rtk init -g --auto-patch");
|
|
1360
|
+
}
|
|
1361
|
+
} else {
|
|
1362
|
+
println!("⚠️ settings.json: exists but invalid JSON");
|
|
1363
|
+
}
|
|
1364
|
+
} else {
|
|
1365
|
+
println!("⚪ settings.json: empty");
|
|
1366
|
+
}
|
|
1367
|
+
} else {
|
|
1368
|
+
println!("⚪ settings.json: not found");
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Check OpenCode plugin
|
|
1372
|
+
if let Ok(opencode_dir) = resolve_opencode_dir() {
|
|
1373
|
+
let plugin = opencode_plugin_path(&opencode_dir);
|
|
1374
|
+
if plugin.exists() {
|
|
1375
|
+
println!("✅ OpenCode: plugin installed ({})", plugin.display());
|
|
1376
|
+
} else {
|
|
1377
|
+
println!("⚪ OpenCode: plugin not found");
|
|
1378
|
+
}
|
|
1379
|
+
} else {
|
|
1380
|
+
println!("⚪ OpenCode: config dir not found");
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
println!("\nUsage:");
|
|
1384
|
+
println!(" rtk init # Full injection into local CLAUDE.md");
|
|
1385
|
+
println!(" rtk init -g # Hook + RTK.md + @RTK.md + settings.json (recommended)");
|
|
1386
|
+
println!(" rtk init -g --auto-patch # Same as above but no prompt");
|
|
1387
|
+
println!(" rtk init -g --no-patch # Skip settings.json (manual setup)");
|
|
1388
|
+
println!(" rtk init -g --uninstall # Remove all RTK artifacts");
|
|
1389
|
+
println!(" rtk init -g --claude-md # Legacy: full injection into ~/.claude/CLAUDE.md");
|
|
1390
|
+
println!(" rtk init -g --hook-only # Hook only, no RTK.md");
|
|
1391
|
+
println!(" rtk init -g --opencode # OpenCode plugin only");
|
|
1392
|
+
|
|
1393
|
+
Ok(())
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
fn run_opencode_only_mode(verbose: u8) -> Result<()> {
|
|
1397
|
+
let opencode_plugin_path = prepare_opencode_plugin_path()?;
|
|
1398
|
+
ensure_opencode_plugin_installed(&opencode_plugin_path, verbose)?;
|
|
1399
|
+
println!("\nOpenCode plugin installed (global).\n");
|
|
1400
|
+
println!(" OpenCode: {}", opencode_plugin_path.display());
|
|
1401
|
+
println!(" Restart OpenCode. Test with: git status\n");
|
|
1402
|
+
Ok(())
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
#[cfg(test)]
|
|
1406
|
+
mod tests {
|
|
1407
|
+
use super::*;
|
|
1408
|
+
use std::path::Path;
|
|
1409
|
+
use tempfile::TempDir;
|
|
1410
|
+
|
|
1411
|
+
#[test]
|
|
1412
|
+
fn test_init_mentions_all_top_level_commands() {
|
|
1413
|
+
for cmd in [
|
|
1414
|
+
"rtk cargo",
|
|
1415
|
+
"rtk gh",
|
|
1416
|
+
"rtk vitest",
|
|
1417
|
+
"rtk tsc",
|
|
1418
|
+
"rtk lint",
|
|
1419
|
+
"rtk prettier",
|
|
1420
|
+
"rtk next",
|
|
1421
|
+
"rtk playwright",
|
|
1422
|
+
"rtk prisma",
|
|
1423
|
+
"rtk pnpm",
|
|
1424
|
+
"rtk npm",
|
|
1425
|
+
"rtk curl",
|
|
1426
|
+
"rtk git",
|
|
1427
|
+
"rtk docker",
|
|
1428
|
+
"rtk kubectl",
|
|
1429
|
+
] {
|
|
1430
|
+
assert!(
|
|
1431
|
+
RTK_INSTRUCTIONS.contains(cmd),
|
|
1432
|
+
"Missing {cmd} in RTK_INSTRUCTIONS"
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
#[test]
|
|
1438
|
+
fn test_init_has_version_marker() {
|
|
1439
|
+
assert!(
|
|
1440
|
+
RTK_INSTRUCTIONS.contains("<!-- rtk-instructions"),
|
|
1441
|
+
"RTK_INSTRUCTIONS must have version marker for idempotency"
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
#[test]
|
|
1446
|
+
fn test_hook_has_guards() {
|
|
1447
|
+
assert!(REWRITE_HOOK.contains("command -v rtk"));
|
|
1448
|
+
assert!(REWRITE_HOOK.contains("command -v jq"));
|
|
1449
|
+
// Guards (rtk/jq availability checks) must appear before the actual delegation call.
|
|
1450
|
+
// The thin delegating hook no longer uses set -euo pipefail.
|
|
1451
|
+
let jq_pos = REWRITE_HOOK.find("command -v jq").unwrap();
|
|
1452
|
+
let rtk_delegate_pos = REWRITE_HOOK.find("rtk rewrite \"$CMD\"").unwrap();
|
|
1453
|
+
assert!(
|
|
1454
|
+
jq_pos < rtk_delegate_pos,
|
|
1455
|
+
"Guards must appear before rtk rewrite delegation"
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
#[test]
|
|
1460
|
+
fn test_migration_removes_old_block() {
|
|
1461
|
+
let input = r#"# My Config
|
|
1462
|
+
|
|
1463
|
+
<!-- rtk-instructions v2 -->
|
|
1464
|
+
OLD RTK STUFF
|
|
1465
|
+
<!-- /rtk-instructions -->
|
|
1466
|
+
|
|
1467
|
+
More content"#;
|
|
1468
|
+
|
|
1469
|
+
let (result, migrated) = remove_rtk_block(input);
|
|
1470
|
+
assert!(migrated);
|
|
1471
|
+
assert!(!result.contains("OLD RTK STUFF"));
|
|
1472
|
+
assert!(result.contains("# My Config"));
|
|
1473
|
+
assert!(result.contains("More content"));
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
#[test]
|
|
1477
|
+
fn test_opencode_plugin_install_and_update() {
|
|
1478
|
+
let temp = TempDir::new().unwrap();
|
|
1479
|
+
let opencode_dir = temp.path().join("opencode");
|
|
1480
|
+
let plugin_path = opencode_plugin_path(&opencode_dir);
|
|
1481
|
+
|
|
1482
|
+
fs::create_dir_all(plugin_path.parent().unwrap()).unwrap();
|
|
1483
|
+
assert!(!plugin_path.exists());
|
|
1484
|
+
|
|
1485
|
+
let changed = ensure_opencode_plugin_installed(&plugin_path, 0).unwrap();
|
|
1486
|
+
assert!(changed);
|
|
1487
|
+
let content = fs::read_to_string(&plugin_path).unwrap();
|
|
1488
|
+
assert_eq!(content, OPENCODE_PLUGIN);
|
|
1489
|
+
|
|
1490
|
+
fs::write(&plugin_path, "// old").unwrap();
|
|
1491
|
+
let changed_again = ensure_opencode_plugin_installed(&plugin_path, 0).unwrap();
|
|
1492
|
+
assert!(changed_again);
|
|
1493
|
+
let content_updated = fs::read_to_string(&plugin_path).unwrap();
|
|
1494
|
+
assert_eq!(content_updated, OPENCODE_PLUGIN);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
#[test]
|
|
1498
|
+
fn test_opencode_plugin_remove() {
|
|
1499
|
+
let temp = TempDir::new().unwrap();
|
|
1500
|
+
let opencode_dir = temp.path().join("opencode");
|
|
1501
|
+
let plugin_path = opencode_plugin_path(&opencode_dir);
|
|
1502
|
+
fs::create_dir_all(plugin_path.parent().unwrap()).unwrap();
|
|
1503
|
+
fs::write(&plugin_path, OPENCODE_PLUGIN).unwrap();
|
|
1504
|
+
|
|
1505
|
+
assert!(plugin_path.exists());
|
|
1506
|
+
fs::remove_file(&plugin_path).unwrap();
|
|
1507
|
+
assert!(!plugin_path.exists());
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
#[test]
|
|
1511
|
+
fn test_migration_warns_on_missing_end_marker() {
|
|
1512
|
+
let input = "<!-- rtk-instructions v2 -->\nOLD STUFF\nNo end marker";
|
|
1513
|
+
let (result, migrated) = remove_rtk_block(input);
|
|
1514
|
+
assert!(!migrated);
|
|
1515
|
+
assert_eq!(result, input);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
#[test]
|
|
1519
|
+
#[cfg(unix)]
|
|
1520
|
+
fn test_default_mode_creates_hook_and_rtk_md() {
|
|
1521
|
+
let temp = TempDir::new().unwrap();
|
|
1522
|
+
let hook_path = temp.path().join("rtk-rewrite.sh");
|
|
1523
|
+
let rtk_md_path = temp.path().join("RTK.md");
|
|
1524
|
+
|
|
1525
|
+
fs::write(&hook_path, REWRITE_HOOK).unwrap();
|
|
1526
|
+
fs::write(&rtk_md_path, RTK_SLIM).unwrap();
|
|
1527
|
+
|
|
1528
|
+
use std::os::unix::fs::PermissionsExt;
|
|
1529
|
+
fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
|
|
1530
|
+
|
|
1531
|
+
assert!(hook_path.exists());
|
|
1532
|
+
assert!(rtk_md_path.exists());
|
|
1533
|
+
|
|
1534
|
+
let metadata = fs::metadata(&hook_path).unwrap();
|
|
1535
|
+
assert!(metadata.permissions().mode() & 0o111 != 0);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
#[test]
|
|
1539
|
+
fn test_claude_md_mode_creates_full_injection() {
|
|
1540
|
+
// Just verify RTK_INSTRUCTIONS constant has the right content
|
|
1541
|
+
assert!(RTK_INSTRUCTIONS.contains("<!-- rtk-instructions"));
|
|
1542
|
+
assert!(RTK_INSTRUCTIONS.contains("rtk cargo test"));
|
|
1543
|
+
assert!(RTK_INSTRUCTIONS.contains("<!-- /rtk-instructions -->"));
|
|
1544
|
+
assert!(RTK_INSTRUCTIONS.len() > 4000);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// --- upsert_rtk_block tests ---
|
|
1548
|
+
|
|
1549
|
+
#[test]
|
|
1550
|
+
fn test_upsert_rtk_block_appends_when_missing() {
|
|
1551
|
+
let input = "# Team instructions";
|
|
1552
|
+
let (content, action) = upsert_rtk_block(input, RTK_INSTRUCTIONS);
|
|
1553
|
+
assert_eq!(action, RtkBlockUpsert::Added);
|
|
1554
|
+
assert!(content.contains("# Team instructions"));
|
|
1555
|
+
assert!(content.contains("<!-- rtk-instructions"));
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
#[test]
|
|
1559
|
+
fn test_upsert_rtk_block_updates_stale_block() {
|
|
1560
|
+
let input = r#"# Team instructions
|
|
1561
|
+
|
|
1562
|
+
<!-- rtk-instructions v1 -->
|
|
1563
|
+
OLD RTK CONTENT
|
|
1564
|
+
<!-- /rtk-instructions -->
|
|
1565
|
+
|
|
1566
|
+
More notes
|
|
1567
|
+
"#;
|
|
1568
|
+
|
|
1569
|
+
let (content, action) = upsert_rtk_block(input, RTK_INSTRUCTIONS);
|
|
1570
|
+
assert_eq!(action, RtkBlockUpsert::Updated);
|
|
1571
|
+
assert!(!content.contains("OLD RTK CONTENT"));
|
|
1572
|
+
assert!(content.contains("rtk cargo test")); // from current RTK_INSTRUCTIONS
|
|
1573
|
+
assert!(content.contains("# Team instructions"));
|
|
1574
|
+
assert!(content.contains("More notes"));
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
#[test]
|
|
1578
|
+
fn test_upsert_rtk_block_noop_when_already_current() {
|
|
1579
|
+
let input = format!(
|
|
1580
|
+
"# Team instructions\n\n{}\n\nMore notes\n",
|
|
1581
|
+
RTK_INSTRUCTIONS
|
|
1582
|
+
);
|
|
1583
|
+
let (content, action) = upsert_rtk_block(&input, RTK_INSTRUCTIONS);
|
|
1584
|
+
assert_eq!(action, RtkBlockUpsert::Unchanged);
|
|
1585
|
+
assert_eq!(content, input);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
#[test]
|
|
1589
|
+
fn test_upsert_rtk_block_detects_malformed_block() {
|
|
1590
|
+
let input = "<!-- rtk-instructions v2 -->\npartial";
|
|
1591
|
+
let (content, action) = upsert_rtk_block(input, RTK_INSTRUCTIONS);
|
|
1592
|
+
assert_eq!(action, RtkBlockUpsert::Malformed);
|
|
1593
|
+
assert_eq!(content, input);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
#[test]
|
|
1597
|
+
fn test_init_is_idempotent() {
|
|
1598
|
+
let temp = TempDir::new().unwrap();
|
|
1599
|
+
let claude_md = temp.path().join("CLAUDE.md");
|
|
1600
|
+
|
|
1601
|
+
fs::write(&claude_md, "# My stuff\n\n@RTK.md\n").unwrap();
|
|
1602
|
+
|
|
1603
|
+
let content = fs::read_to_string(&claude_md).unwrap();
|
|
1604
|
+
let count = content.matches("@RTK.md").count();
|
|
1605
|
+
assert_eq!(count, 1);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
#[test]
|
|
1609
|
+
fn test_local_init_unchanged() {
|
|
1610
|
+
// Local init should use claude-md mode
|
|
1611
|
+
let temp = TempDir::new().unwrap();
|
|
1612
|
+
let claude_md = temp.path().join("CLAUDE.md");
|
|
1613
|
+
|
|
1614
|
+
fs::write(&claude_md, RTK_INSTRUCTIONS).unwrap();
|
|
1615
|
+
let content = fs::read_to_string(&claude_md).unwrap();
|
|
1616
|
+
|
|
1617
|
+
assert!(content.contains("<!-- rtk-instructions"));
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// Tests for hook_already_present()
|
|
1621
|
+
#[test]
|
|
1622
|
+
fn test_hook_already_present_exact_match() {
|
|
1623
|
+
let json_content = serde_json::json!({
|
|
1624
|
+
"hooks": {
|
|
1625
|
+
"PreToolUse": [{
|
|
1626
|
+
"matcher": "Bash",
|
|
1627
|
+
"hooks": [{
|
|
1628
|
+
"type": "command",
|
|
1629
|
+
"command": "/Users/test/.claude/hooks/rtk-rewrite.sh"
|
|
1630
|
+
}]
|
|
1631
|
+
}]
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
let hook_command = "/Users/test/.claude/hooks/rtk-rewrite.sh";
|
|
1636
|
+
assert!(hook_already_present(&json_content, hook_command));
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
#[test]
|
|
1640
|
+
fn test_hook_already_present_different_path() {
|
|
1641
|
+
let json_content = serde_json::json!({
|
|
1642
|
+
"hooks": {
|
|
1643
|
+
"PreToolUse": [{
|
|
1644
|
+
"matcher": "Bash",
|
|
1645
|
+
"hooks": [{
|
|
1646
|
+
"type": "command",
|
|
1647
|
+
"command": "/home/user/.claude/hooks/rtk-rewrite.sh"
|
|
1648
|
+
}]
|
|
1649
|
+
}]
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
let hook_command = "~/.claude/hooks/rtk-rewrite.sh";
|
|
1654
|
+
// Should match on rtk-rewrite.sh substring
|
|
1655
|
+
assert!(hook_already_present(&json_content, hook_command));
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
#[test]
|
|
1659
|
+
fn test_hook_not_present_empty() {
|
|
1660
|
+
let json_content = serde_json::json!({});
|
|
1661
|
+
let hook_command = "/Users/test/.claude/hooks/rtk-rewrite.sh";
|
|
1662
|
+
assert!(!hook_already_present(&json_content, hook_command));
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
#[test]
|
|
1666
|
+
fn test_hook_not_present_other_hooks() {
|
|
1667
|
+
let json_content = serde_json::json!({
|
|
1668
|
+
"hooks": {
|
|
1669
|
+
"PreToolUse": [{
|
|
1670
|
+
"matcher": "Bash",
|
|
1671
|
+
"hooks": [{
|
|
1672
|
+
"type": "command",
|
|
1673
|
+
"command": "/some/other/hook.sh"
|
|
1674
|
+
}]
|
|
1675
|
+
}]
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
let hook_command = "/Users/test/.claude/hooks/rtk-rewrite.sh";
|
|
1680
|
+
assert!(!hook_already_present(&json_content, hook_command));
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Tests for insert_hook_entry()
|
|
1684
|
+
#[test]
|
|
1685
|
+
fn test_insert_hook_entry_empty_root() {
|
|
1686
|
+
let mut json_content = serde_json::json!({});
|
|
1687
|
+
let hook_command = "/Users/test/.claude/hooks/rtk-rewrite.sh";
|
|
1688
|
+
|
|
1689
|
+
insert_hook_entry(&mut json_content, hook_command);
|
|
1690
|
+
|
|
1691
|
+
// Should create full structure
|
|
1692
|
+
assert!(json_content.get("hooks").is_some());
|
|
1693
|
+
assert!(json_content
|
|
1694
|
+
.get("hooks")
|
|
1695
|
+
.unwrap()
|
|
1696
|
+
.get("PreToolUse")
|
|
1697
|
+
.is_some());
|
|
1698
|
+
|
|
1699
|
+
let pre_tool_use = json_content["hooks"]["PreToolUse"].as_array().unwrap();
|
|
1700
|
+
assert_eq!(pre_tool_use.len(), 1);
|
|
1701
|
+
|
|
1702
|
+
let command = pre_tool_use[0]["hooks"][0]["command"].as_str().unwrap();
|
|
1703
|
+
assert_eq!(command, hook_command);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
#[test]
|
|
1707
|
+
fn test_insert_hook_entry_preserves_existing() {
|
|
1708
|
+
let mut json_content = serde_json::json!({
|
|
1709
|
+
"hooks": {
|
|
1710
|
+
"PreToolUse": [{
|
|
1711
|
+
"matcher": "Bash",
|
|
1712
|
+
"hooks": [{
|
|
1713
|
+
"type": "command",
|
|
1714
|
+
"command": "/some/other/hook.sh"
|
|
1715
|
+
}]
|
|
1716
|
+
}]
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
let hook_command = "/Users/test/.claude/hooks/rtk-rewrite.sh";
|
|
1721
|
+
insert_hook_entry(&mut json_content, hook_command);
|
|
1722
|
+
|
|
1723
|
+
let pre_tool_use = json_content["hooks"]["PreToolUse"].as_array().unwrap();
|
|
1724
|
+
assert_eq!(pre_tool_use.len(), 2); // Should have both hooks
|
|
1725
|
+
|
|
1726
|
+
// Check first hook is preserved
|
|
1727
|
+
let first_command = pre_tool_use[0]["hooks"][0]["command"].as_str().unwrap();
|
|
1728
|
+
assert_eq!(first_command, "/some/other/hook.sh");
|
|
1729
|
+
|
|
1730
|
+
// Check second hook is RTK
|
|
1731
|
+
let second_command = pre_tool_use[1]["hooks"][0]["command"].as_str().unwrap();
|
|
1732
|
+
assert_eq!(second_command, hook_command);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
#[test]
|
|
1736
|
+
fn test_insert_hook_preserves_other_keys() {
|
|
1737
|
+
let mut json_content = serde_json::json!({
|
|
1738
|
+
"env": {"PATH": "/custom/path"},
|
|
1739
|
+
"permissions": {"allowAll": true},
|
|
1740
|
+
"model": "claude-sonnet-4"
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
let hook_command = "/Users/test/.claude/hooks/rtk-rewrite.sh";
|
|
1744
|
+
insert_hook_entry(&mut json_content, hook_command);
|
|
1745
|
+
|
|
1746
|
+
// Should preserve all other keys
|
|
1747
|
+
assert_eq!(json_content["env"]["PATH"], "/custom/path");
|
|
1748
|
+
assert_eq!(json_content["permissions"]["allowAll"], true);
|
|
1749
|
+
assert_eq!(json_content["model"], "claude-sonnet-4");
|
|
1750
|
+
|
|
1751
|
+
// And add hooks
|
|
1752
|
+
assert!(json_content.get("hooks").is_some());
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Tests for atomic_write()
|
|
1756
|
+
#[test]
|
|
1757
|
+
fn test_atomic_write() {
|
|
1758
|
+
let temp = TempDir::new().unwrap();
|
|
1759
|
+
let file_path = temp.path().join("test.json");
|
|
1760
|
+
|
|
1761
|
+
let content = r#"{"key": "value"}"#;
|
|
1762
|
+
atomic_write(&file_path, content).unwrap();
|
|
1763
|
+
|
|
1764
|
+
assert!(file_path.exists());
|
|
1765
|
+
let written = fs::read_to_string(&file_path).unwrap();
|
|
1766
|
+
assert_eq!(written, content);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Test for preserve_order round-trip
|
|
1770
|
+
#[test]
|
|
1771
|
+
fn test_preserve_order_round_trip() {
|
|
1772
|
+
let original = r#"{"env": {"PATH": "/usr/bin"}, "permissions": {"allowAll": true}, "model": "claude-sonnet-4"}"#;
|
|
1773
|
+
let parsed: serde_json::Value = serde_json::from_str(original).unwrap();
|
|
1774
|
+
let serialized = serde_json::to_string(&parsed).unwrap();
|
|
1775
|
+
|
|
1776
|
+
// Keys should appear in same order
|
|
1777
|
+
let original_keys: Vec<&str> = original.split("\"").filter(|s| s.contains(":")).collect();
|
|
1778
|
+
let serialized_keys: Vec<&str> =
|
|
1779
|
+
serialized.split("\"").filter(|s| s.contains(":")).collect();
|
|
1780
|
+
|
|
1781
|
+
// Just check that keys exist (preserve_order doesn't guarantee exact order in nested objects)
|
|
1782
|
+
assert!(serialized.contains("\"env\""));
|
|
1783
|
+
assert!(serialized.contains("\"permissions\""));
|
|
1784
|
+
assert!(serialized.contains("\"model\""));
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Tests for clean_double_blanks()
|
|
1788
|
+
#[test]
|
|
1789
|
+
fn test_clean_double_blanks() {
|
|
1790
|
+
// Input: line1, 2 blank lines, line2, 1 blank line, line3, 3 blank lines, line4
|
|
1791
|
+
// Expected: line1, 2 blank lines (kept), line2, 1 blank line, line3, 2 blank lines (max), line4
|
|
1792
|
+
let input = "line1\n\n\nline2\n\nline3\n\n\n\nline4";
|
|
1793
|
+
// That's: line1 \n \n \n line2 \n \n line3 \n \n \n \n line4
|
|
1794
|
+
// Which is: line1, blank, blank, line2, blank, line3, blank, blank, blank, line4
|
|
1795
|
+
// So 2 blanks after line1 (keep both), 1 blank after line2 (keep), 3 blanks after line3 (keep 2)
|
|
1796
|
+
let expected = "line1\n\n\nline2\n\nline3\n\n\nline4";
|
|
1797
|
+
assert_eq!(clean_double_blanks(input), expected);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
#[test]
|
|
1801
|
+
fn test_clean_double_blanks_preserves_single() {
|
|
1802
|
+
let input = "line1\n\nline2\n\nline3";
|
|
1803
|
+
assert_eq!(clean_double_blanks(input), input); // No change
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// Tests for remove_hook_from_settings()
|
|
1807
|
+
#[test]
|
|
1808
|
+
fn test_remove_hook_from_json() {
|
|
1809
|
+
let mut json_content = serde_json::json!({
|
|
1810
|
+
"hooks": {
|
|
1811
|
+
"PreToolUse": [
|
|
1812
|
+
{
|
|
1813
|
+
"matcher": "Bash",
|
|
1814
|
+
"hooks": [{
|
|
1815
|
+
"type": "command",
|
|
1816
|
+
"command": "/some/other/hook.sh"
|
|
1817
|
+
}]
|
|
1818
|
+
},
|
|
1819
|
+
{
|
|
1820
|
+
"matcher": "Bash",
|
|
1821
|
+
"hooks": [{
|
|
1822
|
+
"type": "command",
|
|
1823
|
+
"command": "/Users/test/.claude/hooks/rtk-rewrite.sh"
|
|
1824
|
+
}]
|
|
1825
|
+
}
|
|
1826
|
+
]
|
|
1827
|
+
}
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
let removed = remove_hook_from_json(&mut json_content);
|
|
1831
|
+
assert!(removed);
|
|
1832
|
+
|
|
1833
|
+
// Should have only one hook left
|
|
1834
|
+
let pre_tool_use = json_content["hooks"]["PreToolUse"].as_array().unwrap();
|
|
1835
|
+
assert_eq!(pre_tool_use.len(), 1);
|
|
1836
|
+
|
|
1837
|
+
// Check it's the other hook
|
|
1838
|
+
let command = pre_tool_use[0]["hooks"][0]["command"].as_str().unwrap();
|
|
1839
|
+
assert_eq!(command, "/some/other/hook.sh");
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
#[test]
|
|
1843
|
+
fn test_remove_hook_when_not_present() {
|
|
1844
|
+
let mut json_content = serde_json::json!({
|
|
1845
|
+
"hooks": {
|
|
1846
|
+
"PreToolUse": [{
|
|
1847
|
+
"matcher": "Bash",
|
|
1848
|
+
"hooks": [{
|
|
1849
|
+
"type": "command",
|
|
1850
|
+
"command": "/some/other/hook.sh"
|
|
1851
|
+
}]
|
|
1852
|
+
}]
|
|
1853
|
+
}
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
let removed = remove_hook_from_json(&mut json_content);
|
|
1857
|
+
assert!(!removed);
|
|
1858
|
+
}
|
|
1859
|
+
}
|