Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b960262
feat(01-detection-01): add opencode detection helpers
young5lee Mar 10, 2026
672db69
feat(01-detection-01): integrate opencode init gate
young5lee Mar 10, 2026
efafa3f
test(01-detection-01): cover opencode detection branches
young5lee Mar 10, 2026
5ac6005
test(02-01): add failing tests for opencode plugin install
young5lee Mar 10, 2026
c5a5991
feat(02-01): add opencode plugin asset and installer helpers
young5lee Mar 10, 2026
7a6b0e7
test(02-02): add failing tests for opencode install flow
young5lee Mar 10, 2026
1f9f3a6
feat(02-02): wire opencode plugin install into init flow
young5lee Mar 10, 2026
3ad8e1e
test(02-03): add failing tests for opencode runtime contract
young5lee Mar 10, 2026
dd506e8
feat(02-03): repair opencode runtime plugin compatibility
young5lee Mar 10, 2026
2c45258
fix(02-03): resolve opencode rewrite for source-built installs
young5lee Mar 10, 2026
0e6f91b
test(04-01): add setup-target orchestration contracts
young5lee Mar 10, 2026
00894fd
feat(04-01): branch init from top-level setup target
young5lee Mar 10, 2026
70bb823
test(04-02): add failing tests for official opencode summary flow
young5lee Mar 10, 2026
3804dce
feat(04-02): align opencode setup reporting with official paths
young5lee Mar 10, 2026
35eedd1
test(03-01): add failing tests for opencode AGENTS helpers
young5lee Mar 10, 2026
b7b41eb
feat(03-01): add AGENTS section lifecycle helpers
young5lee Mar 10, 2026
2a936b0
test(03-01): add failing tests for opencode AGENTS setup wiring
young5lee Mar 10, 2026
7e21819
feat(03-01): wire opencode AGENTS setup into init status
young5lee Mar 10, 2026
dbe647b
test(03-02): add failing lifecycle tests for opencode uninstall
young5lee Mar 10, 2026
1af7add
feat(03-02): complete opencode uninstall and show reporting
young5lee Mar 10, 2026
d937855
test(03-04): add failing tests for top-level uninstall UX
young5lee Mar 10, 2026
cca6eae
feat(03-04): promote uninstall to a top-level command
young5lee Mar 10, 2026
d09ec7b
docs(03-03): document the opencode init lifecycle
young5lee Mar 10, 2026
b09a569
docs(03-03): capture the opencode release notes
young5lee Mar 10, 2026
0d1fc6f
docs(03-05): align uninstall reviewer documentation
young5lee Mar 10, 2026
259b137
docs(03-05): sync release notes with uninstall UX
young5lee Mar 10, 2026
3db1f10
docs(03-06): clarify historical uninstall changelog entry
young5lee Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,62 @@ Write template:
Success: "✓ Initialized rtk for LLM integration"
```

### Multi-Target Init Lifecycle (`src/init.rs`)

The init subsystem in `src/init.rs` now manages two integration surfaces from one entry point:

- Claude Code artifacts live under `~/.claude/` and still use the hook + `settings.json` patch flow.
- opencode artifacts are split by responsibility: plugin scope is selectable (`~/.config/opencode/plugins/rtk-rewrite.ts` or `.opencode/plugins/rtk-rewrite.ts`), while RTK guidance always lives in the global `~/.config/opencode/AGENTS.md` file.

Core lifecycle entry points:

```rust
enum Commands {
Uninstall,
Init {
uninstall: bool,
},
}

// src/main.rs
Commands::Uninstall => init::uninstall(false, cli.verbose)?;
Commands::Init { uninstall: true, .. } => {
eprintln!("Deprecated: `rtk init --uninstall` is a compatibility alias. Use `rtk uninstall` instead.");
init::uninstall(global, cli.verbose)?;
}

fn run_opencode_target(global: bool, verbose: u8) -> Result<SetupTargetOutcome>;
pub fn uninstall(global: bool, verbose: u8) -> Result<()>;
pub fn show_config() -> Result<()>;
fn resolve_opencode_agents_path() -> Result<PathBuf>;
fn resolve_opencode_plugin_path(global: bool) -> Result<PathBuf>;
```

#### CLI routing and shared uninstall path

- `src/main.rs` exposes `Commands::Uninstall` as the canonical top-level teardown surface, so reviewers can map the shipped CLI help directly to the removal workflow.
- `Commands::Uninstall` routes straight into `init::uninstall()`, which keeps Claude cleanup, opencode plugin cleanup, and RTK `AGENTS.md` marker removal in one shared implementation.
- `Commands::Init { uninstall: true }` is preserved only as a deprecated compatibility alias; it prints migration guidance and then calls the same `init::uninstall()` helper, so the legacy flag does not fork behavior.

#### opencode setup flow

`run_opencode_target()` installs the selected plugin file and then updates `~/.config/opencode/AGENTS.md` with a fixed RTK marker block. The two operations are reported back as one `SetupTargetOutcome`, which keeps the final setup summary aligned with the actual filesystem lifecycle.

#### Plugin scope vs AGENTS scope

- Plugin scope is user-selectable because opencode can load RTK from either the global plugin directory or the project-local `.opencode/plugins/` directory.
- AGENTS scope is always global because opencode loads `~/.config/opencode/AGENTS.md` as the shared instruction surface for every session.

This separation is intentional in `src/init.rs`: local plugin installs never create a local `AGENTS.md`, and global AGENTS guidance is preserved even when the plugin itself is project-scoped.

#### Show and uninstall lifecycle

- `show_config()` inspects both supported opencode plugin paths and prints each installed location instead of assuming a single global plugin slot.
- `show_config()` treats `AGENTS.md` as configured only when the RTK marker block is present, not when the file merely exists.
- `rtk uninstall` and the deprecated `rtk init --uninstall` alias both call `init::uninstall()`, so the command surface changed without changing cleanup semantics.
- `uninstall()` removes RTK-managed opencode plugins from every supported plugin scope, then rewrites `~/.config/opencode/AGENTS.md` to remove only the RTK block.
- If the RTK block was the only AGENTS content, uninstall writes back an empty `AGENTS.md` file rather than deleting the file outright.

---

## Module Development Pattern
Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Features

* **hooks:** `exclude_commands` config — exclude specific commands from auto-rewrite ([#243](https://github.com/rtk-ai/rtk/issues/243))
* **init:** extend the opencode lifecycle so `rtk init` can install and report the full reviewer-facing integration surface
- installs the rewrite plugin in the selected opencode plugin scope and adds the RTK guidance block to `~/.config/opencode/AGENTS.md`
- `rtk init --show` reports marker-aware `AGENTS.md` status plus any configured global/local opencode plugin paths
- `rtk uninstall` is the canonical removal command and strips only the RTK-managed plugin copies plus the RTK block from `AGENTS.md`
- `rtk init --uninstall` remains available as a deprecated compatibility alias that routes into the same uninstall implementation

### Bug Fixes

Expand Down Expand Up @@ -337,7 +342,7 @@ breakage, but future rule additions won't take effect until they migrate.
- Idempotent: detects existing hook, skips modification if present
- `rtk init --show` now displays settings.json status
- **Uninstall command** for complete RTK removal
- `rtk init -g --uninstall` removes hook, RTK.md, CLAUDE.md reference, and settings.json entry
- historical init-based uninstall compatibility flow removes the hook, RTK.md, CLAUDE.md reference, and settings.json entry
- Restores clean state for fresh installation or testing
- **Improved error handling** with detailed context messages
- All error messages now include file paths and actionable hints
Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,30 @@ The most effective way to use rtk. The hook transparently intercepts Bash comman
### Setup

```bash
rtk init -g # Install hook + RTK.md (recommended)
rtk init -g # Install Claude hook + RTK.md, and opencode support when selected
rtk init -g --auto-patch # Non-interactive (CI/CD)
rtk init -g --hook-only # Hook only, no RTK.md
rtk init --show # Verify installation
```

After install, **restart Claude Code**.
`rtk init` now starts by asking which setup target to configure: `Claude`, `opencode`, or `both`.

- `Claude` keeps the existing Claude Code lifecycle: `~/.claude/hooks/rtk-rewrite.sh`, `~/.claude/RTK.md`, and the `settings.json` hook entry.
- `opencode` installs the rewrite plugin at `~/.config/opencode/plugins/rtk-rewrite.ts` for global setup, or `.opencode/plugins/rtk-rewrite.ts` when you choose a local plugin during project init.
- opencode setup also appends an RTK-managed guidance block to the global `~/.config/opencode/AGENTS.md`, so opencode sessions keep the RTK behavior guidance even when the plugin lives in a project-local `.opencode/plugins/` directory.

After install, restart Claude Code or opencode before testing rewritten commands.

### Inspect and Remove Installed Files

```bash
rtk init --show # Print Claude hook status, opencode plugin path(s), and AGENTS.md status
rtk uninstall # Remove Claude artifacts, opencode plugin copies, and the RTK AGENTS.md block
```

- `rtk init --show` reports the active opencode lifecycle exactly as installed: global plugin, local plugin, and whether the RTK marker block is present in `~/.config/opencode/AGENTS.md`.
- `rtk uninstall` is the canonical teardown command. It removes RTK-managed opencode plugin files from both supported plugin scopes and strips only the RTK section from `~/.config/opencode/AGENTS.md`; it does not delete unrelated user content from that file.
- `rtk init --uninstall` still works as a deprecated compatibility alias for the same shared uninstall path, but it is no longer the primary reviewer-facing lifecycle command.

### Commands Rewritten

Expand Down Expand Up @@ -338,7 +355,8 @@ FAILED: 2/15 tests
### Uninstall

```bash
rtk init -g --uninstall # Remove hook, RTK.md, settings.json entry
rtk uninstall # Canonical RTK teardown command for Claude + opencode artifacts
rtk init --uninstall # Deprecated compatibility alias for rtk uninstall
cargo uninstall rtk # Remove binary
brew uninstall rtk # If installed via Homebrew
```
Expand Down
208 changes: 208 additions & 0 deletions hooks/rtk-rewrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import type { Plugin } from "@opencode-ai/plugin"
import { spawnSync } from "node:child_process"
import { appendFileSync, existsSync, mkdirSync } from "node:fs"
import { delimiter, dirname, join } from "node:path"

const RTK_CANDIDATES = [
`${process.env.HOME ?? ""}/.cargo/bin/rtk`,
"/usr/local/bin/rtk",
"/opt/homebrew/bin/rtk",
]

const DEBUG_ENABLED = process.env.RTK_OPENCODE_DEBUG === "1"
const DEBUG_FILE = process.env.RTK_OPENCODE_DEBUG_FILE || join(process.env.TMPDIR || "/tmp", "rtk-opencode-debug.log")

type CommandAccessor = {
field: string
get: () => string
set: (value: string) => void
}

function writeDebug(event: string, details: Record<string, string | null> = {}): void {
if (!DEBUG_ENABLED) {
return
}

try {
mkdirSync(dirname(DEBUG_FILE), { recursive: true })
appendFileSync(
DEBUG_FILE,
JSON.stringify({
event,
...details,
ts: new Date().toISOString(),
}) + "\n",
"utf8",
)
} catch {
return
}
}

function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
return [...new Set(values.filter((value): value is string => Boolean(value && value.length > 0)))]
}

function getRtkCandidates(context: { directory: string; worktree: string }): string[] {
const pathCandidates = (process.env.PATH || "")
.split(delimiter)
.filter((segment) => segment.length > 0)
.map((segment) => join(segment, "rtk"))

return uniqueNonEmpty([
...RTK_CANDIDATES,
join(context.directory, "target", "debug", "rtk"),
join(context.directory, "target", "release", "rtk"),
join(context.worktree, "target", "debug", "rtk"),
join(context.worktree, "target", "release", "rtk"),
...pathCandidates,
])
}

function findRtk(context: { directory: string; worktree: string }): string | null {
for (const candidate of getRtkCandidates(context)) {
if (candidate && existsSync(candidate)) {
writeDebug("rtk-candidate", { candidate })
return candidate
}
}

writeDebug("rtk-candidate", { candidate: null })
return null
}

function getCommandAccessor(output: { args?: Record<string, unknown> }): CommandAccessor | null {
if (typeof output.args?.command === "string" && output.args.command.length > 0) {
return {
field: "output.args.command",
get: () => output.args!.command as string,
set: (value: string) => {
output.args!.command = value
},
}
}

if (typeof output.args?.cmd === "string" && output.args.cmd.length > 0) {
return {
field: "output.args.cmd",
get: () => output.args!.cmd as string,
set: (value: string) => {
output.args!.cmd = value
},
}
}

const nestedArgvCommand = output.args?.argv?.command
if (
typeof output.args?.argv === "object" &&
output.args?.argv !== null &&
typeof nestedArgvCommand === "string" &&
nestedArgvCommand.length > 0
) {
return {
field: "output.args.argv.command",
get: () => (output.args!.argv as { command: string }).command,
set: (value: string) => {
;(output.args!.argv as { command: string }).command = value
},
}
}

const nestedBashCommand = output.args?.bash?.command
if (
typeof output.args?.bash === "object" &&
output.args?.bash !== null &&
typeof nestedBashCommand === "string" &&
nestedBashCommand.length > 0
) {
return {
field: "output.args.bash.command",
get: () => (output.args!.bash as { command: string }).command,
set: (value: string) => {
;(output.args!.bash as { command: string }).command = value
},
}
}

return null
}

function setCommandValue(accessor: CommandAccessor, value: string): boolean {
if (!value) {
return false
}

accessor.set(value)
return true
}

export const RtkRewritePlugin: Plugin = async (context) => {
writeDebug("plugin-loaded", { directory: context.directory, worktree: context.worktree })

return {
"tool.execute.before": async (input, output) => {
writeDebug("incoming-tool", { tool: input.tool })

if (input.tool === "bash") {
} else {
return
}

const accessor = getCommandAccessor(output)
if (!accessor) {
writeDebug("command-field", { field: "unsupported-command-shape" })
return
}

writeDebug("command-field", { field: accessor.field })

const original = accessor.get()
if (!original) {
writeDebug("rewrite-result", { outcome: "rewrite-noop", reason: "empty-command" })
return
}

const rtkBin = findRtk(context)
if (!rtkBin) {
writeDebug("rewrite-result", { outcome: "rewrite-noop", reason: "rtk-missing" })
return
}

try {
const result = spawnSync(rtkBin, ["rewrite", original], {
encoding: "utf8",
timeout: 5000,
})

if (result.error || result.signal || result.status !== 0) {
writeDebug("rewrite-result", {
outcome: "rewrite-error",
reason: result.error?.message || result.signal || String(result.status),
})
return
}

const rewritten = result.stdout.trimEnd()
if (!rewritten || rewritten === original) {
writeDebug("rewrite-result", { outcome: "rewrite-noop", reason: "unchanged" })
return
}

if (!setCommandValue(accessor, rewritten)) {
writeDebug("rewrite-result", { outcome: "rewrite-error", reason: "set-failed" })
return
}

writeDebug("rewrite-result", { outcome: "rewritten", field: accessor.field })
} catch (error) {
writeDebug("rewrite-result", {
outcome: "rewrite-error",
reason: error instanceof Error ? error.message : "unknown",
})
return
}
},
}
}

export default RtkRewritePlugin
Loading