@hasna/terminal 2.3.0 → 2.3.2

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.
Files changed (267) hide show
  1. package/dist/App.js +404 -0
  2. package/dist/Browse.js +79 -0
  3. package/dist/FuzzyPicker.js +47 -0
  4. package/dist/Onboarding.js +51 -0
  5. package/dist/Spinner.js +12 -0
  6. package/dist/StatusBar.js +49 -0
  7. package/dist/ai.js +322 -0
  8. package/dist/cache.js +41 -0
  9. package/dist/cli.js +64 -16
  10. package/dist/command-rewriter.js +64 -0
  11. package/dist/command-validator.js +86 -0
  12. package/dist/compression.js +107 -0
  13. package/dist/context-hints.js +275 -0
  14. package/dist/diff-cache.js +107 -0
  15. package/dist/discover.js +212 -0
  16. package/dist/economy.js +123 -0
  17. package/dist/expand-store.js +38 -0
  18. package/dist/file-cache.js +72 -0
  19. package/dist/file-index.js +62 -0
  20. package/dist/history.js +62 -0
  21. package/dist/lazy-executor.js +54 -0
  22. package/dist/line-dedup.js +59 -0
  23. package/dist/loop-detector.js +75 -0
  24. package/dist/mcp/install.js +98 -0
  25. package/dist/mcp/server.js +569 -0
  26. package/dist/noise-filter.js +86 -0
  27. package/dist/output-processor.js +129 -0
  28. package/dist/output-router.js +41 -0
  29. package/dist/output-store.js +111 -0
  30. package/dist/parsers/base.js +2 -0
  31. package/dist/parsers/build.js +64 -0
  32. package/dist/parsers/errors.js +101 -0
  33. package/dist/parsers/files.js +78 -0
  34. package/dist/parsers/git.js +99 -0
  35. package/dist/parsers/index.js +48 -0
  36. package/dist/parsers/tests.js +89 -0
  37. package/dist/providers/anthropic.js +39 -0
  38. package/dist/providers/base.js +4 -0
  39. package/dist/providers/cerebras.js +95 -0
  40. package/dist/providers/groq.js +95 -0
  41. package/dist/providers/index.js +73 -0
  42. package/dist/providers/xai.js +95 -0
  43. package/dist/recipes/model.js +20 -0
  44. package/dist/recipes/storage.js +136 -0
  45. package/dist/search/content-search.js +68 -0
  46. package/dist/search/file-search.js +61 -0
  47. package/dist/search/filters.js +34 -0
  48. package/dist/search/index.js +5 -0
  49. package/dist/search/semantic.js +320 -0
  50. package/dist/session-boot.js +59 -0
  51. package/dist/session-context.js +55 -0
  52. package/dist/sessions-db.js +173 -0
  53. package/dist/smart-display.js +286 -0
  54. package/dist/snapshots.js +51 -0
  55. package/dist/supervisor.js +112 -0
  56. package/dist/test-watchlist.js +131 -0
  57. package/dist/tool-profiles.js +122 -0
  58. package/dist/tree.js +94 -0
  59. package/dist/usage-cache.js +65 -0
  60. package/package.json +8 -1
  61. package/src/ai.ts +8 -0
  62. package/src/cli.tsx +57 -18
  63. package/src/output-processor.ts +6 -1
  64. package/src/output-store.ts +58 -12
  65. package/src/tool-profiles.ts +139 -0
  66. package/.claude/scheduled_tasks.lock +0 -1
  67. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -20
  68. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -14
  69. package/CONTRIBUTING.md +0 -80
  70. package/benchmarks/benchmark.mjs +0 -115
  71. package/imported_modules.txt +0 -0
  72. package/temp/rtk/.claude/agents/code-reviewer.md +0 -221
  73. package/temp/rtk/.claude/agents/debugger.md +0 -519
  74. package/temp/rtk/.claude/agents/rtk-testing-specialist.md +0 -461
  75. package/temp/rtk/.claude/agents/rust-rtk.md +0 -511
  76. package/temp/rtk/.claude/agents/technical-writer.md +0 -355
  77. package/temp/rtk/.claude/commands/diagnose.md +0 -352
  78. package/temp/rtk/.claude/commands/test-routing.md +0 -362
  79. package/temp/rtk/.claude/hooks/bash/pre-commit-format.sh +0 -16
  80. package/temp/rtk/.claude/hooks/rtk-rewrite.sh +0 -70
  81. package/temp/rtk/.claude/hooks/rtk-suggest.sh +0 -152
  82. package/temp/rtk/.claude/rules/cli-testing.md +0 -526
  83. package/temp/rtk/.claude/skills/issue-triage/SKILL.md +0 -348
  84. package/temp/rtk/.claude/skills/issue-triage/templates/issue-comment.md +0 -134
  85. package/temp/rtk/.claude/skills/performance.md +0 -435
  86. package/temp/rtk/.claude/skills/pr-triage/SKILL.md +0 -315
  87. package/temp/rtk/.claude/skills/pr-triage/templates/review-comment.md +0 -71
  88. package/temp/rtk/.claude/skills/repo-recap.md +0 -206
  89. package/temp/rtk/.claude/skills/rtk-tdd/SKILL.md +0 -78
  90. package/temp/rtk/.claude/skills/rtk-tdd/references/testing-patterns.md +0 -124
  91. package/temp/rtk/.claude/skills/security-guardian.md +0 -503
  92. package/temp/rtk/.claude/skills/ship.md +0 -404
  93. package/temp/rtk/.github/workflows/benchmark.yml +0 -34
  94. package/temp/rtk/.github/workflows/dco-check.yaml +0 -12
  95. package/temp/rtk/.github/workflows/release-please.yml +0 -51
  96. package/temp/rtk/.github/workflows/release.yml +0 -343
  97. package/temp/rtk/.github/workflows/security-check.yml +0 -135
  98. package/temp/rtk/.github/workflows/validate-docs.yml +0 -78
  99. package/temp/rtk/.release-please-manifest.json +0 -3
  100. package/temp/rtk/ARCHITECTURE.md +0 -1491
  101. package/temp/rtk/CHANGELOG.md +0 -640
  102. package/temp/rtk/CLAUDE.md +0 -605
  103. package/temp/rtk/CONTRIBUTING.md +0 -199
  104. package/temp/rtk/Cargo.lock +0 -1668
  105. package/temp/rtk/Cargo.toml +0 -64
  106. package/temp/rtk/Formula/rtk.rb +0 -43
  107. package/temp/rtk/INSTALL.md +0 -390
  108. package/temp/rtk/LICENSE +0 -21
  109. package/temp/rtk/README.md +0 -386
  110. package/temp/rtk/README_es.md +0 -159
  111. package/temp/rtk/README_fr.md +0 -197
  112. package/temp/rtk/README_ja.md +0 -159
  113. package/temp/rtk/README_ko.md +0 -159
  114. package/temp/rtk/README_zh.md +0 -167
  115. package/temp/rtk/ROADMAP.md +0 -15
  116. package/temp/rtk/SECURITY.md +0 -217
  117. package/temp/rtk/TEST_EXEC_TIME.md +0 -102
  118. package/temp/rtk/build.rs +0 -57
  119. package/temp/rtk/docs/AUDIT_GUIDE.md +0 -432
  120. package/temp/rtk/docs/FEATURES.md +0 -1410
  121. package/temp/rtk/docs/TROUBLESHOOTING.md +0 -309
  122. package/temp/rtk/docs/filter-workflow.md +0 -102
  123. package/temp/rtk/docs/images/gain-dashboard.jpg +0 -0
  124. package/temp/rtk/docs/tracking.md +0 -583
  125. package/temp/rtk/hooks/opencode-rtk.ts +0 -39
  126. package/temp/rtk/hooks/rtk-awareness.md +0 -29
  127. package/temp/rtk/hooks/rtk-rewrite.sh +0 -61
  128. package/temp/rtk/hooks/test-rtk-rewrite.sh +0 -442
  129. package/temp/rtk/install.sh +0 -124
  130. package/temp/rtk/release-please-config.json +0 -10
  131. package/temp/rtk/scripts/benchmark.sh +0 -592
  132. package/temp/rtk/scripts/check-installation.sh +0 -162
  133. package/temp/rtk/scripts/install-local.sh +0 -37
  134. package/temp/rtk/scripts/rtk-economics.sh +0 -137
  135. package/temp/rtk/scripts/test-all.sh +0 -561
  136. package/temp/rtk/scripts/test-aristote.sh +0 -227
  137. package/temp/rtk/scripts/test-tracking.sh +0 -79
  138. package/temp/rtk/scripts/update-readme-metrics.sh +0 -32
  139. package/temp/rtk/scripts/validate-docs.sh +0 -73
  140. package/temp/rtk/src/aws_cmd.rs +0 -880
  141. package/temp/rtk/src/binlog.rs +0 -1645
  142. package/temp/rtk/src/cargo_cmd.rs +0 -1727
  143. package/temp/rtk/src/cc_economics.rs +0 -1157
  144. package/temp/rtk/src/ccusage.rs +0 -340
  145. package/temp/rtk/src/config.rs +0 -187
  146. package/temp/rtk/src/container.rs +0 -855
  147. package/temp/rtk/src/curl_cmd.rs +0 -134
  148. package/temp/rtk/src/deps.rs +0 -268
  149. package/temp/rtk/src/diff_cmd.rs +0 -367
  150. package/temp/rtk/src/discover/mod.rs +0 -274
  151. package/temp/rtk/src/discover/provider.rs +0 -388
  152. package/temp/rtk/src/discover/registry.rs +0 -2022
  153. package/temp/rtk/src/discover/report.rs +0 -202
  154. package/temp/rtk/src/discover/rules.rs +0 -667
  155. package/temp/rtk/src/display_helpers.rs +0 -402
  156. package/temp/rtk/src/dotnet_cmd.rs +0 -1771
  157. package/temp/rtk/src/dotnet_format_report.rs +0 -133
  158. package/temp/rtk/src/dotnet_trx.rs +0 -593
  159. package/temp/rtk/src/env_cmd.rs +0 -204
  160. package/temp/rtk/src/filter.rs +0 -462
  161. package/temp/rtk/src/filters/README.md +0 -52
  162. package/temp/rtk/src/filters/ansible-playbook.toml +0 -34
  163. package/temp/rtk/src/filters/basedpyright.toml +0 -47
  164. package/temp/rtk/src/filters/biome.toml +0 -45
  165. package/temp/rtk/src/filters/brew-install.toml +0 -37
  166. package/temp/rtk/src/filters/composer-install.toml +0 -40
  167. package/temp/rtk/src/filters/df.toml +0 -16
  168. package/temp/rtk/src/filters/dotnet-build.toml +0 -64
  169. package/temp/rtk/src/filters/du.toml +0 -16
  170. package/temp/rtk/src/filters/fail2ban-client.toml +0 -15
  171. package/temp/rtk/src/filters/gcc.toml +0 -49
  172. package/temp/rtk/src/filters/gcloud.toml +0 -22
  173. package/temp/rtk/src/filters/hadolint.toml +0 -24
  174. package/temp/rtk/src/filters/helm.toml +0 -29
  175. package/temp/rtk/src/filters/iptables.toml +0 -27
  176. package/temp/rtk/src/filters/jj.toml +0 -28
  177. package/temp/rtk/src/filters/jq.toml +0 -24
  178. package/temp/rtk/src/filters/make.toml +0 -41
  179. package/temp/rtk/src/filters/markdownlint.toml +0 -24
  180. package/temp/rtk/src/filters/mix-compile.toml +0 -27
  181. package/temp/rtk/src/filters/mix-format.toml +0 -15
  182. package/temp/rtk/src/filters/mvn-build.toml +0 -44
  183. package/temp/rtk/src/filters/oxlint.toml +0 -43
  184. package/temp/rtk/src/filters/ping.toml +0 -63
  185. package/temp/rtk/src/filters/pio-run.toml +0 -40
  186. package/temp/rtk/src/filters/poetry-install.toml +0 -50
  187. package/temp/rtk/src/filters/pre-commit.toml +0 -35
  188. package/temp/rtk/src/filters/ps.toml +0 -16
  189. package/temp/rtk/src/filters/quarto-render.toml +0 -41
  190. package/temp/rtk/src/filters/rsync.toml +0 -48
  191. package/temp/rtk/src/filters/shellcheck.toml +0 -27
  192. package/temp/rtk/src/filters/shopify-theme.toml +0 -29
  193. package/temp/rtk/src/filters/skopeo.toml +0 -45
  194. package/temp/rtk/src/filters/sops.toml +0 -16
  195. package/temp/rtk/src/filters/ssh.toml +0 -44
  196. package/temp/rtk/src/filters/stat.toml +0 -34
  197. package/temp/rtk/src/filters/swift-build.toml +0 -41
  198. package/temp/rtk/src/filters/systemctl-status.toml +0 -33
  199. package/temp/rtk/src/filters/terraform-plan.toml +0 -35
  200. package/temp/rtk/src/filters/tofu-fmt.toml +0 -16
  201. package/temp/rtk/src/filters/tofu-init.toml +0 -38
  202. package/temp/rtk/src/filters/tofu-plan.toml +0 -35
  203. package/temp/rtk/src/filters/tofu-validate.toml +0 -17
  204. package/temp/rtk/src/filters/trunk-build.toml +0 -39
  205. package/temp/rtk/src/filters/ty.toml +0 -50
  206. package/temp/rtk/src/filters/uv-sync.toml +0 -37
  207. package/temp/rtk/src/filters/xcodebuild.toml +0 -99
  208. package/temp/rtk/src/filters/yamllint.toml +0 -25
  209. package/temp/rtk/src/find_cmd.rs +0 -598
  210. package/temp/rtk/src/format_cmd.rs +0 -386
  211. package/temp/rtk/src/gain.rs +0 -723
  212. package/temp/rtk/src/gh_cmd.rs +0 -1651
  213. package/temp/rtk/src/git.rs +0 -2012
  214. package/temp/rtk/src/go_cmd.rs +0 -592
  215. package/temp/rtk/src/golangci_cmd.rs +0 -254
  216. package/temp/rtk/src/grep_cmd.rs +0 -288
  217. package/temp/rtk/src/gt_cmd.rs +0 -810
  218. package/temp/rtk/src/hook_audit_cmd.rs +0 -283
  219. package/temp/rtk/src/hook_check.rs +0 -171
  220. package/temp/rtk/src/init.rs +0 -1859
  221. package/temp/rtk/src/integrity.rs +0 -537
  222. package/temp/rtk/src/json_cmd.rs +0 -231
  223. package/temp/rtk/src/learn/detector.rs +0 -628
  224. package/temp/rtk/src/learn/mod.rs +0 -119
  225. package/temp/rtk/src/learn/report.rs +0 -184
  226. package/temp/rtk/src/lint_cmd.rs +0 -694
  227. package/temp/rtk/src/local_llm.rs +0 -316
  228. package/temp/rtk/src/log_cmd.rs +0 -248
  229. package/temp/rtk/src/ls.rs +0 -324
  230. package/temp/rtk/src/main.rs +0 -2482
  231. package/temp/rtk/src/mypy_cmd.rs +0 -389
  232. package/temp/rtk/src/next_cmd.rs +0 -241
  233. package/temp/rtk/src/npm_cmd.rs +0 -236
  234. package/temp/rtk/src/parser/README.md +0 -267
  235. package/temp/rtk/src/parser/error.rs +0 -46
  236. package/temp/rtk/src/parser/formatter.rs +0 -336
  237. package/temp/rtk/src/parser/mod.rs +0 -311
  238. package/temp/rtk/src/parser/types.rs +0 -119
  239. package/temp/rtk/src/pip_cmd.rs +0 -302
  240. package/temp/rtk/src/playwright_cmd.rs +0 -479
  241. package/temp/rtk/src/pnpm_cmd.rs +0 -573
  242. package/temp/rtk/src/prettier_cmd.rs +0 -221
  243. package/temp/rtk/src/prisma_cmd.rs +0 -482
  244. package/temp/rtk/src/psql_cmd.rs +0 -382
  245. package/temp/rtk/src/pytest_cmd.rs +0 -384
  246. package/temp/rtk/src/read.rs +0 -217
  247. package/temp/rtk/src/rewrite_cmd.rs +0 -50
  248. package/temp/rtk/src/ruff_cmd.rs +0 -402
  249. package/temp/rtk/src/runner.rs +0 -271
  250. package/temp/rtk/src/summary.rs +0 -297
  251. package/temp/rtk/src/tee.rs +0 -405
  252. package/temp/rtk/src/telemetry.rs +0 -248
  253. package/temp/rtk/src/toml_filter.rs +0 -1655
  254. package/temp/rtk/src/tracking.rs +0 -1416
  255. package/temp/rtk/src/tree.rs +0 -209
  256. package/temp/rtk/src/tsc_cmd.rs +0 -259
  257. package/temp/rtk/src/utils.rs +0 -432
  258. package/temp/rtk/src/verify_cmd.rs +0 -47
  259. package/temp/rtk/src/vitest_cmd.rs +0 -385
  260. package/temp/rtk/src/wc_cmd.rs +0 -401
  261. package/temp/rtk/src/wget_cmd.rs +0 -260
  262. package/temp/rtk/tests/fixtures/dotnet/build_failed.txt +0 -11
  263. package/temp/rtk/tests/fixtures/dotnet/format_changes.json +0 -31
  264. package/temp/rtk/tests/fixtures/dotnet/format_empty.json +0 -1
  265. package/temp/rtk/tests/fixtures/dotnet/format_success.json +0 -12
  266. package/temp/rtk/tests/fixtures/dotnet/test_failed.txt +0 -18
  267. package/tsconfig.json +0 -15
@@ -1,1859 +0,0 @@
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
- }