feat: add OpenCode platform integration (consolidate ring-for-opencode)

Move OpenCode runtime plugin and installer into Ring monorepo under platforms/opencode/.
The installer reads skills, agents, and commands directly from the Ring monorepo's
canonical directories (default/, dev-team/, pm-team/, etc.) — zero asset duplication.

What's included:
- installer.sh: reads from Ring dirs, applies frontmatter/tool transforms, installs to ~/.config/opencode/
- plugin/: TypeScript runtime (RingUnifiedPlugin) with hooks, lifecycle, loaders
- src/: CLI (doctor, config-manager)
- prompts/: session-start and context-injection templates
- standards/: coding standards (from dev-team/docs/)
- ring.jsonc: default config with full 86-skill/35-agent/33-command inventory

What's NOT included (intentionally):
- assets/ directory: eliminated, content comes from Ring monorepo
- scripts/codereview/: eliminated, replaced by mithril
- using-ring-opencode skill: uses canonical using-ring instead

Transforms applied by installer:
- Agent: type→mode, strip version/changelog/output_schema/input_schema
- Skill: keep name+description frontmatter, body unchanged
- Command: strip argument-hint (unsupported by OpenCode)
- All: normalize tool names (Bash→bash, Read→read, etc.)
- All: strip Model Requirement sections from agents

Replaces: LerianStudio/ring-for-opencode repository
Generated-by: Gandalf
AI-Model: claude-opus-4
This commit is contained in:
Gandalf 2026-03-07 22:46:41 -03:00
parent bc42262597
commit f85aa61440
No known key found for this signature in database
GPG key ID: 4E45E2B33A0134C9
71 changed files with 16209 additions and 0 deletions

View file

@ -0,0 +1,74 @@
# Ring for OpenCode
OpenCode platform integration for the Ring skills library. This directory contains the runtime plugin and installer that brings Ring's skills, agents, and commands to [OpenCode](https://github.com/ohmyopencode/opencode).
## Architecture
```
platforms/opencode/
├── installer.sh # Installer script (reads from Ring monorepo)
├── plugin/ # TypeScript runtime plugin (RingUnifiedPlugin)
│ ├── config/ # Configuration handler and schema
│ ├── hooks/ # Session-start, context-injection hooks
│ ├── lifecycle/ # Event routing
│ ├── loaders/ # Agent, skill, command loaders
│ ├── tools/ # Custom Ring tools
│ └── utils/ # State management
├── prompts/ # Prompt templates (session-start, context-injection)
├── standards/ # Coding standards (from dev-team/docs/standards/)
├── templates/ # Project templates
├── src/ # CLI (doctor, config-manager)
├── ring.jsonc # Default Ring configuration
└── ring-config.schema.json # JSON schema for ring.jsonc
```
## How It Works
The installer reads skills, agents, and commands directly from the Ring monorepo's canonical directories (`default/`, `dev-team/`, `pm-team/`, etc.) and applies transformations for OpenCode compatibility:
1. **Agent transform**: `type``mode`, strip unsupported frontmatter (version, changelog, output_schema), normalize tool names, remove Claude-specific model requirement sections
2. **Skill transform**: Keep `name` and `description` in frontmatter, normalize tool references in body
3. **Command transform**: Strip `argument-hint` (unsupported), normalize tool references
4. **Hooks**: Not installed (OpenCode uses plugin-based hooks incompatible with Ring's file-based hooks)
After transformation, files are installed to `~/.config/opencode/`:
- `agent/*.md` — Agents (35 from 6 Ring plugins)
- `skill/*/SKILL.md` — Skills (86 from 6 Ring plugins)
- `command/*.md` — Commands (33 from 6 Ring plugins)
- `skill/shared-patterns/*.md` — Shared patterns (merged from all plugins)
## Installation
```bash
# From the Ring monorepo root:
./platforms/opencode/installer.sh
# Or with custom config dir:
OPENCODE_CONFIG_DIR=~/.config/opencode ./platforms/opencode/installer.sh
```
## Configuration
User config lives at `~/.config/opencode/ring/config.jsonc`. Use it to disable specific agents, skills, commands, or hooks:
```jsonc
{
"disabled_agents": ["finops-analyzer"],
"disabled_skills": ["regulatory-templates"],
"disabled_commands": []
}
```
## Key Differences from Claude Code
| Feature | Claude Code | OpenCode |
|---------|------------|----------|
| Directory names | Plural (`agents/`, `skills/`) | Singular (`agent/`, `skill/`) |
| Tool names | Capitalized (`Bash`, `Read`) | Lowercase (`bash`, `read`) |
| Hooks | File-based (JSON + scripts) | Plugin-based (TypeScript) |
| Agent type field | `type: reviewer` | `mode: subagent` |
| Argument hints | `argument-hint: "[files]"` | Not supported |
## Previously: ring-for-opencode
This was previously a separate repository (`LerianStudio/ring-for-opencode`). It was consolidated into the Ring monorepo to eliminate content drift and sync overhead. The runtime plugin and installer are maintained here; skills, agents, and commands come from the Ring monorepo's canonical sources.

View file

@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { clearConfigCache, getConfigLayers, loadConfig } from "../../../plugin/config/loader.js"
describe("config/loader", () => {
test("ignores config layers that parse to non-objects", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ring-config-nonobject-"))
const prevXdg = process.env.XDG_CONFIG_HOME
process.env.XDG_CONFIG_HOME = path.join(tmpRoot, "xdg")
try {
// Valid project layer
fs.mkdirSync(path.join(tmpRoot, ".opencode"), { recursive: true })
fs.writeFileSync(
path.join(tmpRoot, ".opencode", "ring.jsonc"),
JSON.stringify({ disabled_hooks: ["session-start"] }),
)
// Invalid local layer (non-object) should be ignored.
fs.mkdirSync(path.join(tmpRoot, ".ring"), { recursive: true })
fs.writeFileSync(path.join(tmpRoot, ".ring", "local.jsonc"), '"oops"')
clearConfigCache()
const config = loadConfig(tmpRoot, true)
expect(config.disabled_hooks).toContain("session-start")
} finally {
process.env.XDG_CONFIG_HOME = prevXdg
fs.rmSync(tmpRoot, { recursive: true, force: true })
}
})
test("resolves user config from XDG_CONFIG_HOME as config.json when config.jsonc is absent", () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ring-config-xdg-"))
const prevXdg = process.env.XDG_CONFIG_HOME
process.env.XDG_CONFIG_HOME = path.join(tmpRoot, "xdg")
try {
const userDir = path.join(tmpRoot, "xdg", "opencode", "ring")
fs.mkdirSync(userDir, { recursive: true })
fs.writeFileSync(
path.join(userDir, "config.json"),
JSON.stringify({ disabled_agents: ["code-reviewer"] }),
)
clearConfigCache()
const config = loadConfig(tmpRoot, true)
expect(config.disabled_agents).toContain("code-reviewer")
const userLayer = getConfigLayers().find((l) => l.name === "user")
expect(userLayer?.exists).toBe(true)
expect(userLayer?.path?.endsWith(path.join("ring", "config.json"))).toBe(true)
} finally {
process.env.XDG_CONFIG_HOME = prevXdg
fs.rmSync(tmpRoot, { recursive: true, force: true })
}
})
})

View file

@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { DEFAULT_RING_CONFIG } from "../../../plugin/config/index.js"
import { createLifecycleRouter } from "../../../plugin/lifecycle/router.js"
import { readState, writeState } from "../../../plugin/utils/state.js"
describe("createLifecycleRouter - session.created", () => {
test("resets state using event sessionID (not env/default session)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ring-router-"))
// Seed two session-specific state files
writeState(tmpDir, "context-usage", { seeded: true }, "event-session")
writeState(tmpDir, "context-usage", { seeded: true }, "other-session")
const router = createLifecycleRouter({
projectRoot: tmpDir,
ringConfig: DEFAULT_RING_CONFIG,
})
await router({
event: {
type: "session.created",
properties: {
sessionID: "event-session",
},
},
})
expect(readState(tmpDir, "context-usage", "event-session")).toBeNull()
expect(readState(tmpDir, "context-usage", "other-session")).not.toBeNull()
})
})

View file

@ -0,0 +1,260 @@
/**
* Tests for placeholder-utils.ts
*
* Tests the placeholder expansion functionality including:
* - Environment variable priority (OPENCODE_CONFIG_DIR > XDG_CONFIG_HOME > default)
* - Input validation
* - Placeholder expansion patterns
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { loadRingAgents } from "../../../plugin/loaders/agent-loader.js"
import {
expandPlaceholders,
getOpenCodeConfigDir,
OPENCODE_CONFIG_PLACEHOLDER,
} from "../../../plugin/loaders/placeholder-utils.js"
describe("placeholder-utils", () => {
// Store original env to restore after each test
let originalEnv: NodeJS.ProcessEnv
beforeEach(() => {
originalEnv = { ...process.env }
})
afterEach(() => {
// Restore original environment
process.env = { ...originalEnv }
})
describe("OPENCODE_CONFIG_PLACEHOLDER constant", () => {
test("has correct value", () => {
expect(OPENCODE_CONFIG_PLACEHOLDER).toBe("{OPENCODE_CONFIG}")
})
})
describe("getOpenCodeConfigDir", () => {
test("uses OPENCODE_CONFIG_DIR when set", () => {
process.env.OPENCODE_CONFIG_DIR = "/custom/config"
delete process.env.XDG_CONFIG_HOME
const result = getOpenCodeConfigDir()
expect(result).toBe("/custom/config")
})
test("falls back to XDG_CONFIG_HOME/opencode when OPENCODE_CONFIG_DIR unset", () => {
delete process.env.OPENCODE_CONFIG_DIR
process.env.XDG_CONFIG_HOME = "/xdg/home"
const result = getOpenCodeConfigDir()
expect(result).toBe("/xdg/home/opencode")
})
test("ignores XDG_CONFIG_HOME if not absolute path", () => {
delete process.env.OPENCODE_CONFIG_DIR
process.env.XDG_CONFIG_HOME = "relative/path"
const result = getOpenCodeConfigDir()
// Should fall back to default, not use relative XDG path
expect(result).toContain(".config/opencode")
expect(result).not.toContain("relative/path")
})
test("defaults to ~/.config/opencode when both env vars unset", () => {
delete process.env.OPENCODE_CONFIG_DIR
delete process.env.XDG_CONFIG_HOME
const result = getOpenCodeConfigDir()
// Should contain home directory and .config/opencode
expect(result).toContain(".config")
expect(result).toContain("opencode")
expect(result).toBe(path.join(os.homedir(), ".config", "opencode"))
})
test("OPENCODE_CONFIG_DIR takes priority over XDG_CONFIG_HOME", () => {
process.env.OPENCODE_CONFIG_DIR = "/explicit/override"
process.env.XDG_CONFIG_HOME = "/xdg/should/not/use"
const result = getOpenCodeConfigDir()
expect(result).toBe("/explicit/override")
})
})
describe("expandPlaceholders", () => {
test("expands single placeholder", () => {
process.env.OPENCODE_CONFIG_DIR = "/test"
const result = expandPlaceholders("Read {OPENCODE_CONFIG}/standards/go.md")
expect(result).toBe("Read /test/standards/go.md")
})
test("expands multiple placeholders", () => {
process.env.OPENCODE_CONFIG_DIR = "/test"
const result = expandPlaceholders("{OPENCODE_CONFIG}/a and {OPENCODE_CONFIG}/b")
expect(result).toBe("/test/a and /test/b")
})
test("handles empty string", () => {
const result = expandPlaceholders("")
expect(result).toBe("")
})
test("passes through string without placeholders", () => {
const result = expandPlaceholders("No placeholders here")
expect(result).toBe("No placeholders here")
})
test("handles placeholder at string start", () => {
process.env.OPENCODE_CONFIG_DIR = "/test"
const result = expandPlaceholders("{OPENCODE_CONFIG}/end")
expect(result).toBe("/test/end")
})
test("handles placeholder at string end", () => {
process.env.OPENCODE_CONFIG_DIR = "/test"
const result = expandPlaceholders("start/{OPENCODE_CONFIG}")
expect(result).toBe("start//test")
})
test("handles placeholder as entire string", () => {
process.env.OPENCODE_CONFIG_DIR = "/test"
const result = expandPlaceholders("{OPENCODE_CONFIG}")
expect(result).toBe("/test")
})
test("handles null input gracefully", () => {
// @ts-expect-error - Testing runtime behavior with invalid input
const result = expandPlaceholders(null)
expect(result).toBe("")
})
test("handles undefined input gracefully", () => {
// @ts-expect-error - Testing runtime behavior with invalid input
const result = expandPlaceholders(undefined)
expect(result).toBe("")
})
test("handles non-string input gracefully", () => {
// @ts-expect-error - Testing runtime behavior with invalid input
const result = expandPlaceholders(123)
expect(result).toBe("")
})
test("expands with XDG_CONFIG_HOME fallback", () => {
delete process.env.OPENCODE_CONFIG_DIR
process.env.XDG_CONFIG_HOME = "/xdg/config"
const result = expandPlaceholders("Path: {OPENCODE_CONFIG}/file.md")
expect(result).toBe("Path: /xdg/config/opencode/file.md")
})
test("expands with default fallback", () => {
delete process.env.OPENCODE_CONFIG_DIR
delete process.env.XDG_CONFIG_HOME
const result = expandPlaceholders("{OPENCODE_CONFIG}/test")
const expected = path.join(os.homedir(), ".config", "opencode", "test")
expect(result).toBe(expected)
})
})
describe("integration: agent-loader placeholder expansion", () => {
// Helper to create temporary directories
function mkdtemp(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
}
function writeFile(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content, "utf-8")
}
test("agent loader expands placeholders in prompts", () => {
const tmp = mkdtemp("ring-placeholder-int-")
try {
const pluginRoot = path.join(tmp, "plugin")
const projectRoot = path.join(tmp, "project")
// Create an agent with placeholder in prompt
const agentContent = `---
description: Test agent with placeholder
mode: subagent
---
Read the standards from {OPENCODE_CONFIG}/standards/golang.md
Then apply them.
`
writeFile(path.join(pluginRoot, "assets", "agent", "test-agent.md"), agentContent)
// Set a known config dir for testing
process.env.OPENCODE_CONFIG_DIR = "/test/config/dir"
const agents = loadRingAgents(pluginRoot, projectRoot)
// Verify agent was loaded and placeholder was expanded
expect(agents["ring:test-agent"]).toBeDefined()
expect(agents["ring:test-agent"].prompt).toContain("/test/config/dir/standards/golang.md")
expect(agents["ring:test-agent"].prompt).not.toContain("{OPENCODE_CONFIG}")
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("agent loader expands multiple placeholders in prompt", () => {
const tmp = mkdtemp("ring-placeholder-multi-")
try {
const pluginRoot = path.join(tmp, "plugin")
const projectRoot = path.join(tmp, "project")
// Create an agent with multiple placeholders
const agentContent = `---
description: Test agent with multiple placeholders
---
First: {OPENCODE_CONFIG}/file1.md
Second: {OPENCODE_CONFIG}/file2.md
`
writeFile(path.join(pluginRoot, "assets", "agent", "multi-placeholder.md"), agentContent)
process.env.OPENCODE_CONFIG_DIR = "/multi/test"
const agents = loadRingAgents(pluginRoot, projectRoot)
expect(agents["ring:multi-placeholder"]).toBeDefined()
expect(agents["ring:multi-placeholder"].prompt).toBe(
"First: /multi/test/file1.md\nSecond: /multi/test/file2.md",
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test("agent loader handles prompts without placeholders", () => {
const tmp = mkdtemp("ring-placeholder-none-")
try {
const pluginRoot = path.join(tmp, "plugin")
const projectRoot = path.join(tmp, "project")
// Create an agent without placeholders
const agentContent = `---
description: Agent without placeholders
---
This prompt has no placeholders at all.
`
writeFile(path.join(pluginRoot, "assets", "agent", "no-placeholder.md"), agentContent)
const agents = loadRingAgents(pluginRoot, projectRoot)
expect(agents["ring:no-placeholder"]).toBeDefined()
expect(agents["ring:no-placeholder"].prompt).toBe("This prompt has no placeholders at all.")
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
})
})

View file

@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { deepMerge } from "../../plugin/config/loader.js"
import { loadRingAgents } from "../../plugin/loaders/agent-loader.js"
import { loadRingCommands } from "../../plugin/loaders/command-loader.js"
import { loadRingSkills } from "../../plugin/loaders/skill-loader.js"
function mkdtemp(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
}
function writeFile(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content, "utf-8")
}
describe("security hardening", () => {
test("deepMerge blocks prototype pollution (__proto__/constructor/prototype)", () => {
const protoBefore = ({} as Record<string, unknown>).polluted
// JSON.parse ensures __proto__ is treated as a plain enumerable key.
const source = JSON.parse(
'{"__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}},"prototype":{"polluted":true},"safe":{"__proto__":{"pollutedNested":true},"b":2}}',
) as Record<string, unknown>
try {
const merged = deepMerge({ safe: { a: 1 } }, source as unknown as Partial<{ safe: unknown }>)
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
expect(({} as Record<string, unknown>).pollutedNested).toBeUndefined()
expect(protoBefore).toBeUndefined()
expect(merged).toMatchObject({ safe: { a: 1, b: 2 } })
} finally {
// In case of regression, clean global prototype to avoid cascading failures.
delete (Object.prototype as unknown as Record<string, unknown>).polluted
delete (Object.prototype as unknown as Record<string, unknown>).pollutedNested
}
})
test("loaders building maps from filenames use null-prototype objects and skip forbidden keys", () => {
const tmp = mkdtemp("ring-sec-loaders-")
try {
const pluginRoot = path.join(tmp, "plugin")
const projectRoot = path.join(tmp, "project")
// commands
writeFile(path.join(pluginRoot, "assets", "command", "ok.md"), "# ok")
writeFile(path.join(pluginRoot, "assets", "command", "__proto__.md"), "# bad")
const { commands } = loadRingCommands(pluginRoot, projectRoot)
expect(Object.getPrototypeOf(commands)).toBeNull()
expect(Object.keys(commands)).toEqual(["ring:ok"])
// agents
writeFile(
path.join(pluginRoot, "assets", "agent", "good.md"),
"---\ndescription: good\n---\nhello",
)
writeFile(path.join(pluginRoot, "assets", "agent", "constructor.md"), "---\n---\nnope")
const agents = loadRingAgents(pluginRoot, projectRoot)
expect(Object.getPrototypeOf(agents)).toBeNull()
expect(Object.keys(agents)).toEqual(["ring:good"])
// skills
writeFile(path.join(pluginRoot, "assets", "skill", "skill-1", "SKILL.md"), "# skill")
writeFile(
path.join(pluginRoot, "assets", "skill", "__proto__", "SKILL.md"),
"# should be skipped",
)
const skills = loadRingSkills(pluginRoot, projectRoot)
expect(Object.getPrototypeOf(skills)).toBeNull()
expect(Object.keys(skills)).toEqual(["ring:skill-1"])
// ensure no global pollution from attempting to set __proto__ keys
expect(({} as Record<string, unknown>).ok).toBeUndefined()
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
})

View file

@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import { RingOpenCodeConfigSchema } from "../../../src/config/schema"
describe("RingOpenCodeConfigSchema - backwards compatible aliases", () => {
test("accepts canonical keys (agent/permission)", () => {
const result = RingOpenCodeConfigSchema.safeParse({
agent: {
"code-reviewer": {
model: "anthropic/claude-sonnet-4-20250514",
},
},
permission: {
edit: "allow",
bash: "ask",
},
})
expect(result.success).toBe(true)
if (!result.success) return
expect(result.data.agent?.["code-reviewer"]?.model).toBe("anthropic/claude-sonnet-4-20250514")
expect(result.data.permission?.edit).toBe("allow")
})
test("accepts alias keys (agents/permissions) and normalizes to canonical", () => {
const result = RingOpenCodeConfigSchema.safeParse({
agents: {
"write-plan": {
model: "anthropic/claude-opus-4-5-20251101",
},
},
permissions: {
edit: "deny",
},
})
expect(result.success).toBe(true)
if (!result.success) return
// canonical keys present
expect(result.data.agent?.["write-plan"]?.model).toBe("anthropic/claude-opus-4-5-20251101")
expect(result.data.permission?.edit).toBe("deny")
// aliases removed from parsed output
expect("agents" in (result.data as Record<string, unknown>)).toBe(false)
expect("permissions" in (result.data as Record<string, unknown>)).toBe(false)
})
test("fails when both canonical and alias keys are present (agent + agents)", () => {
const result = RingOpenCodeConfigSchema.safeParse({
agent: { "code-reviewer": { model: "x" } },
agents: { "code-reviewer": { model: "y" } },
})
expect(result.success).toBe(false)
})
test("fails when both canonical and alias keys are present (permission + permissions)", () => {
const result = RingOpenCodeConfigSchema.safeParse({
permission: { edit: "allow" },
permissions: { edit: "deny" },
})
expect(result.success).toBe(false)
})
})

View file

@ -0,0 +1,57 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "warn"
},
"style": {
"noNonNullAssertion": "warn",
"useConst": "error"
},
"complexity": {
"noForEach": "off"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded"
}
},
"files": {
"includes": [
"**",
"!**/node_modules",
"!**/dist",
"!**/.references",
"!**/.opencode/state",
"!**/*.md",
"!**/*.json",
"!**/*.schema.json",
"!**/testdata"
]
},
"overrides": [
{
"includes": ["assets/skill/visual-explainer/templates/**/*.html"],
"linter": {
"rules": {
"complexity": {
"noImportantStyles": "off"
}
}
}
}
]
}

View file

@ -0,0 +1,52 @@
# Session Handoff: Code Review Pipeline Fixes
**Session Name:** codereview-fixes
**Timestamp:** 2026-01-18 14:41:44
**Branch:** main
**Commit:** 5c0e640cb4e47d76b4490887e77986db10362000
## 1. Task Summary
Comprehensive remediation of issues identified by the `ring:code-reviewer` suite in the `scripts/codereview/` directory. The primary focus was enabling polyglot support (mixed language PRs), fixing non-deterministic behavior, improving test coverage, and enforcing safety/style standards.
**Status:** Completed (Critical, High, and most Medium issues resolved).
## 2. Critical References
- `scripts/codereview/internal/scope/scope.go`: Core language detection logic (updated to support `LanguageMixed`).
- `scripts/codereview/cmd/run-all/main.go`: Orchestrator logic (updated to handle mixed languages).
- `scripts/codereview/cmd/static-analysis/main.go`: Linter dispatch (updated to select linters for mixed languages).
- `scripts/codereview/internal/callgraph/golang_test.go`: New tests for Call Graph analyzer.
- `scripts/codereview/ts/call-graph.ts`: TypeScript call graph analysis (fixed light mode fallback).
## 3. Recent Changes
### Business Logic (Polyglot Support)
- **`internal/scope`**: Introduced `LanguageMixed`. `DetectLanguage` now returns `LanguageMixed` instead of `LanguageUnknown` when multiple languages are found. Added `DetectLanguages` to return a list of all detected languages.
- **`cmd/run-all`**: Removed `shouldSkipForUnknownLanguage` in favor of `shouldSkipForNoFiles`. Updated AST phase to iterate over detected languages when scope is mixed.
- **`cmd/static-analysis`**: Updated to detect all available linters when language is `mixed`.
- **`internal/scope/reader.go`**: Updated `ScopeJSON` struct to include `Languages []string`.
### Security & Determinism
- **`py/ast_extractor.py`**: Replaced Python's built-in `hash()` (randomized) with `hashlib.sha256()` for deterministic AST body hashes.
- **`cmd/ast-extractor/main.go`**: Added path validation using `ast.NewPathValidator` for CLI arguments.
### Test Quality
- **`internal/callgraph/golang_test.go`**: Added `TestAnalyze_GoAnalyzer_Basic` to cover the previously untested `Analyze` logic in `golang.go`.
- **`cmd/scope-detector/main_test.go`** & **`internal/output/json_test.go`**: Updated tests to reflect the new `Languages` field in JSON output.
### Code Quality & Maintenance
- **`internal/ast/golang.go`**: Added `ctx.Done()` checks in `ExtractDiff` to support cancellation.
- **`internal/git/git.go`**: Added defensive map initialization in `parseNumstat` error paths.
- **`ts/call-graph.ts`**: Fixed logic for "light mode" (AST-only) vs full type-checking. Explicitly handles `typeChecker` availability.
- **Emoji Removal**: Removed emojis (:warning:, :rotating_light:, etc.) from Markdown outputs (`internal/output/markdown.go`, `internal/dataflow/report.go`) to comply with style guides.
## 4. Learnings
- **Polyglot Design**: The original "single language per PR" assumption was too rigid. Promoting `LanguageMixed` and adding a secondary `Languages` list allowed preserving backward compatibility while enabling multi-language analysis.
- **Test Fragility**: Regex-based tests in `markdown_test.go` broke when removing emojis. Future output tests should perhaps rely more on structured data or be less sensitive to cosmetic formatting.
- **TypeScript Interop**: Passing state between Go orchestrator and TS scripts requires robust error code/message handling (e.g., the "Light mode disabled" error propagation).
## 5. Action Items
1. **Verify End-to-End**: Run `scripts/codereview/bin/run-all` on a mixed-language commit (e.g., Go + TS) to verify the integration works in a real environment.
2. **Data Flow Refactoring**: The Python data flow analyzer (`py/data_flow.py`) still relies on regex matching. Consider evaluating true AST-based tainting libraries for future improvements (Medium severity issue).
3. **TS Performance**: Monitor memory usage of `call-graph.ts` on large repos now that it's more active.
4. **Rename**: Consider renaming `py/data_flow.py` to `py/pattern_matcher.py` if the functionality isn't expanded to true data flow analysis.

View file

@ -0,0 +1,142 @@
---
date: 2026-01-12T22:39:46Z
session_name: ring-simplification
git_commit: b40c18940df018708b70646fea0d539b7758fc5b
branch: main
repository: ring-for-opencode
topic: "Ring Plugin Simplification"
tags: [refactoring, simplification, prompts, externalization]
status: complete
outcome: UNKNOWN
root_span_id:
turn_span_id:
---
# Handoff: Ring Plugin Simplification - Remove Orchestrator/Background/Notifications
## Task Summary
Major simplification of the Ring plugin by removing complex orchestration features and externalizing hardcoded prompts.
**Completed:**
1. Removed `plugin/background/` module (async task management, concurrency control)
2. Removed `plugin/orchestrator/` module (worker pools, job registry, workflow engine, task tools)
3. Removed notification hook (cross-platform desktop notifications)
4. Removed task-completion hook (todo state tracking)
5. Removed ledger features from session-start and context-injection hooks
6. Externalized 8 hardcoded prompts to `assets/prompts/` folder
7. Made skills/commands/agents references programmatic (generated from actual loaded assets)
## Critical References
- `plugin/ring-unified.ts` - Main plugin entry point (simplified)
- `plugin/hooks/factories/context-injection.ts` - Programmatic reference generation
- `assets/prompts/` - Externalized prompt files
## Recent Changes
### Deleted Folders
- `plugin/background/` - Entire module removed
- `plugin/orchestrator/` - Entire module removed
- `__tests__/plugin/background/` - Tests for removed module
- `__tests__/plugin/orchestrator/` - Tests for removed module
### Deleted Files
- `plugin/hooks/factories/notification.ts`
- `plugin/hooks/factories/task-completion.ts`
- `__tests__/plugin/hooks/task-completion-notification.integration.test.ts`
- `assets/prompts/context-injection/agents-reference.txt` (now programmatic)
- `assets/prompts/context-injection/skills-reference.txt` (now programmatic)
- `assets/prompts/context-injection/commands-reference.txt` (now programmatic)
### Modified Files
- `plugin/ring-unified.ts` - Removed background manager, simplified event handling
- `plugin/tools/index.ts` - Returns empty object (no orchestrator tools)
- `plugin/config/schema.ts` - Removed BackgroundTaskConfig, NotificationConfig, orchestrator schemas
- `plugin/config/loader.ts` - Removed getBackgroundTaskConfig(), getNotificationConfig()
- `plugin/config/index.ts` - Cleaned up exports
- `plugin/hooks/factories/index.ts` - Removed notification/task-completion exports
- `plugin/hooks/factories/session-start.ts` - Removed ledger loading, externalized prompts
- `plugin/hooks/factories/context-injection.ts` - Removed ledger summary, externalized prompts, programmatic references
- `plugin/hooks/types.ts` - Removed notification, task-completion from HookName
- `__tests__/plugin/security-hardening.test.ts` - Removed notification/ledger tests
### Created Files
- `assets/prompts/session-start/critical-rules.txt`
- `assets/prompts/session-start/agent-reminder.txt`
- `assets/prompts/session-start/duplication-guard.txt`
- `assets/prompts/session-start/doubt-questions.txt`
- `assets/prompts/context-injection/compact-rules.txt`
## Learnings
### What Worked
- **Incremental deletion approach** - Removing modules one by one and fixing imports after each helped catch issues early
- **Subagent delegation** - Using subagents for multi-file changes was efficient
- **Prompt externalization pattern** - Content in .txt files, XML wrappers in TypeScript code provides good separation
### What Failed
- **Initial test run** - Forgot to delete test files for removed modules, causing test failures
- **Security test file** - Had to manually update to remove references to deleted notification functions
### Key Decisions
- Decision: **Centralized prompts in `assets/prompts/`** instead of distributed per-module
- Alternatives: `plugin/hooks/factories/prompts/` folder
- Reason: Consistent with existing `assets/` structure (agents, skills, commands)
- Decision: **Programmatic reference generation** for skills/commands/agents
- Alternatives: Keep static text files
- Reason: References auto-update when assets change, includes user customizations
- Decision: **Keep XML wrapper tags in TypeScript**
- Alternatives: Include tags in .txt files
- Reason: Enforces structure, allows graceful fallback if file missing
## Files Modified
**Deleted (~2,500+ lines removed):**
- `plugin/background/` - 4 files (manager, concurrency, types, index)
- `plugin/orchestrator/` - 8+ files (config, profiles, jobs, worker-pool, workflow/engine, tools/task-tools, types, index)
- `plugin/hooks/factories/notification.ts`
- `plugin/hooks/factories/task-completion.ts`
**Created:**
- `assets/prompts/session-start/critical-rules.txt`
- `assets/prompts/session-start/agent-reminder.txt`
- `assets/prompts/session-start/duplication-guard.txt`
- `assets/prompts/session-start/doubt-questions.txt`
- `assets/prompts/context-injection/compact-rules.txt`
**Modified:**
- `plugin/ring-unified.ts` - Simplified (removed ~50 lines)
- `plugin/tools/index.ts` - Now returns empty object
- `plugin/config/schema.ts` - Removed 3 schemas
- `plugin/hooks/factories/context-injection.ts` - Added programmatic generators
## Action Items & Next Steps
1. **Consider further simplification** - Review remaining hooks for necessity
2. **Update documentation** - AGENTS.md may need updates to reflect removed features
3. **Test in real usage** - Verify the simplified plugin works correctly with opencode
4. **Consider removing utils/state.ts functions** - Some functions (findMostRecentFile, readFileSafe) may now be unused
## Other Notes
**Final structure of assets/prompts/:**
```
assets/prompts/
├── session-start/
│ ├── critical-rules.txt
│ ├── agent-reminder.txt
│ ├── duplication-guard.txt
│ └── doubt-questions.txt
└── context-injection/
└── compact-rules.txt
(skills/commands/agents refs are now programmatic)
```
**Verification commands:**
```bash
npm run lint # 48 files, no issues
npm run build # Bundles successfully
npm test # 9 tests pass
```

588
platforms/opencode/installer.sh Executable file
View file

@ -0,0 +1,588 @@
#!/usr/bin/env bash
set -euo pipefail
# Ring → OpenCode Installer (Monorepo Edition)
#
# Installs Ring skills, agents, commands, and the OpenCode plugin
# from the Ring monorepo into ~/.config/opencode.
#
# Instead of maintaining separate assets/, this installer reads directly
# from the Ring monorepo's canonical directories (default/, dev-team/, etc.)
# and applies necessary transformations for OpenCode compatibility.
#
# Architecture:
# - Reads from Ring monorepo plugin dirs (default/, dev-team/, pm-team/, etc.)
# - Applies frontmatter transforms (type→mode, strip unsupported fields)
# - Normalizes tool names (Bash→bash, Read→read, etc.)
# - Installs the OpenCode plugin runtime (TypeScript)
# - Merges dependencies into package.json
#
# Behavior:
# - Copies (overwrites) only Ring-managed files
# - NEVER deletes unknown files in the target directory
# - Backs up overwritten files to ~/.config/opencode/.ring-backups/<timestamp>/
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RING_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
TARGET_ROOT="${OPENCODE_CONFIG_DIR:-"$HOME/.config/opencode"}"
# Ring plugin directories (order matters for conflict resolution — last wins)
RING_PLUGINS=(default dev-team pm-team pmo-team tw-team finops-team)
# Validate TARGET_ROOT
if [[ -z "$TARGET_ROOT" || "$TARGET_ROOT" != /* ]]; then
echo "ERROR: Cannot determine config directory. HOME is not set or TARGET_ROOT is not absolute." >&2
exit 1
fi
# Colors
if [[ -t 1 ]] && command -v tput &>/dev/null; then
GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); BLUE=$(tput setaf 4); RED=$(tput setaf 1); RESET=$(tput sgr0)
else
GREEN="" YELLOW="" BLUE="" RED="" RESET=""
fi
echo "${BLUE}================================================${RESET}"
echo "${BLUE}Ring → OpenCode Installer (Monorepo)${RESET}"
echo "${BLUE}================================================${RESET}"
echo ""
echo "Ring root: $RING_ROOT"
echo "Target: $TARGET_ROOT"
echo ""
# Node version check
check_node_version() {
if ! command -v node >/dev/null 2>&1; then
echo "${YELLOW}WARN: Node.js not found. Will attempt to use bun.${RESET}" >&2
return 0
fi
local node_version
node_version=$(node -v | sed 's/^v//' | cut -d. -f1)
if [[ "$node_version" -lt 18 ]]; then
echo "${RED}ERROR: Node.js $node_version too old. Requires 18+.${RESET}" >&2
exit 1
fi
}
check_node_version
mkdir -p "$TARGET_ROOT"
STAMP="$(date -u +"%Y%m%dT%H%M%SZ")"
BACKUP_DIR="$TARGET_ROOT/.ring-backups/$STAMP"
mkdir -p "$BACKUP_DIR"
# ============================================================
# TRANSFORM FUNCTIONS
# ============================================================
# Transform agent frontmatter for OpenCode compatibility
# - type: reviewer/subagent → mode: subagent
# - Strip unsupported fields (version, changelog, last_updated, output_schema, input_schema)
# - Normalize tool names
transform_agent() {
local input="$1"
python3 -c "
import sys, re
content = open('$input', 'r').read()
# Split frontmatter and body
if content.startswith('---'):
end = content.find('---', 3)
if end != -1:
fm = content[3:end].strip()
body = content[end+3:].strip()
else:
fm = ''
body = content
else:
fm = ''
body = content
# Parse frontmatter lines
fm_lines = fm.split('\n') if fm else []
new_fm = []
in_multiline = False
multiline_key = ''
skip_keys = {'version', 'changelog', 'last_updated', 'output_schema', 'input_schema',
'type', 'project_rules_integration'}
keep_keys = {'name', 'description', 'mode', 'model', 'tools', 'color', 'hidden',
'subtask', 'temperature', 'maxSteps', 'permission'}
has_mode = False
agent_type = None
for line in fm_lines:
# Detect multiline values (indented continuation)
if in_multiline:
if line.startswith(' ') or line.startswith('\t') or line.strip().startswith('- '):
# Skip multiline content of skipped keys
if multiline_key in skip_keys:
continue
new_fm.append(line)
continue
else:
in_multiline = False
# Parse key
colon = line.find(':')
if colon == -1:
new_fm.append(line)
continue
key = line[:colon].strip()
value = line[colon+1:].strip()
if key == 'type':
agent_type = value.strip('\"').strip(\"'\")
continue
if key == 'mode':
has_mode = True
if key in skip_keys:
# Check if multiline
if value == '' or value == '|' or value == '>':
in_multiline = True
multiline_key = key
continue
if key in keep_keys:
new_fm.append(line)
# Check if multiline
if value == '' or value == '|' or value == '>':
in_multiline = True
multiline_key = key
# else: strip unknown keys
# Add mode from type if not already present
if not has_mode and agent_type:
if agent_type in ('reviewer', 'subagent'):
new_fm.append('mode: subagent')
elif agent_type == 'primary':
new_fm.append('mode: primary')
else:
new_fm.append('mode: subagent')
elif not has_mode:
new_fm.append('mode: subagent')
# Strip Model Requirement section from body
body = re.sub(r'## ⚠️ Model Requirement[^\n]*\n.*?\n---\n', '', body, flags=re.DOTALL)
body = re.sub(r'\n{3,}', '\n\n', body).strip()
# Normalize tool references in body
tool_map = {
'Bash': 'bash', 'Read': 'read', 'Write': 'write', 'Edit': 'edit',
'List': 'list', 'Glob': 'glob', 'Grep': 'grep', 'WebFetch': 'webfetch',
'Task': 'task', 'TodoWrite': 'todowrite', 'TodoRead': 'todoread',
'MultiEdit': 'edit', 'NotebookEdit': 'edit', 'BrowseURL': 'webfetch',
'FetchURL': 'webfetch',
}
for claude_name, oc_name in tool_map.items():
body = re.sub(rf'\b{claude_name}\b(?=\s+tool|\s+command)', oc_name, body, flags=re.IGNORECASE)
# Reconstruct
fm_str = '\n'.join(new_fm)
if fm_str.strip():
print(f'---\n{fm_str}\n---\n\n{body}\n')
else:
print(f'{body}\n')
"
}
# Transform skill frontmatter for OpenCode compatibility
# - Keep name, description (OpenCode loaders only read these)
# - The body (including trigger, skip_when as prose) is the actual skill content
transform_skill() {
local input="$1"
python3 -c "
import sys, re
content = open('$input', 'r').read()
if content.startswith('---'):
end = content.find('---', 3)
if end != -1:
fm = content[3:end].strip()
body = content[end+3:].strip()
else:
fm = ''
body = content
else:
fm = ''
body = content
# Parse and filter frontmatter
fm_lines = fm.split('\n') if fm else []
new_fm = []
in_multiline = False
multiline_key = ''
# OpenCode skill loader only uses: name, description, agent, subtask
# We keep them all since extra fields are harmlessly ignored
# but we reorganize trigger/skip_when into description context
keep_keys = {'name', 'description'}
for line in fm_lines:
if in_multiline:
if line.startswith(' ') or line.startswith('\t') or line.strip().startswith('- '):
if multiline_key in keep_keys:
new_fm.append(line)
continue
else:
in_multiline = False
colon = line.find(':')
if colon == -1:
continue
key = line[:colon].strip()
value = line[colon+1:].strip()
if key in keep_keys:
new_fm.append(line)
if value == '' or value == '|' or value == '>':
in_multiline = True
multiline_key = key
# Normalize tool references
tool_map = {
'Bash': 'bash', 'Read': 'read', 'Write': 'write', 'Edit': 'edit',
'List': 'list', 'Glob': 'glob', 'Grep': 'grep', 'WebFetch': 'webfetch',
'Task': 'task', 'TodoWrite': 'todowrite', 'TodoRead': 'todoread',
}
for claude_name, oc_name in tool_map.items():
body = re.sub(rf'\b{claude_name}\b(?=\s+tool|\s+command)', oc_name, body, flags=re.IGNORECASE)
fm_str = '\n'.join(new_fm)
if fm_str.strip():
print(f'---\n{fm_str}\n---\n\n{body}\n')
else:
print(f'{body}\n')
"
}
# Transform command frontmatter for OpenCode compatibility
# - Strip argument-hint (not supported)
# - Keep name, description, agent, subtask
transform_command() {
local input="$1"
python3 -c "
import sys, re
content = open('$input', 'r').read()
if content.startswith('---'):
end = content.find('---', 3)
if end != -1:
fm = content[3:end].strip()
body = content[end+3:].strip()
else:
fm = ''
body = content
else:
fm = ''
body = content
fm_lines = fm.split('\n') if fm else []
new_fm = []
keep_keys = {'name', 'description', 'agent', 'subtask', 'model'}
for line in fm_lines:
colon = line.find(':')
if colon == -1:
continue
key = line[:colon].strip()
if key in keep_keys:
new_fm.append(line)
# Normalize tool references
tool_map = {
'Bash': 'bash', 'Read': 'read', 'Write': 'write', 'Edit': 'edit',
'List': 'list', 'Glob': 'glob', 'Grep': 'grep', 'WebFetch': 'webfetch',
'Task': 'task', 'TodoWrite': 'todowrite', 'TodoRead': 'todoread',
}
for claude_name, oc_name in tool_map.items():
body = re.sub(rf'\b{claude_name}\b(?=\s+tool|\s+command)', oc_name, body, flags=re.IGNORECASE)
# Replace mithril/codereview binary references with just mithril
body = re.sub(r'\\\$BINARY.*?run-all\"?\n?', 'mithril', body)
fm_str = '\n'.join(new_fm)
if fm_str.strip():
print(f'---\n{fm_str}\n---\n\n{body}\n')
else:
print(f'{body}\n')
"
}
# Expand {OPENCODE_CONFIG} placeholder
expand_placeholders() {
local file="$1"
local config_dir
config_dir="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}"
local escaped
escaped=$(printf '%s\n' "$config_dir" | sed 's/[&/\|]/\\&/g')
if sed --version >/dev/null 2>&1; then
sed -i "s|{OPENCODE_CONFIG}|$escaped|g" "$file"
else
sed -i '' "s|{OPENCODE_CONFIG}|$escaped|g" "$file"
fi
}
# ============================================================
# INSTALL AGENTS
# ============================================================
echo "${GREEN}Installing agents...${RESET}"
AGENT_TARGET="$TARGET_ROOT/agent"
mkdir -p "$AGENT_TARGET"
agent_count=0
for plugin in "${RING_PLUGINS[@]}"; do
agent_dir="$RING_ROOT/$plugin/agents"
[[ -d "$agent_dir" ]] || continue
for agent_file in "$agent_dir"/*.md; do
[[ -f "$agent_file" ]] || continue
agent_name=$(basename "$agent_file")
# Backup if exists
if [[ -f "$AGENT_TARGET/$agent_name" ]]; then
mkdir -p "$BACKUP_DIR/agent"
cp "$AGENT_TARGET/$agent_name" "$BACKUP_DIR/agent/$agent_name"
fi
transform_agent "$agent_file" > "$AGENT_TARGET/$agent_name"
((agent_count++))
done
done
echo " Installed $agent_count agents"
# ============================================================
# INSTALL SKILLS
# ============================================================
echo "${GREEN}Installing skills...${RESET}"
SKILL_TARGET="$TARGET_ROOT/skill"
mkdir -p "$SKILL_TARGET"
skill_count=0
for plugin in "${RING_PLUGINS[@]}"; do
skill_dir="$RING_ROOT/$plugin/skills"
[[ -d "$skill_dir" ]] || continue
for skill_path in "$skill_dir"/*/; do
[[ -d "$skill_path" ]] || continue
skill_name=$(basename "$skill_path")
# Skip shared-patterns (they're referenced by skills, not loaded as skills)
[[ "$skill_name" == "shared-patterns" ]] && continue
skill_file="$skill_path/SKILL.md"
[[ -f "$skill_file" ]] || continue
target_skill_dir="$SKILL_TARGET/$skill_name"
mkdir -p "$target_skill_dir"
# Backup if exists
if [[ -f "$target_skill_dir/SKILL.md" ]]; then
mkdir -p "$BACKUP_DIR/skill/$skill_name"
cp "$target_skill_dir/SKILL.md" "$BACKUP_DIR/skill/$skill_name/SKILL.md"
fi
transform_skill "$skill_file" > "$target_skill_dir/SKILL.md"
# Expand placeholders
if grep -q "{OPENCODE_CONFIG}" "$target_skill_dir/SKILL.md" 2>/dev/null; then
expand_placeholders "$target_skill_dir/SKILL.md"
fi
((skill_count++))
done
done
# Install shared-patterns (merged from all plugins)
echo " Installing shared-patterns..."
SHARED_TARGET="$SKILL_TARGET/shared-patterns"
mkdir -p "$SHARED_TARGET"
shared_count=0
for plugin in "${RING_PLUGINS[@]}"; do
shared_dir="$RING_ROOT/$plugin/skills/shared-patterns"
[[ -d "$shared_dir" ]] || continue
for pattern_file in "$shared_dir"/*.md; do
[[ -f "$pattern_file" ]] || continue
pattern_name=$(basename "$pattern_file")
cp "$pattern_file" "$SHARED_TARGET/$pattern_name"
((shared_count++))
done
done
echo " Installed $skill_count skills + $shared_count shared patterns"
# ============================================================
# INSTALL COMMANDS
# ============================================================
echo "${GREEN}Installing commands...${RESET}"
CMD_TARGET="$TARGET_ROOT/command"
mkdir -p "$CMD_TARGET"
cmd_count=0
for plugin in "${RING_PLUGINS[@]}"; do
cmd_dir="$RING_ROOT/$plugin/commands"
[[ -d "$cmd_dir" ]] || continue
for cmd_file in "$cmd_dir"/*.md; do
[[ -f "$cmd_file" ]] || continue
cmd_name=$(basename "$cmd_file")
# Backup if exists
if [[ -f "$CMD_TARGET/$cmd_name" ]]; then
mkdir -p "$BACKUP_DIR/command"
cp "$CMD_TARGET/$cmd_name" "$BACKUP_DIR/command/$cmd_name"
fi
transform_command "$cmd_file" > "$CMD_TARGET/$cmd_name"
((cmd_count++))
done
done
echo " Installed $cmd_count commands"
# ============================================================
# INSTALL STANDARDS & TEMPLATES
# ============================================================
echo "${GREEN}Installing standards & templates...${RESET}"
# Standards from dev-team/docs/standards/
STANDARDS_TARGET="$TARGET_ROOT/standards"
mkdir -p "$STANDARDS_TARGET"
if [[ -d "$RING_ROOT/dev-team/docs/standards" ]]; then
rsync -a --checksum "$RING_ROOT/dev-team/docs/standards/" "$STANDARDS_TARGET/"
echo " Installed standards from dev-team/docs/standards/"
fi
# Templates
TEMPLATES_TARGET="$TARGET_ROOT/templates"
mkdir -p "$TEMPLATES_TARGET"
if [[ -d "$SCRIPT_DIR/templates" ]]; then
rsync -a --checksum "$SCRIPT_DIR/templates/" "$TEMPLATES_TARGET/"
echo " Installed templates"
fi
# ============================================================
# INSTALL PLUGIN RUNTIME
# ============================================================
echo "${GREEN}Installing plugin runtime...${RESET}"
# Plugin TypeScript files
if [[ -d "$SCRIPT_DIR/plugin" ]]; then
mkdir -p "$TARGET_ROOT/plugin"
rsync -a --checksum "$SCRIPT_DIR/plugin/" "$TARGET_ROOT/plugin/"
echo " Installed plugin/"
fi
# Prompts (session-start, context-injection)
if [[ -d "$SCRIPT_DIR/prompts" ]]; then
mkdir -p "$TARGET_ROOT/prompts"
rsync -a --checksum "$SCRIPT_DIR/prompts/" "$TARGET_ROOT/prompts/"
echo " Installed prompts/"
fi
# Schema files
for schema in ring-config.schema.json background-tasks.schema.json; do
if [[ -f "$SCRIPT_DIR/$schema" ]]; then
cp "$SCRIPT_DIR/$schema" "$TARGET_ROOT/$schema"
echo " Installed $schema"
fi
done
# Ring config
mkdir -p "$TARGET_ROOT/ring"
if [[ -f "$SCRIPT_DIR/ring.jsonc" ]]; then
# Only copy if target doesn't exist (don't overwrite user config)
if [[ ! -f "$TARGET_ROOT/ring/config.jsonc" ]]; then
cp "$SCRIPT_DIR/ring.jsonc" "$TARGET_ROOT/ring/config.jsonc"
echo " Installed ring/config.jsonc (default)"
else
echo " ${YELLOW}Skipped ring/config.jsonc (user config exists)${RESET}"
fi
fi
# Ensure state dir exists
mkdir -p "$TARGET_ROOT/state"
# ============================================================
# INSTALL DEPENDENCIES
# ============================================================
echo "${GREEN}Installing dependencies...${RESET}"
# Merge package.json
REQUIRED_DEPS_JSON='{
"dependencies": {
"@opencode-ai/plugin": "1.1.3",
"better-sqlite3": "12.6.0",
"zod": "^4.1.8",
"jsonc-parser": "^3.3.1",
"@clack/prompts": "^0.11.0",
"picocolors": "^1.1.1",
"commander": "^14.0.2"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
"@types/node": "22.19.5",
"typescript": "5.9.3",
"@biomejs/biome": "^1.9.4"
}
}'
REQUIRED_DEPS_JSON="$REQUIRED_DEPS_JSON" TARGET_ROOT="$TARGET_ROOT" node - <<'NODE'
const fs = require('fs');
const targetRoot = process.env.TARGET_ROOT;
const pkgPath = `${targetRoot}/package.json`;
const required = JSON.parse(process.env.REQUIRED_DEPS_JSON);
let pkg = {};
if (fs.existsSync(pkgPath)) {
try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch (e) {
console.error(`ERROR: Failed to parse ${pkgPath}: ${e}`);
process.exit(1);
}
}
for (const section of ['dependencies', 'devDependencies']) {
pkg[section] = { ...(pkg[section] || {}), ...(required[section] || {}) };
}
pkg.name ??= 'opencode-config';
pkg.private ??= true;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
console.log(` Updated ${pkgPath}`);
NODE
# Install with bun
if command -v bun >/dev/null 2>&1; then
echo " Running bun install..."
if ! (cd "$TARGET_ROOT" && CXXFLAGS='-std=c++20' bun install 2>&1 | tail -3); then
echo "${RED}ERROR: bun install failed.${RESET}" >&2
echo " Install Node 22 LTS and try again." >&2
exit 1
fi
else
echo " ${YELLOW}WARN: bun not found; skipping dependency install.${RESET}" >&2
fi
# ============================================================
# SUMMARY
# ============================================================
echo ""
echo "${GREEN}================================================${RESET}"
echo "${GREEN} Ring → OpenCode - Install Complete${RESET}"
echo "${GREEN}================================================${RESET}"
echo ""
echo "Installed from Ring monorepo:"
echo "$agent_count agents (from ${#RING_PLUGINS[@]} plugins)"
echo "$skill_count skills + $shared_count shared patterns"
echo "$cmd_count commands"
echo " • Plugin runtime, standards, templates, prompts"
echo ""
echo "Backup: $BACKUP_DIR"
echo ""
echo "To verify:"
echo " 1. Start OpenCode in your project"
echo " 2. Ring skills should appear in command palette"
echo ""

View file

@ -0,0 +1,50 @@
{
"name": "ring-opencode",
"version": "1.0.0",
"description": "Ring skills library for OpenCode - enforces proven software engineering practices",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"ring": "./dist/cli/index.js"
},
"files": [
"dist",
"assets"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./schema.json": "./assets/ring-config.schema.json"
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --target bun --format esm && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm && bun run build:schema",
"build:schema": "bun run scripts/build-schema.ts",
"clean": "rm -rf dist",
"prepublishOnly": "bun run clean && bun run build",
"typecheck": "tsc --noEmit && tsc -p plugin/tsconfig.json --noEmit",
"test": "bun test",
"test:plugin": "bun test __tests__/plugin/",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
},
"dependencies": {
"@clack/prompts": "^0.11.0",
"@opencode-ai/plugin": "1.1.3",
"better-sqlite3": "12.6.0",
"commander": "^14.0.2",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"zod": "^4.1.8"
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@types/better-sqlite3": "7.6.13",
"@types/node": "22.19.5",
"bun-types": "latest",
"typescript": "5.9.3"
}
}

View file

@ -0,0 +1,168 @@
/**
* Ring Config Handler
*
* Creates a config hook that injects Ring agents, skills, and commands
* into OpenCode's configuration at runtime.
*
* Pattern from oh-my-opencode:
* config: async (opencodeConfig) => {
* // Modify opencodeConfig.agent, .skill, .command
* return opencodeConfig
* }
*/
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { loadRingAgents } from "../loaders/agent-loader.js"
import { loadRingCommands } from "../loaders/command-loader.js"
import { loadRingSkills } from "../loaders/skill-loader.js"
import type { RingConfig } from "./schema.js"
// Determine plugin root directory (where assets/ is located)
const __dirname = dirname(fileURLToPath(import.meta.url))
const pluginRoot = join(__dirname, "..", "..")
/**
* OpenCode config structure (subset used by Ring).
*/
export interface OpenCodeConfig {
/** Agent configurations */
agent?: Record<string, unknown>
/** Command configurations */
command?: Record<string, unknown>
/** Permission settings */
permission?: Record<string, string>
/** Tools configuration */
tools?: Record<string, boolean>
/** Model configuration */
model?: string
}
/**
* Dependencies for creating the config handler.
*/
export interface ConfigHandlerDeps {
/** Project root directory */
projectRoot: string
/** Ring plugin configuration */
ringConfig: RingConfig
}
/**
* Create the config handler that injects Ring components.
*
* This handler is called by OpenCode to modify the configuration
* before the session starts. We use this to inject:
* - Ring agents (from plugin's assets/agent/ + user's .opencode/agent/)
* - Ring skills (from plugin's assets/skill/ + user's .opencode/skill/)
* - Ring commands (from plugin's assets/command/ + user's .opencode/command/)
*
* User's customizations take priority over Ring's built-in assets.
*/
export function createConfigHandler(deps: ConfigHandlerDeps) {
const { projectRoot, ringConfig } = deps
return async (config: OpenCodeConfig): Promise<void> => {
const debug = process.env.DEBUG === "true" || process.env.RING_DEBUG === "true"
// Load Ring agents (from plugin's assets/ + user's .opencode/)
const ringAgents = loadRingAgents(pluginRoot, projectRoot, ringConfig.disabled_agents)
if (debug) {
const agentNames = Object.keys(ringAgents)
console.debug(
`[ring] Loaded ${agentNames.length} agents:`,
agentNames.slice(0, 5).join(", "),
agentNames.length > 5 ? "..." : "",
)
}
// Load Ring skills (from plugin's assets/ + user's .opencode/)
const ringSkills = loadRingSkills(pluginRoot, projectRoot, ringConfig.disabled_skills)
if (debug) {
const skillNames = Object.keys(ringSkills)
console.debug(
`[ring] Loaded ${skillNames.length} skills:`,
skillNames.slice(0, 5).join(", "),
skillNames.length > 5 ? "..." : "",
)
}
// Load Ring commands (from plugin's assets/ + user's .opencode/)
const { commands: ringCommands, validation: commandValidation } = loadRingCommands(
pluginRoot,
projectRoot,
ringConfig.disabled_commands,
debug, // Only validate refs in debug mode
)
if (debug) {
const commandNames = Object.keys(ringCommands)
console.debug(
`[ring] Loaded ${commandNames.length} commands:`,
commandNames.slice(0, 5).join(", "),
commandNames.length > 5 ? "..." : "",
)
// Log validation warnings
for (const warning of commandValidation) {
console.debug(`[ring] Command '${warning.command}': ${warning.issue}`)
}
}
// Inject agents into config
// Ring agents are added with lower priority (spread first, then existing)
// so project-specific overrides can take precedence
// TODO(review): Consider deep merge for nested agent configs
config.agent = {
...ringAgents,
...(config.agent ?? {}),
}
// Inject skills and commands
// Commands and skills both go into config.command
config.command = {
...ringSkills,
...ringCommands,
...(config.command ?? {}),
}
// Disable recursive agent calls in certain agents
const agentConfig = config.agent as Record<string, { tools?: Record<string, boolean> }>
// Prevent explore agents from using task recursively
if (agentConfig["ring:codebase-explorer"]) {
agentConfig["ring:codebase-explorer"].tools = {
...agentConfig["ring:codebase-explorer"].tools,
task: false,
}
}
// Prevent reviewers from spawning more reviewers
const reviewerAgents = [
"ring:code-reviewer",
"ring:security-reviewer",
"ring:business-logic-reviewer",
"ring:test-reviewer",
"ring:nil-safety-reviewer",
]
for (const reviewerName of reviewerAgents) {
if (agentConfig[reviewerName]) {
agentConfig[reviewerName].tools = {
...agentConfig[reviewerName].tools,
task: false,
}
}
}
if (debug) {
console.debug("[ring] Config injection complete")
}
}
}

View file

@ -0,0 +1,71 @@
/**
* Ring Configuration System
*
* Layered configuration with Zod validation.
*
* @example
* ```typescript
* import {
* loadConfig,
* isHookDisabledInConfig,
* RingConfig,
* } from "./config"
*
* const config = loadConfig("/path/to/project")
* if (!isHookDisabledInConfig("session-start")) {
* // Hook is enabled
* }
* ```
*/
// Config handler for OpenCode injection
export {
type ConfigHandlerDeps,
createConfigHandler,
type OpenCodeConfig,
} from "./config-handler"
// Loader exports - configuration loading and management
export {
// Types
type ConfigLayer,
checkConfigChanged,
clearConfigCache,
deepMerge,
getCachedConfig,
getConfigLayers,
getExperimentalConfig,
// Config getters
getHookConfig,
isAgentDisabledInConfig,
isCommandDisabledInConfig,
// Disabled checks
isHookDisabledInConfig,
isSkillDisabledInConfig,
// Core functions
loadConfig,
// Utilities
parseJsoncContent,
// File watching
startConfigWatch,
stopConfigWatch,
} from "./loader"
// Schema exports - types and validation
export {
type AgentName,
AgentNameSchema,
type CommandName,
CommandNameSchema,
// Default values
DEFAULT_RING_CONFIG,
type ExperimentalConfig,
ExperimentalConfigSchema,
// TypeScript types
type HookName,
// Zod schemas
HookNameSchema,
type RingConfig,
RingConfigSchema,
type SkillName,
SkillNameSchema,
} from "./schema"

View file

@ -0,0 +1,481 @@
/**
* Ring Configuration Loader
*
* Implements a 4-layer configuration system:
* 1. Built-in defaults
* 2. User config: ~/.config/opencode/ring/config.jsonc (or .json)
* 3. Project config: .opencode/ring.jsonc or .ring/config.jsonc
* 4. Directory overrides: .ring/local.jsonc
*/
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { parse as parseJsonc } from "jsonc-parser"
import {
AgentNameSchema,
CommandNameSchema,
DEFAULT_RING_CONFIG,
HookNameSchema,
type RingConfig,
RingConfigSchema,
SkillNameSchema,
} from "./schema"
/**
* Configuration layer metadata.
*/
export interface ConfigLayer {
/** Layer name for debugging */
name: string
/** File path that was loaded (if any) */
path: string | null
/** Whether the file exists */
exists: boolean
/** Last modified timestamp */
mtime: number | null
/** The partial config from this layer */
config: Partial<RingConfig> | null
}
/**
* Module-level state for configuration caching and watching.
*/
let cachedConfig: RingConfig | null = null
let configLayers: ConfigLayer[] = []
let fileWatchers: fs.FSWatcher[] = []
let lastLoadedRoot: string | null = null
/**
* Parse JSONC content (JSON with comments and trailing commas).
* Uses jsonc-parser for robust parsing.
*
* @param content - The JSONC string content
* @returns Parsed object
* @throws Error if parsing fails
*/
export function parseJsoncContent<T>(content: string): T {
const errors: Array<{ error: number; offset: number; length: number }> = []
const result = parseJsonc(content, errors, {
allowTrailingComma: true,
disallowComments: false,
})
if (errors.length > 0) {
const firstError = errors[0]
throw new Error(
`JSONC parse error at offset ${firstError.offset}: error code ${firstError.error}`,
)
}
return result as T
}
/**
* Keys that must never be merged/assigned from untrusted input.
*
* These are the common gadget keys used for prototype pollution.
*/
const FORBIDDEN_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"])
function isForbiddenObjectKey(key: string): boolean {
return FORBIDDEN_OBJECT_KEYS.has(key)
}
function isMergeableObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false
}
// Only merge plain objects (or null-prototype maps)
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
/**
* Deep merge two objects, with source overwriting target.
* Arrays are replaced, not merged.
*
* SECURITY: Defends against prototype pollution by:
* - using null-prototype result objects (Object.create(null))
* - skipping forbidden gadget keys (__proto__/constructor/prototype)
*
* @param target - The base object
* @param source - The object to merge in
* @returns Merged object
*/
export function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
const result = Object.create(null) as T
// Copy existing keys from target (excluding forbidden keys)
for (const [key, value] of Object.entries(target)) {
if (isForbiddenObjectKey(key)) continue
;(result as Record<string, unknown>)[key] = value
}
if (!isMergeableObject(source)) {
return result
}
for (const key of Object.keys(source)) {
if (isForbiddenObjectKey(key)) {
continue
}
const sourceValue = (source as Record<string, unknown>)[key]
const targetValue = (target as Record<string, unknown>)[key]
if (sourceValue === undefined) {
continue
}
if (isMergeableObject(sourceValue) && isMergeableObject(targetValue)) {
;(result as Record<string, unknown>)[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>,
)
} else {
;(result as Record<string, unknown>)[key] = sourceValue
}
}
return result
}
/**
* Try to load a config file from the given path.
* Supports both .jsonc and .json extensions.
*
* @param filePath - Path to config file (without extension for auto-detection)
* @param withExtension - If true, use path as-is; if false, try .jsonc then .json
* @returns Config layer with metadata
*/
function tryLoadConfigFile(filePath: string, withExtension = true): ConfigLayer {
const layer: ConfigLayer = {
name: path.basename(filePath),
path: null,
exists: false,
mtime: null,
config: null,
}
const pathsToTry = withExtension ? [filePath] : [`${filePath}.jsonc`, `${filePath}.json`]
for (const tryPath of pathsToTry) {
try {
const stats = fs.statSync(tryPath)
if (stats.isFile()) {
layer.path = tryPath
layer.exists = true
layer.mtime = stats.mtimeMs
const content = fs.readFileSync(tryPath, "utf-8")
const parsed = parseJsoncContent<unknown>(content)
// Non-object config files are ignored to avoid poisoning the merge.
layer.config = isMergeableObject(parsed) ? (parsed as Partial<RingConfig>) : null
break
}
} catch {
// File doesn't exist or can't be read, continue
}
}
return layer
}
/**
* Get the user config directory path.
* Uses XDG_CONFIG_HOME if set and absolute, otherwise ~/.config.
* M4: Removed redundant darwin branch that returned the same path.
*/
function getUserConfigDir(): string {
const home = os.homedir()
const xdgConfig = process.env.XDG_CONFIG_HOME
if (xdgConfig && path.isAbsolute(xdgConfig)) {
return path.join(xdgConfig, "opencode", "ring")
}
return path.join(home, ".config", "opencode", "ring")
}
/**
* Load Ring configuration with 4-layer merging.
*
* @param root - Project root directory
* @param forceReload - Force reload even if cached
* @returns Merged configuration
*/
export function loadConfig(root: string, forceReload = false): RingConfig {
// Return cached config if available and not forcing reload
if (cachedConfig && lastLoadedRoot === root && !forceReload) {
return cachedConfig
}
const layers: ConfigLayer[] = []
// Layer 1: Built-in defaults
layers.push({
name: "defaults",
path: null,
exists: true,
mtime: null,
config: DEFAULT_RING_CONFIG,
})
// Layer 2: User config (~/.config/opencode/ring/config.jsonc)
const userConfigDir = getUserConfigDir()
const userConfigPath = path.join(userConfigDir, "config")
layers.push(tryLoadConfigFile(userConfigPath, false))
layers[1].name = "user"
// Layer 3: Project config (.opencode/ring.jsonc or .ring/config.jsonc)
const projectConfig1 = path.join(root, ".opencode", "ring.jsonc")
const projectConfig2 = path.join(root, ".ring", "config.jsonc")
let projectLayer = tryLoadConfigFile(projectConfig1)
if (!projectLayer.exists) {
projectLayer = tryLoadConfigFile(projectConfig2)
}
projectLayer.name = "project"
layers.push(projectLayer)
// Layer 4: Directory overrides (.ring/local.jsonc)
const localConfigPath = path.join(root, ".ring", "local.jsonc")
const localLayer = tryLoadConfigFile(localConfigPath)
localLayer.name = "local"
layers.push(localLayer)
// Merge all layers
let merged: RingConfig = { ...DEFAULT_RING_CONFIG }
for (const layer of layers) {
if (layer.config) {
merged = deepMerge(merged, layer.config)
}
}
// Validate merged config
const parseResult = RingConfigSchema.safeParse(merged)
if (!parseResult.success) {
console.error("[Ring] Configuration validation failed:", parseResult.error.issues)
// Return defaults on validation failure
merged = { ...DEFAULT_RING_CONFIG }
} else {
merged = parseResult.data
}
// Cache the result
cachedConfig = merged
configLayers = layers
lastLoadedRoot = root
return merged
}
/**
* Get metadata about loaded configuration layers.
*
* @returns Array of config layer metadata
*/
export function getConfigLayers(): ConfigLayer[] {
return [...configLayers]
}
/**
* Check if any configuration file has changed since last load.
*
* @returns True if files have changed
*/
export function checkConfigChanged(): boolean {
for (const layer of configLayers) {
if (!layer.path) continue
try {
const stats = fs.statSync(layer.path)
if (stats.mtimeMs !== layer.mtime) {
return true
}
} catch {
// File was deleted
if (layer.exists) {
return true
}
}
}
return false
}
/**
* Start watching configuration files for changes.
*
* @param root - Project root directory
* @param onChange - Callback when config changes
*/
export function startConfigWatch(root: string, onChange: (config: RingConfig) => void): void {
// Stop any existing watchers
stopConfigWatch()
// Paths to watch
const watchPaths = [
path.join(getUserConfigDir(), "config.jsonc"),
path.join(getUserConfigDir(), "config.json"),
path.join(root, ".opencode", "ring.jsonc"),
path.join(root, ".ring", "config.jsonc"),
path.join(root, ".ring", "local.jsonc"),
]
// Debounce reload to avoid multiple rapid reloads
let reloadTimeout: NodeJS.Timeout | null = null
const debouncedReload = () => {
if (reloadTimeout) {
clearTimeout(reloadTimeout)
}
reloadTimeout = setTimeout(() => {
const newConfig = loadConfig(root, true)
onChange(newConfig)
}, 100)
}
for (const watchPath of watchPaths) {
try {
// Ensure parent directory exists before watching
const dir = path.dirname(watchPath)
if (fs.existsSync(dir)) {
const watcher = fs.watch(dir, (_eventType, filename) => {
if (filename && path.join(dir, filename) === watchPath) {
debouncedReload()
}
})
fileWatchers.push(watcher)
}
} catch {
// Directory doesn't exist, skip watching
}
}
}
/**
* Stop watching configuration files.
*/
export function stopConfigWatch(): void {
for (const watcher of fileWatchers) {
watcher.close()
}
fileWatchers = []
}
/**
* Clear the configuration cache.
* Next loadConfig call will reload from disk.
*/
export function clearConfigCache(): void {
cachedConfig = null
configLayers = []
lastLoadedRoot = null
}
/**
* Get the current cached configuration.
* Returns null if not loaded.
*/
export function getCachedConfig(): RingConfig | null {
return cachedConfig
}
/**
* Check if a hook is disabled in the configuration.
* H5: Uses Zod validation instead of unsafe type assertion.
*
* @param hookName - The hook name to check
* @returns True if the hook is disabled
*/
export function isHookDisabledInConfig(hookName: string): boolean {
if (!cachedConfig) {
return false
}
const parsed = HookNameSchema.safeParse(hookName)
if (!parsed.success) {
return false
}
return cachedConfig.disabled_hooks.includes(parsed.data)
}
/**
* Check if an agent is disabled in the configuration.
* H5: Uses Zod validation instead of unsafe type assertion.
*
* @param agentName - The agent name to check
* @returns True if the agent is disabled
*/
export function isAgentDisabledInConfig(agentName: string): boolean {
if (!cachedConfig) {
return false
}
const parsed = AgentNameSchema.safeParse(agentName)
if (!parsed.success) {
return false
}
return cachedConfig.disabled_agents.includes(parsed.data)
}
/**
* Check if a skill is disabled in the configuration.
* H5: Uses Zod validation instead of unsafe type assertion.
*
* @param skillName - The skill name to check
* @returns True if the skill is disabled
*/
export function isSkillDisabledInConfig(skillName: string): boolean {
if (!cachedConfig) {
return false
}
const parsed = SkillNameSchema.safeParse(skillName)
if (!parsed.success) {
return false
}
return cachedConfig.disabled_skills.includes(parsed.data)
}
/**
* Check if a command is disabled in the configuration.
* H5: Uses Zod validation instead of unsafe type assertion.
*
* @param commandName - The command name to check
* @returns True if the command is disabled
*/
export function isCommandDisabledInConfig(commandName: string): boolean {
if (!cachedConfig) {
return false
}
const parsed = CommandNameSchema.safeParse(commandName)
if (!parsed.success) {
return false
}
return cachedConfig.disabled_commands.includes(parsed.data)
}
/**
* Get a specific hook's custom configuration.
*
* @param hookName - The hook name
* @returns Hook config or undefined
*/
export function getHookConfig(hookName: string): Record<string, unknown> | undefined {
if (!cachedConfig?.hooks) {
return undefined
}
return cachedConfig.hooks[hookName]
}
/**
* Get the experimental features configuration.
*
* @returns Experimental config
*/
export function getExperimentalConfig(): RingConfig["experimental"] {
if (!cachedConfig) {
return DEFAULT_RING_CONFIG.experimental
}
return cachedConfig.experimental
}

View file

@ -0,0 +1,140 @@
/**
* Ring Configuration Schema
*
* Defines the structure for Ring's layered configuration system.
* Uses Zod for runtime validation.
*/
import { z } from "zod"
/**
* Hook names that can be disabled.
*/
export const HookNameSchema = z.enum(["session-start", "context-injection"])
/**
* Agent names that can be disabled.
*/
export const AgentNameSchema = z.enum([
"code-reviewer",
"security-reviewer",
"business-logic-reviewer",
"test-reviewer",
"nil-safety-reviewer",
"codebase-explorer",
"write-plan",
"backend-engineer-golang",
"backend-engineer-typescript",
"frontend-engineer",
"frontend-designer",
"devops-engineer",
"sre",
"qa-analyst",
])
/**
* Skill names that can be disabled.
*/
export const SkillNameSchema = z.enum([
"using-ring-opencode",
"test-driven-development",
"requesting-code-review",
"writing-plans",
"executing-plans",
"brainstorming",
"linting-codebase",
"using-git-worktrees",
"exploring-codebase",
"handoff-tracking",
"interviewing-user",
"receiving-code-review",
"using-dev-team",
"writing-skills",
"dev-cycle",
"dev-devops",
"dev-feedback-loop",
"dev-implementation",
"dev-refactor",
"dev-sre",
"dev-testing",
"dev-validation",
])
/**
* Command names that can be disabled.
*/
export const CommandNameSchema = z.enum([
"brainstorm",
"codereview",
"commit",
"create-handoff",
"dev-cancel",
"dev-cycle",
"dev-refactor",
"dev-report",
"dev-status",
"execute-plan",
"explore-codebase",
"lint",
"resume-handoff",
"worktree",
"write-plan",
])
/**
* Experimental features configuration.
*/
export const ExperimentalConfigSchema = z.object({
/** Enable preemptive compaction */
preemptiveCompaction: z.boolean().default(false),
/** Compaction threshold (0.5-0.95) */
compactionThreshold: z.number().min(0.5).max(0.95).default(0.8),
/** Enable aggressive tool output truncation */
aggressiveTruncation: z.boolean().default(false),
})
/**
* Main Ring configuration schema.
*/
export const RingConfigSchema = z.object({
/** Schema URL for IDE support */
$schema: z.string().optional(),
/** Disabled hooks (won't be loaded) */
disabled_hooks: z.array(HookNameSchema).default([]),
/** Disabled agents (won't be available) */
disabled_agents: z.array(AgentNameSchema).default([]),
/** Disabled skills (won't be loaded) */
disabled_skills: z.array(SkillNameSchema).default([]),
/** Disabled commands (won't be registered) */
disabled_commands: z.array(CommandNameSchema).default([]),
/** Experimental features */
experimental: ExperimentalConfigSchema.optional().default({
preemptiveCompaction: false,
compactionThreshold: 0.8,
aggressiveTruncation: false,
}),
/** Custom hook configurations */
hooks: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
})
/**
* Inferred TypeScript types from schemas.
*/
export type HookName = z.infer<typeof HookNameSchema>
export type AgentName = z.infer<typeof AgentNameSchema>
export type SkillName = z.infer<typeof SkillNameSchema>
export type CommandName = z.infer<typeof CommandNameSchema>
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
export type RingConfig = z.infer<typeof RingConfigSchema>
/**
* Default configuration values.
* M3: Derived from schema to ensure consistency with defaults defined in Zod.
*/
export const DEFAULT_RING_CONFIG: RingConfig = RingConfigSchema.parse({})

View file

@ -0,0 +1,223 @@
/**
* Context Injection Hook Factory
*
* Handles context injection during session compaction.
* Provides compact versions of rules, skills, commands, and agents references.
*/
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { type AgentConfig, loadRingAgents } from "../../loaders/agent-loader.js"
import { loadRingCommands } from "../../loaders/command-loader.js"
import { loadRingSkills, type SkillConfig } from "../../loaders/skill-loader.js"
import type { Hook, HookContext, HookFactory, HookOutput, HookResult } from "../types.js"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
/** Root directory (contains agent/, skill/, command/) */
const PLUGIN_ROOT = path.resolve(__dirname, "../../..")
/**
* Load a prompt file from the prompts directory.
*/
function loadPrompt(filename: string): string {
const promptPath = path.resolve(__dirname, "../../../prompts", filename)
try {
return fs.readFileSync(promptPath, "utf-8").trim()
} catch {
return ""
}
}
/**
* Generate skills reference from loaded skills.
* Format: "Available Ring skills: /skill1, /skill2, ... Use 'Skill' tool with skill name to invoke."
*/
function generateSkillsReference(skills: Record<string, SkillConfig>): string {
const skillNames = Object.keys(skills)
.map((key) => key.replace("ring:", ""))
.sort()
.map((name) => `/${name}`)
.join(", ")
if (!skillNames) return ""
return `Available Ring skills: ${skillNames}\nUse "Skill" tool with skill name to invoke.`
}
/**
* Generate commands reference from loaded commands.
* Format: "Ring commands: /cmd1, /cmd2, ... Use skills via Skill tool."
*/
function generateCommandsReference(commands: Record<string, unknown>): string {
const cmdNames = Object.keys(commands)
.map((key) => key.replace("ring:", ""))
.sort()
if (cmdNames.length === 0) return ""
return `Ring commands: Use skills via Skill tool. Check /help for full list.\nKey patterns: TDD (test-first), systematic debugging, defense-in-depth validation.`
}
/**
* Generate agents reference from loaded agents.
* Format categorizes agents by role prefix for compact representation.
*/
function generateAgentsReference(agents: Record<string, AgentConfig>): string {
const agentNames = Object.keys(agents)
.map((key) => key.replace("ring:", ""))
.sort()
if (agentNames.length === 0) return ""
// Categorize agents by common prefixes
const devAgents: string[] = []
const reviewers: string[] = []
const pmAgents: string[] = []
const otherAgents: string[] = []
for (const name of agentNames) {
if (name.includes("reviewer")) {
reviewers.push(name.replace("-reviewer", ""))
} else if (name.startsWith("pre-dev")) {
pmAgents.push(name)
} else if (
name.includes("engineer") ||
name === "devops" ||
name === "sre" ||
name === "qa-analyst" ||
name === "frontend-designer"
) {
devAgents.push(name)
} else {
otherAgents.push(name)
}
}
const parts: string[] = []
if (devAgents.length > 0) parts.push(`Dev agents: ${devAgents.join(", ")}`)
if (reviewers.length > 0) parts.push(`Reviewers: ${reviewers.join(", ")}`)
if (pmAgents.length > 0) parts.push(`PM agents: ${pmAgents.join(", ")}`)
if (otherAgents.length > 0) parts.push(`Other: ${otherAgents.join(", ")}`)
return `${parts.join("\n")}\nDispatch via dev-cycle or pre-dev workflows.`
}
/**
* Configuration for context injection hook.
*/
export interface ContextInjectionConfig {
/** Enable compact critical rules */
injectCompactRules?: boolean
/** Enable skills reference */
injectSkillsRef?: boolean
/** Enable commands reference */
injectCommandsRef?: boolean
/** Enable agents reference */
injectAgentsRef?: boolean
}
/** Default configuration */
const DEFAULT_CONFIG: Required<ContextInjectionConfig> = {
injectCompactRules: true,
injectSkillsRef: true,
injectCommandsRef: true,
injectAgentsRef: true,
}
// Load static prompt content (compact-rules.txt is still static)
const COMPACT_CRITICAL_RULES_CONTENT = loadPrompt("context-injection/compact-rules.txt")
/**
* Compact critical rules for compaction context.
*/
const COMPACT_CRITICAL_RULES = COMPACT_CRITICAL_RULES_CONTENT
? `<ring-compact-rules>
${COMPACT_CRITICAL_RULES_CONTENT}
</ring-compact-rules>`
: ""
/**
* Create a context injection hook.
*/
export const createContextInjectionHook: HookFactory<ContextInjectionConfig> = (
config?: ContextInjectionConfig,
): Hook => {
const cfg = { ...DEFAULT_CONFIG, ...config }
return {
name: "context-injection",
lifecycles: ["session.compacting"],
priority: 20,
enabled: true,
async execute(ctx: HookContext, output: HookOutput): Promise<HookResult> {
const contextInjections: string[] = []
const projectRoot = ctx.directory
try {
// Inject compact critical rules
if (cfg.injectCompactRules && COMPACT_CRITICAL_RULES) {
contextInjections.push(COMPACT_CRITICAL_RULES)
}
// Generate and inject skills reference dynamically
if (cfg.injectSkillsRef) {
const skills = loadRingSkills(PLUGIN_ROOT, projectRoot)
const skillsContent = generateSkillsReference(skills)
if (skillsContent) {
contextInjections.push(`<ring-skills-ref>\n${skillsContent}\n</ring-skills-ref>`)
}
}
// Generate and inject commands reference dynamically
if (cfg.injectCommandsRef) {
const { commands } = loadRingCommands(PLUGIN_ROOT, projectRoot)
const commandsContent = generateCommandsReference(commands)
if (commandsContent) {
contextInjections.push(`<ring-commands-ref>\n${commandsContent}\n</ring-commands-ref>`)
}
}
// Generate and inject agents reference dynamically
if (cfg.injectAgentsRef) {
const agents = loadRingAgents(PLUGIN_ROOT, projectRoot)
const agentsContent = generateAgentsReference(agents)
if (agentsContent) {
contextInjections.push(`<ring-agents-ref>\n${agentsContent}\n</ring-agents-ref>`)
}
}
// Add to output context
if (contextInjections.length > 0) {
output.context = output.context ?? []
output.context.push(...contextInjections)
}
return {
success: true,
data: {
injectionsCount: contextInjections.length,
},
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return {
success: false,
error: `Context injection hook failed: ${errorMessage}`,
}
}
},
}
}
/**
* Hook registry entry for context injection.
*/
export const contextInjectionEntry = {
name: "context-injection" as const,
factory: createContextInjectionHook,
defaultEnabled: true,
description: "Injects compact rules, skills, commands, and agents references during compaction",
}

View file

@ -0,0 +1,38 @@
/**
* Ring Hook Factories Index
*
* Exports all hook factories and their configuration types.
*/
export type { ContextInjectionConfig } from "./context-injection.js"
// Context Injection Hook
export {
contextInjectionEntry,
createContextInjectionHook,
} from "./context-injection.js"
export type { SessionStartConfig } from "./session-start.js"
// Session Start Hook
export {
createSessionStartHook,
sessionStartEntry,
} from "./session-start.js"
import { contextInjectionEntry } from "./context-injection.js"
// All registry entries for bulk registration
import { sessionStartEntry } from "./session-start.js"
/**
* All built-in hook registry entries.
*/
export const builtInHookEntries = [sessionStartEntry, contextInjectionEntry] as const
/**
* Register all built-in hooks with a registry.
*/
export function registerBuiltInHooks(registry: {
registerFactory: (entry: (typeof builtInHookEntries)[number]) => void
}): void {
for (const entry of builtInHookEntries) {
registry.registerFactory(entry)
}
}

View file

@ -0,0 +1,179 @@
/**
* Session Start Hook Factory
*
* Injects critical context at session initialization and chat params.
*/
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import type { Hook, HookContext, HookFactory, HookOutput, HookResult } from "../types.js"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
/**
* Load a prompt file from the prompts directory.
*/
function loadPrompt(filename: string): string {
const promptPath = path.resolve(__dirname, "../../../prompts", filename)
try {
return fs.readFileSync(promptPath, "utf-8").trim()
} catch {
return ""
}
}
/**
* Configuration for session start hook.
*/
export interface SessionStartConfig {
/** Enable critical rules injection */
injectCriticalRules?: boolean
/** Enable agent reminder injection */
injectAgentReminder?: boolean
/** Enable duplication guard */
injectDuplicationGuard?: boolean
/** Enable doubt questions */
injectDoubtQuestions?: boolean
/** Enable path context injection */
injectPathContext?: boolean
}
/** Default configuration */
const DEFAULT_CONFIG: Required<SessionStartConfig> = {
injectCriticalRules: true,
injectAgentReminder: true,
injectDuplicationGuard: true,
injectDoubtQuestions: true,
injectPathContext: true,
}
// Load prompt content from external files
const CRITICAL_RULES_CONTENT = loadPrompt("session-start/critical-rules.txt")
const AGENT_REMINDER_CONTENT = loadPrompt("session-start/agent-reminder.txt")
const DUPLICATION_GUARD_CONTENT = loadPrompt("session-start/duplication-guard.txt")
const DOUBT_QUESTIONS_CONTENT = loadPrompt("session-start/doubt-questions.txt")
const PATH_CONTEXT_CONTENT = loadPrompt("session-start/path-context.txt")
/**
* Critical rules that must be followed in every session.
*/
const CRITICAL_RULES = CRITICAL_RULES_CONTENT
? `<ring-critical-rules>
${CRITICAL_RULES_CONTENT}
</ring-critical-rules>`
: ""
/**
* Agent reminder for maintaining quality.
*/
const AGENT_REMINDER = AGENT_REMINDER_CONTENT
? `<ring-agent-reminder>
${AGENT_REMINDER_CONTENT}
</ring-agent-reminder>`
: ""
/**
* Duplication guard to prevent redundant work.
*/
const DUPLICATION_GUARD = DUPLICATION_GUARD_CONTENT
? `<ring-duplication-guard>
${DUPLICATION_GUARD_CONTENT}
</ring-duplication-guard>`
: ""
/**
* Doubt questions to resolve ambiguity.
*/
const DOUBT_QUESTIONS = DOUBT_QUESTIONS_CONTENT
? `<ring-doubt-resolver>
${DOUBT_QUESTIONS_CONTENT}
</ring-doubt-resolver>`
: ""
/**
* Path context for OpenCode directory structure.
*/
const PATH_CONTEXT = PATH_CONTEXT_CONTENT
? `<ring-paths>
${PATH_CONTEXT_CONTENT}
</ring-paths>`
: ""
/**
* Create a session start hook.
*/
export const createSessionStartHook: HookFactory<SessionStartConfig> = (
config?: SessionStartConfig,
): Hook => {
const cfg = { ...DEFAULT_CONFIG, ...config }
return {
name: "session-start",
lifecycles: ["session.created", "chat.params"],
priority: 10, // Run early
enabled: true,
async execute(_ctx: HookContext, output: HookOutput): Promise<HookResult> {
const systemInjections: string[] = []
try {
// Inject critical rules
if (cfg.injectCriticalRules && CRITICAL_RULES) {
systemInjections.push(CRITICAL_RULES)
}
// Inject agent reminder
if (cfg.injectAgentReminder && AGENT_REMINDER) {
systemInjections.push(AGENT_REMINDER)
}
// Inject duplication guard
if (cfg.injectDuplicationGuard && DUPLICATION_GUARD) {
systemInjections.push(DUPLICATION_GUARD)
}
// Inject doubt questions
if (cfg.injectDoubtQuestions && DOUBT_QUESTIONS) {
systemInjections.push(DOUBT_QUESTIONS)
}
// Inject path context
if (cfg.injectPathContext && PATH_CONTEXT) {
systemInjections.push(PATH_CONTEXT)
}
// Add to output
if (systemInjections.length > 0) {
output.system = output.system ?? []
output.system.push(...systemInjections)
}
return {
success: true,
data: {
injectionsCount: systemInjections.length,
},
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return {
success: false,
error: `Session start hook failed: ${errorMessage}`,
}
}
},
}
}
/**
* Hook registry entry for session start.
*/
export const sessionStartEntry = {
name: "session-start" as const,
factory: createSessionStartHook,
defaultEnabled: true,
description: "Injects critical rules and reminders at session start",
}

View file

@ -0,0 +1,30 @@
/**
* Ring Hook System
*
* Exports all hook types, registry, and utilities.
*/
// Hook factories (will be populated as factories are created)
export * from "./factories/index.js"
export type { HookConfig } from "./registry.js"
// Registry
export {
HookRegistry,
hookRegistry,
isHookDisabled,
} from "./registry.js"
// Type definitions
export type {
Hook,
HookChatHandler,
HookCompactionHandler,
HookContext,
HookEventHandler,
HookFactory,
HookLifecycle,
HookName,
HookOutput,
HookRegistryEntry,
HookResult,
HookSystemHandler,
} from "./types.js"

View file

@ -0,0 +1,251 @@
/**
* Ring Hook Registry
*
* Manages hook registration, instantiation, and lifecycle execution.
* Implements middleware pattern for extensible hook chains.
*/
import type {
Hook,
HookContext,
HookLifecycle,
HookName,
HookOutput,
HookRegistryEntry,
HookResult,
} from "./types.js"
/**
* Configuration interface for hook system.
*/
export interface HookConfig {
disabledHooks?: HookName[]
}
/**
* Registry for managing hooks and their lifecycle execution.
*/
export class HookRegistry {
/** Registered hook factories */
private factories: Map<HookName, HookRegistryEntry> = new Map()
/** Instantiated hooks */
private hooks: Map<HookName, Hook> = new Map()
/** Disabled hooks set */
private disabledHooks: Set<HookName> = new Set()
/**
* Register a hook factory with metadata.
*/
registerFactory(entry: HookRegistryEntry): void {
this.factories.set(entry.name, entry)
}
/**
* Get all registered factories.
*/
getFactories(): HookRegistryEntry[] {
return Array.from(this.factories.values())
}
/**
* Instantiate a hook from its factory.
*/
instantiate<TConfig extends Record<string, unknown> = Record<string, unknown>>(
name: HookName,
config?: TConfig,
): Hook | null {
const entry = this.factories.get(name)
if (!entry) {
return null
}
const hook = entry.factory(config as Record<string, unknown> | undefined)
// Respect disabled state
if (this.disabledHooks.has(name)) {
hook.enabled = false
}
this.hooks.set(name, hook)
return hook
}
/**
* Register an instantiated hook directly.
*/
register(hook: Hook): void {
// Respect disabled state
if (this.disabledHooks.has(hook.name)) {
hook.enabled = false
}
this.hooks.set(hook.name, hook)
}
/**
* Unregister a hook by name.
*/
unregister(name: HookName): void {
this.hooks.delete(name)
}
/**
* Get a hook by name.
*/
get(name: HookName): Hook | undefined {
return this.hooks.get(name)
}
/**
* Check if a hook is registered.
*/
has(name: HookName): boolean {
return this.hooks.has(name)
}
/**
* Disable a hook by name.
*/
disable(name: HookName): void {
this.disabledHooks.add(name)
const hook = this.hooks.get(name)
if (hook) {
hook.enabled = false
}
}
/**
* Enable a hook by name.
*/
enable(name: HookName): void {
this.disabledHooks.delete(name)
const hook = this.hooks.get(name)
if (hook) {
hook.enabled = true
}
}
/**
* Check if a hook is disabled.
*/
isDisabled(name: HookName): boolean {
return this.disabledHooks.has(name)
}
/**
* Set the disabled hooks from configuration.
*/
setDisabledHooks(names: HookName[]): void {
this.disabledHooks = new Set(names)
// Update existing hooks
for (const [hookName, hook] of this.hooks) {
hook.enabled = !this.disabledHooks.has(hookName)
}
}
/**
* Get all hooks that respond to a specific lifecycle event.
* Returns hooks sorted by priority (lower = earlier).
*/
getHooksForLifecycle(lifecycle: HookLifecycle): Hook[] {
const matchingHooks: Hook[] = []
for (const hook of this.hooks.values()) {
if (hook.enabled && hook.lifecycles.includes(lifecycle)) {
matchingHooks.push(hook)
}
}
// Sort by priority (lower = earlier, default 100)
// Use name as secondary key for deterministic ordering
return matchingHooks.sort((a, b) => {
const priorityDiff = (a.priority ?? 100) - (b.priority ?? 100)
if (priorityDiff !== 0) return priorityDiff
return a.name.localeCompare(b.name)
})
}
/**
* Execute all hooks for a lifecycle event in priority order.
* Implements chain-of-responsibility pattern.
*/
async executeLifecycle(
lifecycle: HookLifecycle,
ctx: HookContext,
output: HookOutput,
): Promise<HookResult[]> {
const hooks = this.getHooksForLifecycle(lifecycle)
const results: HookResult[] = []
let chainData: Record<string, unknown> = {}
for (const hook of hooks) {
try {
// Pass chain data from previous hooks
const contextWithChainData: HookContext = {
...ctx,
chainData,
}
const result = await hook.execute(contextWithChainData, output)
results.push(result)
// Accumulate chain data
if (result.data) {
chainData = { ...chainData, ...result.data }
}
// Stop chain if requested
if (result.stopChain) {
break
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
results.push({
success: false,
error: `Hook ${hook.name} failed: ${errorMessage}`,
})
}
}
return results
}
/**
* Get names of all registered hooks.
*/
getRegisteredNames(): HookName[] {
return Array.from(this.hooks.keys())
}
/**
* Get count of registered hooks.
*/
count(): number {
return this.hooks.size
}
/**
* Clear all registered hooks.
*/
clear(): void {
this.hooks.clear()
this.disabledHooks.clear()
}
}
/**
* Singleton hook registry instance.
*/
export const hookRegistry = new HookRegistry()
/**
* Check if a hook is disabled in the given configuration.
*/
export function isHookDisabled(config: HookConfig | undefined, hookName: HookName): boolean {
if (!config?.disabledHooks) {
return false
}
return config.disabledHooks.includes(hookName)
}

View file

@ -0,0 +1,137 @@
/**
* Ring Hook System Type Definitions
*
* Defines hook lifecycle types and interfaces for middleware pattern.
* Based on oh-my-opencode patterns adapted for Ring.
*/
/**
* Hook lifecycle events supported by Ring.
* Each event corresponds to a specific point in the OpenCode pipeline.
*/
export type HookLifecycle =
| "session.created" // Session initialized
| "session.idle" // Session became idle
| "session.error" // Session encountered an error
| "session.compacting" // Context being compacted
| "chat.message" // User message received
| "chat.params" // Chat parameters being set
| "tool.before" // Before tool execution
| "tool.after" // After tool execution
| "todo.updated" // Todo list changed
| "event" // Generic event handler
/**
* Hook execution result indicating success/failure.
*/
export interface HookResult {
success: boolean
error?: string
/** Optional data to pass to next hook in chain */
data?: Record<string, unknown>
/** If true, stop hook chain execution */
stopChain?: boolean
}
/**
* Context provided to all hooks.
*/
export interface HookContext {
/** Session identifier */
sessionId: string
/** Project root directory */
directory: string
/** Hook lifecycle event that triggered this hook */
lifecycle: HookLifecycle
/** Original event data from OpenCode */
event?: {
type: string
properties?: Record<string, unknown>
}
/** Data passed from previous hook in chain */
chainData?: Record<string, unknown>
}
/**
* Base hook interface that all hooks must implement.
*/
export interface Hook {
/** Unique identifier for this hook */
name: HookName
/** Hook lifecycle events this hook responds to */
lifecycles: HookLifecycle[]
/** Priority for execution order (lower = earlier, default 100) */
priority?: number
/** Whether this hook is enabled */
enabled: boolean
/** Execute the hook */
execute: (ctx: HookContext, output: HookOutput) => Promise<HookResult>
}
/**
* Hook output object for modifying OpenCode behavior.
*/
export interface HookOutput {
/** System prompt context to inject */
system?: string[]
/** Compaction context to inject */
context?: string[]
/** Message parts to modify */
parts?: Array<{ type: string; text?: string; [key: string]: unknown }>
/** Block the operation */
block?: boolean
/** Reason for blocking */
blockReason?: string
}
/**
* Hook factory function signature.
* All hooks are created via factory functions for consistent initialization.
*/
export type HookFactory<TConfig = Record<string, unknown>> = (config?: TConfig) => Hook
/**
* Built-in hook names supported by Ring.
*/
export type HookName = "session-start" | "context-injection"
/**
* Hook registry entry with metadata.
*/
export interface HookRegistryEntry {
name: HookName
factory: HookFactory
defaultEnabled: boolean
description: string
}
/**
* Event handler signature for hooks.
*/
export type HookEventHandler = (input: {
event: { type: string; properties?: unknown }
}) => Promise<void>
/**
* Chat message handler signature.
*/
export type HookChatHandler = (
input: { sessionID: string; agent?: string },
output: { parts: Array<{ type: string; text?: string }> },
) => Promise<void>
/**
* Compaction handler signature.
*/
export type HookCompactionHandler = (
input: { sessionID: string },
output: { context: string[] },
) => Promise<void>
/**
* System transform handler signature.
*/
export type HookSystemHandler = (
input: Record<string, unknown>,
output: { system: string[] },
) => Promise<void>

View file

@ -0,0 +1,28 @@
/**
* Ring OpenCode Plugin
*
* This module exports ONLY the plugin function for OpenCode.
*
* IMPORTANT: OpenCode's plugin loader iterates over ALL exports and calls
* each one as a function. Any non-function export will crash the loader with:
* "TypeError: fn3 is not a function"
*
* Therefore, this file MUST only export:
* - RingUnifiedPlugin (named export)
* - default export
*
* For internal APIs (hooks, config, utils, etc.), import directly from
* the submodules:
* - "./hooks/index.js"
* - "./config/index.js"
* - "./utils/state.js"
* - "./loaders/index.js"
* - "./tools/index.js"
* - "./lifecycle/index.js"
*/
// =============================================================================
// PLUGIN EXPORTS ONLY
// =============================================================================
export { RingUnifiedPlugin, RingUnifiedPlugin as default } from "./ring-unified.js"

View file

@ -0,0 +1,12 @@
/**
* Ring Lifecycle Module
*
* Central export for lifecycle routing.
*/
export {
createLifecycleRouter,
EVENTS,
type LifecycleRouterDeps,
type OpenCodeEvent,
} from "./router.js"

View file

@ -0,0 +1,96 @@
/**
* Ring Lifecycle Router
*
* Routes OpenCode lifecycle events to Ring's plugin handlers.
* Maps OpenCode events to Ring hook registry.
*/
import type { RingConfig } from "../config/index.js"
import { cleanupOldState, deleteState, getSessionId } from "../utils/state.js"
/**
* OpenCode event structure.
*/
export interface OpenCodeEvent {
type: string
properties?: Record<string, unknown>
}
/**
* Dependencies for lifecycle router.
*/
export interface LifecycleRouterDeps {
projectRoot: string
ringConfig: RingConfig
}
/**
* Create the event handler that routes lifecycle events.
*
* Event mappings:
* - session.created -> Reset context state, cleanup old files
* - session.idle -> Hook execution (via RingUnifiedPlugin)
* - session.error -> Hook execution (via RingUnifiedPlugin)
* - todo.updated -> Task completion hook
* - experimental.session.compacting -> Context injection
*/
export function createLifecycleRouter(deps: LifecycleRouterDeps) {
const { projectRoot, ringConfig } = deps
const debug = process.env.RING_DEBUG === "true"
if (debug) {
console.debug("[ring] Lifecycle router initialized", {
disabledAgents: ringConfig.disabled_agents?.length ?? 0,
disabledSkills: ringConfig.disabled_skills?.length ?? 0,
})
}
return async (input: { event: OpenCodeEvent }): Promise<void> => {
const { event } = input
const eventType = event.type
if (debug) {
console.debug(`[ring] Event: ${eventType}`)
}
// session.created - Initialize session
if (eventType === "session.created") {
const props = event.properties as Record<string, unknown> | undefined
const eventSessionId = (props?.sessionID as string | undefined) ?? getSessionId()
// Reset context usage state for new session
deleteState(projectRoot, "context-usage", eventSessionId)
// Clean up old state files (> 7 days)
cleanupOldState(projectRoot, 7)
if (debug) {
console.debug("[ring] Session initialized, state reset")
}
return
}
// session.idle / session.error / todo.updated are handled via hook execution
// in RingUnifiedPlugin (this router only manages state side effects).
if (
eventType === "session.idle" ||
eventType === "session.error" ||
eventType === "todo.updated"
) {
return
}
// Other events - no action needed
}
}
/**
* Event type constants for type safety.
*/
export const EVENTS = {
SESSION_CREATED: "session.created",
SESSION_IDLE: "session.idle",
SESSION_ERROR: "session.error",
SESSION_DELETED: "session.deleted",
TODO_UPDATED: "todo.updated",
MESSAGE_PART_UPDATED: "message.part.updated",
} as const

View file

@ -0,0 +1,307 @@
/**
* Ring Agent Loader
*
* Loads Ring agents from:
* 1. Ring agent/*.md files (Ring's built-in agents)
* 2. User's .opencode/agent/*.md files (user customizations)
*
* User's agents take priority over Ring's built-in agents.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { basename, join } from "node:path"
import { expandPlaceholders } from "./placeholder-utils.js"
/**
* Agent configuration compatible with OpenCode SDK.
*/
export interface AgentConfig {
description?: string
mode?: "primary" | "subagent"
prompt?: string
model?: string
temperature?: number
tools?: Record<string, boolean>
permission?: Record<string, string>
color?: string
}
/**
* Frontmatter data from agent markdown files.
*/
interface AgentFrontmatter {
description?: string
mode?: string
model?: string
temperature?: number
tools?: string
color?: string
}
/**
* Keys that must never be used as object properties when building maps from filenames.
*/
const FORBIDDEN_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"])
function isForbiddenObjectKey(key: string): boolean {
return FORBIDDEN_OBJECT_KEYS.has(key)
}
/**
* Parse YAML frontmatter from markdown content.
*/
function parseFrontmatter(content: string): { data: AgentFrontmatter; body: string } {
// Normalize line endings for cross-platform support
const normalizedContent = content.replace(/\r\n/g, "\n")
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
const match = normalizedContent.match(frontmatterRegex)
if (!match) {
return { data: {}, body: normalizedContent }
}
const yamlContent = match[1]
const body = match[2]
// Simple YAML parsing for our use case
const data: AgentFrontmatter = {}
const lines = yamlContent.split("\n")
for (const line of lines) {
const colonIndex = line.indexOf(":")
if (colonIndex === -1) continue
const key = line.slice(0, colonIndex).trim()
let value = line.slice(colonIndex + 1).trim()
// Remove quotes if present
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (key === "description") data.description = value
if (key === "mode") data.mode = value
if (key === "model") data.model = value
if (key === "temperature") {
const parsed = parseFloat(value)
if (!Number.isNaN(parsed)) data.temperature = parsed
}
if (key === "tools") data.tools = value
if (key === "color") data.color = value
}
return { data, body }
}
/**
* Parse tools string into tools config object.
*/
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
if (!toolsStr) return undefined
const tools = toolsStr
.split(",")
.map((t) => t.trim())
.filter(Boolean)
if (tools.length === 0) return undefined
const result: Record<string, boolean> = {}
for (const tool of tools) {
// Handle negation (e.g., "!task" means task: false)
if (tool.startsWith("!")) {
result[tool.slice(1).toLowerCase()] = false
} else {
result[tool.toLowerCase()] = true
}
}
return result
}
/**
* Get default temperature based on agent name pattern.
* Role-based defaults ensure consistent behavior across agent types.
*/
function getDefaultTemperature(agentName: string): number {
const name = agentName.toLowerCase()
// Reviewers: precise, consistent analysis (0.1)
if (name.includes("reviewer")) {
return 0.1
}
// Ops roles: precise, consistent operations (0.1)
if (name === "devops-engineer" || name === "sre" || name === "qa-analyst") {
return 0.1
}
// Planners/explorers: balanced creativity with structure (0.2)
if (name === "write-plan" || name === "codebase-explorer") {
return 0.2
}
// Engineers: balanced creativity with precision (0.2)
if (
name === "backend-engineer-golang" ||
name === "backend-engineer-typescript" ||
name === "frontend-engineer" ||
name === "frontend-bff-engineer-typescript"
) {
return 0.2
}
// Creative roles: higher creativity (0.4)
if (name === "frontend-designer") {
return 0.4
}
// Default fallback
return 0.2
}
/**
* Load agents from a directory.
*/
function loadAgentsFromDir(
agentsDir: string,
disabledAgents: Set<string>,
): Record<string, AgentConfig> {
if (!existsSync(agentsDir)) {
return {}
}
const result: Record<string, AgentConfig> = Object.create(null)
try {
const entries = readdirSync(agentsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue
const agentPath = join(agentsDir, entry.name)
const agentName = basename(entry.name, ".md")
// SECURITY: Skip forbidden gadget keys
if (isForbiddenObjectKey(agentName)) continue
// Skip disabled agents
if (disabledAgents.has(agentName)) continue
try {
const content = readFileSync(agentPath, "utf-8")
const { data, body } = parseFrontmatter(content)
// Validate mode before casting
const validModes = ["primary", "subagent"] as const
const mode = validModes.includes(data.mode as (typeof validModes)[number])
? (data.mode as "primary" | "subagent")
: "subagent"
const config: AgentConfig = {
description: data.description
? `(ring) ${data.description}`
: `(ring) ${agentName} agent`,
mode,
prompt: expandPlaceholders(body.trim()),
}
if (data.model) {
config.model = data.model
}
if (data.color) {
config.color = data.color
}
// Apply temperature: explicit frontmatter value or role-based default
config.temperature = data.temperature ?? getDefaultTemperature(agentName)
const toolsConfig = parseToolsConfig(data.tools)
if (toolsConfig) {
config.tools = toolsConfig
}
// Use ring namespace for agents
result[`ring:${agentName}`] = config
} catch (error) {
if (process.env.RING_DEBUG === "true") {
console.debug(`[ring] Failed to parse ${agentPath}:`, error)
}
}
}
} catch (error) {
if (process.env.RING_DEBUG === "true") {
console.debug(`[ring] Failed to read agents directory:`, error)
}
return {}
}
return result
}
/**
* Load Ring agents from both Ring and user's .opencode/ directories.
*
* @param pluginRoot - Path to the plugin directory (installed by Ring)
* @param projectRoot - Path to the user's project directory (contains .opencode/)
* @param disabledAgents - List of agent names to skip
* @returns Merged agent configs with user's taking priority
*/
export function loadRingAgents(
pluginRoot: string,
projectRoot: string,
disabledAgents: string[] = [],
): Record<string, AgentConfig> {
const disabledSet = new Set(disabledAgents)
// Load Ring's built-in agents from assets/agent/
const builtInDir = join(pluginRoot, "agent")
const builtInAgents = loadAgentsFromDir(builtInDir, disabledSet)
// Load user's custom agents from .opencode/agent/
const userDir = join(projectRoot, ".opencode", "agent")
const userAgents = loadAgentsFromDir(userDir, disabledSet)
// Merge with user's taking priority, using a null-prototype map
const merged: Record<string, AgentConfig> = Object.create(null)
Object.assign(merged, builtInAgents)
Object.assign(merged, userAgents)
return merged
}
/**
* Get count of available agents from both Ring and user's .opencode/.
*/
export function countRingAgents(pluginRoot: string, projectRoot: string): number {
const uniqueAgents = new Set<string>()
// Count built-in agents
const builtInDir = join(pluginRoot, "agent")
if (existsSync(builtInDir)) {
try {
const entries = readdirSync(builtInDir)
for (const f of entries) {
if (f.endsWith(".md")) uniqueAgents.add(f)
}
} catch {
// Ignore errors
}
}
// Count user agents
const userDir = join(projectRoot, ".opencode", "agent")
if (existsSync(userDir)) {
try {
const entries = readdirSync(userDir)
for (const f of entries) {
if (f.endsWith(".md")) uniqueAgents.add(f)
}
} catch {
// Ignore errors
}
}
return uniqueAgents.size
}

View file

@ -0,0 +1,329 @@
/**
* Ring Command Loader
*
* Loads Ring commands from:
* 1. Ring command/*.md files (Ring's built-in commands)
* 2. User's .opencode/command/*.md files (user customizations)
*
* User's commands take priority over Ring's built-in commands.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { basename, join } from "node:path"
/**
* Command configuration compatible with OpenCode SDK.
*/
export interface CommandConfig {
description?: string
agent?: string
subtask?: boolean
}
/**
* Validation warning for command references.
*/
export interface CommandValidationWarning {
command: string
issue: string
severity: "warning" | "error"
}
/**
* Result of loading commands with validation.
*/
export interface LoadCommandsResult {
commands: Record<string, CommandConfig>
validation: CommandValidationWarning[]
}
/**
* Frontmatter data from command markdown files.
*/
interface CommandFrontmatter {
description?: string
agent?: string
subtask?: string | boolean
}
/**
* Keys that must never be used as object properties when building maps from filenames.
* Prevents prototype pollution via "__proto__.md", etc.
*/
const FORBIDDEN_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"])
function isForbiddenObjectKey(key: string): boolean {
return FORBIDDEN_OBJECT_KEYS.has(key)
}
/**
* Parse YAML frontmatter from markdown content.
*/
function parseFrontmatter(content: string): { data: CommandFrontmatter; body: string } {
// Normalize line endings for cross-platform support
const normalizedContent = content.replace(/\r\n/g, "\n")
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
const match = normalizedContent.match(frontmatterRegex)
if (!match) {
return { data: {}, body: normalizedContent }
}
const yamlContent = match[1]
const body = match[2]
const data: CommandFrontmatter = {}
const lines = yamlContent.split("\n")
for (const line of lines) {
const colonIndex = line.indexOf(":")
if (colonIndex === -1) continue
const key = line.slice(0, colonIndex).trim()
let value = line.slice(colonIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (key === "description") data.description = value
if (key === "agent") data.agent = value
if (key === "subtask") {
data.subtask = value === "true" || value === "false" ? value === "true" : value
}
}
return { data, body }
}
/**
* Load commands from a directory.
*/
function loadCommandsFromDir(
commandsDir: string,
disabledCommands: Set<string>,
): Record<string, CommandConfig> {
if (!existsSync(commandsDir)) {
return {}
}
const result: Record<string, CommandConfig> = Object.create(null)
try {
const entries = readdirSync(commandsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue
const commandPath = join(commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
// SECURITY: Skip forbidden gadget keys
if (isForbiddenObjectKey(commandName)) continue
// Skip disabled commands
if (disabledCommands.has(commandName)) continue
try {
const content = readFileSync(commandPath, "utf-8")
const { data } = parseFrontmatter(content)
const config: CommandConfig = {
description: data.description || `Ring command: ${commandName}`,
}
if (data.agent) {
config.agent = data.agent
}
if (typeof data.subtask === "boolean") {
config.subtask = data.subtask
}
// Use ring namespace for commands
result[`ring:${commandName}`] = config
} catch (error) {
if (process.env.RING_DEBUG === "true") {
console.debug(`[ring] Failed to parse ${commandPath}:`, error)
}
}
}
} catch (error) {
if (process.env.RING_DEBUG === "true") {
console.debug(`[ring] Failed to read commands directory:`, error)
}
return {}
}
return result
}
/**
* Validate command references in a directory.
* Checks for references to skills or other commands that don't exist.
*/
function validateCommandReferences(
commandsDir: string,
allSkillNames: Set<string>,
allCommandNames: Set<string>,
): CommandValidationWarning[] {
const warnings: CommandValidationWarning[] = []
if (!existsSync(commandsDir)) {
return warnings
}
try {
const entries = readdirSync(commandsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue
const commandPath = join(commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
try {
const content = readFileSync(commandPath, "utf-8")
// Check for skill references like "skill: <name>" or "@skill-name"
const skillRefs = content.match(/skill:\s*(\S+)/gi) ?? []
for (const ref of skillRefs) {
const skillName = ref.replace(/skill:\s*/i, "").trim()
if (skillName && !allSkillNames.has(skillName)) {
warnings.push({
command: commandName,
issue: `References unknown skill: '${skillName}'`,
severity: "warning",
})
}
}
// Check for command references like "/ring:<name>"
const cmdRefs = content.match(/\/ring:(\S+)/g) ?? []
for (const ref of cmdRefs) {
const refName = ref.replace("/ring:", "")
if (refName && !allCommandNames.has(refName) && refName !== commandName) {
warnings.push({
command: commandName,
issue: `References unknown command: '${refName}'`,
severity: "warning",
})
}
}
} catch {
// Skip unreadable files
}
}
} catch {
// Skip unreadable directories
}
return warnings
}
/**
* Load Ring commands from both Ring and user's .opencode/ directories.
*
* @param pluginRoot - Path to the plugin directory (installed by Ring)
* @param projectRoot - Path to the user's project directory (contains .opencode/)
* @param disabledCommands - List of command names to skip
* @param validateRefs - Whether to validate command references (default: false)
* @returns Object containing merged commands and validation warnings
*/
export function loadRingCommands(
pluginRoot: string,
projectRoot: string,
disabledCommands: string[] = [],
validateRefs = false,
): LoadCommandsResult {
const disabledSet = new Set(disabledCommands)
// Load Ring's built-in commands from assets/command/
const builtInDir = join(pluginRoot, "command")
const builtInCommands = loadCommandsFromDir(builtInDir, disabledSet)
// Load user's custom commands from .opencode/command/
const userDir = join(projectRoot, ".opencode", "command")
const userCommands = loadCommandsFromDir(userDir, disabledSet)
// Merge with user's taking priority, using a null-prototype map
const merged: Record<string, CommandConfig> = Object.create(null)
Object.assign(merged, builtInCommands)
Object.assign(merged, userCommands)
// Validate references if requested
let validation: CommandValidationWarning[] = []
if (validateRefs) {
// Get all skill names (would need skill loader integration)
const allSkillNames = new Set<string>()
const skillsDir = join(pluginRoot, "skill")
if (existsSync(skillsDir)) {
try {
const skillEntries = readdirSync(skillsDir, { withFileTypes: true })
for (const entry of skillEntries) {
if (entry.isDirectory() && !entry.name.startsWith(".")) {
allSkillNames.add(entry.name)
}
}
} catch {
// Ignore errors
}
}
// Get all command names (without ring: prefix)
const allCommandNames = new Set<string>()
for (const key of Object.keys(merged)) {
allCommandNames.add(key.replace("ring:", ""))
}
// Validate built-in commands
validation = validation.concat(
validateCommandReferences(builtInDir, allSkillNames, allCommandNames),
)
// Validate user commands
validation = validation.concat(
validateCommandReferences(userDir, allSkillNames, allCommandNames),
)
}
return { commands: merged, validation }
}
/**
* Get count of available commands from both Ring and user's .opencode/.
*/
export function countRingCommands(pluginRoot: string, projectRoot: string): number {
const uniqueCommands = new Set<string>()
// Count built-in commands
const builtInDir = join(pluginRoot, "command")
if (existsSync(builtInDir)) {
try {
const entries = readdirSync(builtInDir)
for (const f of entries) {
if (f.endsWith(".md")) uniqueCommands.add(f)
}
} catch {
// Ignore errors
}
}
// Count user commands
const userDir = join(projectRoot, ".opencode", "command")
if (existsSync(userDir)) {
try {
const entries = readdirSync(userDir)
for (const f of entries) {
if (f.endsWith(".md")) uniqueCommands.add(f)
}
} catch {
// Ignore errors
}
}
return uniqueCommands.size
}

View file

@ -0,0 +1,32 @@
/**
* Ring Component Loaders
*
* Central export for all component loaders.
*/
// Agent loader
export {
type AgentConfig,
countRingAgents,
loadRingAgents,
} from "./agent-loader.js"
// Command loader
export {
type CommandConfig,
type CommandValidationWarning,
countRingCommands,
type LoadCommandsResult,
loadRingCommands,
} from "./command-loader.js"
// Placeholder utilities
export {
expandPlaceholders,
getOpenCodeConfigDir,
OPENCODE_CONFIG_PLACEHOLDER,
} from "./placeholder-utils.js"
// Skill loader
export {
countRingSkills,
loadRingSkills,
type SkillConfig,
} from "./skill-loader.js"

View file

@ -0,0 +1,81 @@
/**
* Placeholder Expansion Utilities
*
* Expands template placeholders in markdown content before passing to OpenCode.
*
* Supported placeholders:
* - {OPENCODE_CONFIG} - Expands to the OpenCode config directory path
*/
import { homedir } from "node:os"
import { isAbsolute, join } from "node:path"
/**
* Named constant for the placeholder string.
* Using a constant improves maintainability and ensures consistency.
*/
export const OPENCODE_CONFIG_PLACEHOLDER = "{OPENCODE_CONFIG}"
/**
* Get the OpenCode config directory path.
*
* Respects OPENCODE_CONFIG_DIR environment variable if set,
* falls back to XDG_CONFIG_HOME/opencode if set and absolute,
* otherwise defaults to ~/.config/opencode (following XDG standards).
*
* @returns The absolute path to OpenCode's config directory
* @throws Error if home directory cannot be determined
*/
export function getOpenCodeConfigDir(): string {
// Priority 1: Explicit OPENCODE_CONFIG_DIR
if (process.env.OPENCODE_CONFIG_DIR) {
return process.env.OPENCODE_CONFIG_DIR
}
// Priority 2: XDG_CONFIG_HOME (must be absolute path per XDG spec)
// Matches validation in config/loader.ts:196
const xdgConfigHome = process.env.XDG_CONFIG_HOME
if (xdgConfigHome && isAbsolute(xdgConfigHome)) {
return join(xdgConfigHome, "opencode")
}
// Priority 3: Default to ~/.config/opencode
const home = homedir()
if (!home) {
throw new Error(
"Cannot determine home directory. Set OPENCODE_CONFIG_DIR or HOME environment variable.",
)
}
return join(home, ".config", "opencode")
}
/**
* Expand placeholders in markdown content.
*
* Currently supports:
* - {OPENCODE_CONFIG} -> actual config directory path
*
* @param content - The markdown content with placeholders
* @returns Content with all placeholders expanded to their actual values
*
* @example
* ```typescript
* const content = "Read from {OPENCODE_CONFIG}/standards/golang.md"
* const expanded = expandPlaceholders(content)
* // Result: "Read from /Users/john/.config/opencode/standards/golang.md"
* ```
*/
export function expandPlaceholders(content: string): string {
// Input validation: handle null/undefined/non-string gracefully
if (typeof content !== "string") {
return ""
}
if (!content) {
return ""
}
const configDir = getOpenCodeConfigDir()
// Replace all occurrences of {OPENCODE_CONFIG} with the actual path
return content.replace(new RegExp(OPENCODE_CONFIG_PLACEHOLDER, "g"), configDir)
}

View file

@ -0,0 +1,221 @@
/**
* Ring Skill Loader
*
* Loads Ring skills from:
* 1. Ring skill/{name}/SKILL.md files (Ring's built-in skills)
* 2. User's .opencode/skill/{name}/SKILL.md files (user customizations)
*
* User's skills take priority over Ring's built-in skills.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
/**
* Skill configuration compatible with OpenCode SDK.
*/
export interface SkillConfig {
description?: string
agent?: string
subtask?: boolean
}
/**
* Frontmatter data from skill markdown files.
*/
interface SkillFrontmatter {
description?: string
agent?: string
subtask?: string | boolean
}
/**
* Keys that must never be used as object properties when building maps from filenames.
*/
const FORBIDDEN_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"])
function isForbiddenObjectKey(key: string): boolean {
return FORBIDDEN_OBJECT_KEYS.has(key)
}
// TODO(review): Consider using js-yaml for multiline YAML support
/**
* Parse YAML frontmatter from markdown content.
*/
function parseFrontmatter(content: string): { data: SkillFrontmatter; body: string } {
// Normalize line endings for cross-platform support
const normalizedContent = content.replace(/\r\n/g, "\n")
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
const match = normalizedContent.match(frontmatterRegex)
if (!match) {
return { data: {}, body: normalizedContent }
}
const yamlContent = match[1]
const body = match[2]
const data: SkillFrontmatter = {}
const lines = yamlContent.split("\n")
for (const line of lines) {
const colonIndex = line.indexOf(":")
if (colonIndex === -1) continue
const key = line.slice(0, colonIndex).trim()
let value = line.slice(colonIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (key === "description") data.description = value
if (key === "agent") data.agent = value
if (key === "subtask") {
data.subtask = value === "true" || value === "false" ? value === "true" : value
}
}
return { data, body }
}
/**
* Load skills from a directory.
* Expects structure: skill/<skill-name>/SKILL.md
*/
function loadSkillsFromDir(
skillsDir: string,
disabledSkills: Set<string>,
): Record<string, SkillConfig> {
if (!existsSync(skillsDir)) {
return {}
}
const result: Record<string, SkillConfig> = Object.create(null)
try {
const entries = readdirSync(skillsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
const skillName = entry.name
// SECURITY: Skip forbidden gadget keys
if (isForbiddenObjectKey(skillName)) continue
// Skip disabled skills
if (disabledSkills.has(skillName)) continue
// Look for SKILL.md in the directory
const skillFile = join(skillsDir, skillName, "SKILL.md")
if (!existsSync(skillFile)) continue
try {
const content = readFileSync(skillFile, "utf-8")
const { data } = parseFrontmatter(content)
const config: SkillConfig = {
description: data.description || `Ring skill: ${skillName}`,
}
if (data.agent) {
config.agent = data.agent
}
if (typeof data.subtask === "boolean") {
config.subtask = data.subtask
}
// Use ring namespace for skills
result[`ring:${skillName}`] = config
} catch (error) {
if (process.env.RING_DEBUG === "true") {
console.debug(`[ring] Failed to parse skill ${skillFile}:`, error)
}
}
}
} catch (error) {
if (process.env.RING_DEBUG === "true") {
console.debug(`[ring] Failed to read skills directory:`, error)
}
return {}
}
return result
}
/**
* Load Ring skills from both Ring and user's .opencode/ directories.
*
* @param pluginRoot - Path to the plugin directory (installed by Ring)
* @param projectRoot - Path to the user's project directory (contains .opencode/)
* @param disabledSkills - List of skill names to skip
* @returns Merged skill configs with user's taking priority
*/
export function loadRingSkills(
pluginRoot: string,
projectRoot: string,
disabledSkills: string[] = [],
): Record<string, SkillConfig> {
const disabledSet = new Set(disabledSkills)
// Load Ring's built-in skills from assets/skill/
const builtInDir = join(pluginRoot, "skill")
const builtInSkills = loadSkillsFromDir(builtInDir, disabledSet)
// Load user's custom skills from .opencode/skill/
const userDir = join(projectRoot, ".opencode", "skill")
const userSkills = loadSkillsFromDir(userDir, disabledSet)
// Merge with user's taking priority, using a null-prototype map
const merged: Record<string, SkillConfig> = Object.create(null)
Object.assign(merged, builtInSkills)
Object.assign(merged, userSkills)
return merged
}
/**
* Get count of available skills from both Ring and user's .opencode/.
*/
export function countRingSkills(pluginRoot: string, projectRoot: string): number {
const uniqueSkills = new Set<string>()
// Count built-in skills
const builtInDir = join(pluginRoot, "skill")
if (existsSync(builtInDir)) {
try {
const entries = readdirSync(builtInDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const skillFile = join(builtInDir, entry.name, "SKILL.md")
if (existsSync(skillFile)) uniqueSkills.add(entry.name)
}
}
} catch {
// Ignore errors
}
}
// Count user skills
const userDir = join(projectRoot, ".opencode", "skill")
if (existsSync(userDir)) {
try {
const entries = readdirSync(userDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const skillFile = join(userDir, entry.name, "SKILL.md")
if (existsSync(skillFile)) uniqueSkills.add(entry.name)
}
}
} catch {
// Ignore errors
}
}
return uniqueSkills.size
}

View file

@ -0,0 +1,185 @@
/**
* Ring Unified Plugin
*
* Single entry point that matches oh-my-opencode's registration pattern.
* Combines all Ring functionality into one Plugin export.
*
* Features:
* - Config handler: Injects agents, skills, commands
* - Tool registration: Custom Ring tools
* - Event routing: Lifecycle events to hooks
* - System transform: Context injection
* - Compaction: Context preservation
*/
import type { Plugin, PluginInput } from "@opencode-ai/plugin"
import type { Config as OpenCodeSdkConfig } from "@opencode-ai/sdk"
import type { RingConfig } from "./config/index.js"
// Config
import { createConfigHandler, loadConfig } from "./config/index.js"
import { createContextInjectionHook, createSessionStartHook } from "./hooks/factories/index.js"
// Hooks
import { hookRegistry } from "./hooks/index.js"
import type { HookContext, HookOutput } from "./hooks/types.js"
// Lifecycle
import { createLifecycleRouter } from "./lifecycle/index.js"
// Tools
import { createRingTools } from "./tools/index.js"
// Utils
import { getSessionId } from "./utils/state.js"
/**
* Initialize all hooks based on configuration.
*/
function initializeHooks(config: RingConfig): void {
hookRegistry.clear()
hookRegistry.setDisabledHooks(config.disabled_hooks)
const isDisabled = (name: string) => config.disabled_hooks?.includes(name as never) ?? false
if (!isDisabled("session-start")) {
hookRegistry.register(createSessionStartHook(config.hooks?.["session-start"]))
}
if (!isDisabled("context-injection")) {
hookRegistry.register(createContextInjectionHook(config.hooks?.["context-injection"]))
}
}
/**
* Build a HookContext from available context data.
*/
function buildHookContext(
sessionId: string,
directory: string,
lifecycle: HookContext["lifecycle"],
event?: { type: string; properties?: Record<string, unknown> },
): HookContext {
return {
sessionId,
directory,
lifecycle,
event,
}
}
/**
* Ring Unified Plugin
*
* Matches oh-my-opencode's Plugin signature:
* Plugin = (input: PluginInput) => Promise<Hooks>
*
* Note: Return type is inferred to allow custom properties
* that extend the base Hooks interface for Ring-specific functionality.
*/
export const RingUnifiedPlugin: Plugin = async (ctx: PluginInput) => {
const { directory } = ctx
const projectRoot = directory
// Load Ring configuration
const config = loadConfig(projectRoot)
const debug = process.env.RING_DEBUG === "true"
if (debug) {
console.debug("[ring] Initializing unified plugin")
}
// Initialize hooks
initializeHooks(config)
// Create config handler for OpenCode config injection
const configHandler = createConfigHandler({
projectRoot,
ringConfig: config,
})
// Create lifecycle router (state side-effects only)
const lifecycleRouter = createLifecycleRouter({
projectRoot,
ringConfig: config,
})
const sessionId = getSessionId()
const ringTools = createRingTools(directory)
return {
// Register Ring tools
tool: ringTools,
// Config handler - inject agents, skills, commands
// Type assertion needed: our handler modifies a subset of config properties
// that are compatible with the SDK's Config type at runtime
config: configHandler as unknown as (input: OpenCodeSdkConfig) => Promise<void>,
// Event handler - lifecycle routing + hook execution
event: async ({ event }) => {
// Route to lifecycle router
await lifecycleRouter({ event })
// Build output object for hooks to modify
const output: HookOutput = {}
// Extract sessionID from event properties (may be present in various event types)
const props = event.properties as Record<string, unknown> | undefined
const eventSessionId = (props?.sessionID as string) ?? sessionId
// Build normalized event for hook context
const normalizedEvent = {
type: event.type,
properties: props,
}
// Execute hooks based on event type
if (event.type === "session.created") {
const hookCtx = buildHookContext(
eventSessionId,
directory,
"session.created",
normalizedEvent,
)
await hookRegistry.executeLifecycle("session.created", hookCtx, output)
}
if (event.type === "session.idle") {
const hookCtx = buildHookContext(eventSessionId, directory, "session.idle", normalizedEvent)
await hookRegistry.executeLifecycle("session.idle", hookCtx, output)
}
if (event.type === "session.error") {
const hookCtx = buildHookContext(
eventSessionId,
directory,
"session.error",
normalizedEvent,
)
await hookRegistry.executeLifecycle("session.error", hookCtx, output)
}
},
// System prompt transformation
"experimental.chat.system.transform": async (
_input: Record<string, unknown>,
output: { system: string[] },
) => {
if (!output?.system || !Array.isArray(output.system)) return
const hookCtx = buildHookContext(sessionId, directory, "chat.params")
const hookOutput: HookOutput = { system: output.system }
await hookRegistry.executeLifecycle("chat.params", hookCtx, hookOutput)
},
// Compaction context injection
"experimental.session.compacting": async (
input: { sessionID: string },
output: { context: string[] },
) => {
if (!output?.context || !Array.isArray(output.context)) return
const hookCtx = buildHookContext(input.sessionID, directory, "session.compacting")
const hookOutput: HookOutput = { context: output.context }
await hookRegistry.executeLifecycle("session.compacting", hookCtx, hookOutput)
},
}
}
export default RingUnifiedPlugin

View file

@ -0,0 +1,20 @@
/**
* Ring Tools
*
* Custom tools registered by Ring plugin.
* Currently a placeholder - orchestrator tools have been removed.
*/
/**
* Create Ring tools (currently returns empty object).
* Orchestrator and background task tools have been removed for simplification.
*/
export function createRingTools(_directory: string, _options: Record<string, unknown> = {}) {
return {}
}
/**
* Legacy export for backwards compatibility.
* @deprecated Use createRingTools(directory) instead.
*/
export const ringTools = {}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["bun-types", "node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": ".",
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,252 @@
/**
* Ring State Management Utilities
*
* Provides state persistence and utility functions for hooks.
*/
import { randomBytes } from "node:crypto"
import * as fs from "node:fs"
import * as path from "node:path"
/** State directory name within .ring */
const STATE_DIR = ".ring/state"
/**
* Get the state directory path for a project.
*/
function getStateDir(directory: string): string {
return path.join(directory, STATE_DIR)
}
/**
* Get the state file path for a key and session.
*/
function getStateFilePath(directory: string, key: string, sessionId: string): string {
const stateDir = getStateDir(directory)
const sanitizedKey = key.replace(/[^a-zA-Z0-9-_]/g, "_")
const sanitizedSession = sessionId.replace(/[^a-zA-Z0-9-_]/g, "_")
return path.join(stateDir, `${sanitizedKey}-${sanitizedSession}.json`)
}
/**
* Clean up old state files beyond max age.
*/
export function cleanupOldState(directory: string, maxAgeDays: number): void {
const stateDir = getStateDir(directory)
if (!fs.existsSync(stateDir)) {
return
}
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000
const now = Date.now()
try {
const files = fs.readdirSync(stateDir)
for (const file of files) {
if (!file.endsWith(".json")) {
continue
}
const filePath = path.join(stateDir, file)
const stats = fs.statSync(filePath)
const age = now - stats.mtimeMs
if (age > maxAgeMs) {
fs.unlinkSync(filePath)
}
}
} catch (error) {
// Log cleanup errors in debug mode
if (process.env.RING_DEBUG) {
console.debug(`[ring] State cleanup failed:`, error)
}
}
}
/**
* Delete a specific state file.
*/
export function deleteState(directory: string, key: string, sessionId: string): void {
const filePath = getStateFilePath(directory, key, sessionId)
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
} catch (error) {
// Log delete errors in debug mode
if (process.env.RING_DEBUG) {
console.debug(`[ring] State delete failed:`, error)
}
}
}
/**
* Get or generate a session ID.
* Uses environment variable if available, otherwise generates one.
*/
export function getSessionId(): string {
// Check for OpenCode session ID in environment
const envSessionId = process.env.OPENCODE_SESSION_ID
if (envSessionId) {
return envSessionId
}
// Generate a new session ID based on timestamp and cryptographically secure random suffix
const timestamp = Date.now().toString(36)
const random = randomBytes(6).toString("hex")
return `session-${timestamp}-${random}`
}
/**
* Escape angle brackets to prevent XML/HTML injection in prompts.
*/
export function escapeAngleBrackets(str: string): string {
return str.replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
/**
* Sanitize a string for safe inclusion in prompts.
* Escapes special characters and truncates to max length.
*/
export function sanitizeForPrompt(str: string, maxLength: number): string {
let sanitized = str
// Remove null bytes
.replace(/\0/g, "")
// Escape angle brackets
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// Normalize whitespace
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
// Truncate if needed
if (sanitized.length > maxLength) {
sanitized = `${sanitized.substring(0, maxLength - 3)}...`
}
return sanitized
}
/**
* Check if a path is within a root directory.
* Prevents path traversal attacks.
*/
export function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
const resolvedTarget = path.resolve(targetPath)
const resolvedRoot = path.resolve(rootPath)
// Ensure root ends with separator for proper prefix matching
const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(rootWithSep)
}
/**
* Write state data to a file.
*/
export function writeState(directory: string, key: string, data: unknown, sessionId: string): void {
const stateDir = getStateDir(directory)
const filePath = getStateFilePath(directory, key, sessionId)
try {
// Ensure state directory exists
if (!fs.existsSync(stateDir)) {
fs.mkdirSync(stateDir, { recursive: true })
}
// Write state with timestamp
const stateData = {
key,
sessionId,
timestamp: new Date().toISOString(),
data,
}
fs.writeFileSync(filePath, JSON.stringify(stateData, null, 2), {
encoding: "utf-8",
mode: 0o600, // Owner read/write only
})
} catch (error) {
// Log write errors in debug mode
if (process.env.RING_DEBUG) {
console.debug(`[ring] State write failed:`, error)
}
}
}
/**
* Read state data from a file.
*/
export function readState<T = unknown>(
directory: string,
key: string,
sessionId: string,
): T | null {
const filePath = getStateFilePath(directory, key, sessionId)
try {
if (!fs.existsSync(filePath)) {
return null
}
const content = fs.readFileSync(filePath, "utf-8")
const stateData = JSON.parse(content)
return stateData.data as T
} catch (error) {
// Log read errors in debug mode
if (process.env.RING_DEBUG) {
console.debug(`[ring] State read failed:`, error)
}
return null
}
}
/**
* Find the most recent file matching a pattern in a directory.
*/
export function findMostRecentFile(directory: string, pattern: RegExp): string | null {
try {
if (!fs.existsSync(directory)) {
return null
}
const files = fs.readdirSync(directory)
let mostRecent: { path: string; mtime: number } | null = null
for (const file of files) {
if (!pattern.test(file)) {
continue
}
const filePath = path.join(directory, file)
// Use lstat so symlink mtimes are respected (security: allows rejecting symlink targets)
const stats = fs.lstatSync(filePath)
if (!mostRecent || stats.mtimeMs > mostRecent.mtime) {
mostRecent = { path: filePath, mtime: stats.mtimeMs }
}
}
return mostRecent?.path ?? null
} catch {
return null
}
}
/**
* Read file contents safely.
*/
export function readFileSafe(filePath: string): string | null {
try {
if (!fs.existsSync(filePath)) {
return null
}
return fs.readFileSync(filePath, "utf-8")
} catch {
return null
}
}

View file

@ -0,0 +1,10 @@
## Critical Rules
Before and during implementation:
- [ ] Verify all changes compile and work
- [ ] Fix failing tests before proceeding
- [ ] No secrets or credentials in code
- [ ] Ask if requirements are unclear
- [ ] Match existing conventions and patterns
- [ ] Preserve working code (don't break what works)
- [ ] Use todowrite tool for multi-step tasks

View file

@ -0,0 +1,19 @@
OpenCode Configuration Directory: ~/.config/opencode/
├── skill/ - Skill definitions (SKILL.md files)
├── agent/ - Agent prompts (.md files)
├── command/ - Slash command definitions (.md files)
├── standards/ - Ring coding standards (.md files)
├── templates/ - Templates (e.g., PROJECT_RULES.md)
├── plugin/ - TypeScript plugin infrastructure
├── ring/ - Ring configuration (config.jsonc)
└── AGENTS.md - Global agent instructions
Variable Convention:
- {OPENCODE_CONFIG} = ~/.config/opencode
- {PROJECT_ROOT} = current working directory
When skills/agents reference paths like "{OPENCODE_CONFIG}/standards/golang.md",
read from ~/.config/opencode/standards/golang.md
When skills reference "{PROJECT_ROOT}/docs/PROJECT_RULES.md",
read from the current project's docs/PROJECT_RULES.md file.

View file

@ -0,0 +1,8 @@
## Quality Checklist
Before completing any task:
- [ ] Code compiles without errors
- [ ] Tests pass (if applicable)
- [ ] No console.log/print debugging left behind
- [ ] Error handling is in place
- [ ] Changes are minimal and focused

View file

@ -0,0 +1,9 @@
## Critical Rules (NON-NEGOTIABLE)
1. **NEVER skip verification** - Always verify changes work before claiming completion
2. **NEVER ignore test failures** - Fix failing tests, don't comment them out
3. **NEVER commit secrets** - Check for API keys, passwords, tokens before committing
4. **NEVER make assumptions** - If unclear, ask for clarification
5. **ALWAYS follow project conventions** - Match existing code style and patterns
6. **ALWAYS preserve working code** - Don't break existing functionality
7. **ALWAYS track multi-step tasks** - Use the todo list

View file

@ -0,0 +1,9 @@
## When In Doubt
Ask yourself:
1. Is this requirement clear enough to implement?
2. Are there edge cases I should clarify?
3. Does this conflict with existing behavior?
4. Should I confirm the approach before proceeding?
If ANY answer is "no" or "maybe" - ASK THE USER before proceeding.

View file

@ -0,0 +1,7 @@
## Avoid Duplication
Before implementing:
1. Check if similar functionality already exists
2. Reuse existing utilities and helpers
3. Don't reinvent patterns that are already established
4. Reference existing code for style consistency

View file

@ -0,0 +1,16 @@
OpenCode Configuration Directory: ~/.config/opencode/
├── skill/ - Skill definitions (SKILL.md files)
├── agent/ - Agent prompts (.md files)
├── command/ - Slash command definitions (.md files)
├── standards/ - Ring coding standards (.md files)
├── templates/ - Templates (e.g., PROJECT_RULES.md)
├── plugin/ - TypeScript plugin infrastructure
├── ring/ - Ring configuration (config.jsonc)
└── AGENTS.md - Global agent instructions
Variable Convention:
- {OPENCODE_CONFIG} = ~/.config/opencode
- {PROJECT_ROOT} = current working directory
When skills reference paths like "{OPENCODE_CONFIG}/standards/golang.md",
read from ~/.config/opencode/standards/golang.md

View file

@ -0,0 +1,140 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/LerianStudio/ring-for-opencode/main/assets/ring-config.schema.json",
"title": "Ring Configuration",
"description": "Configuration schema for Ring",
"type": "object",
"properties": {
"$schema": {
"type": "string"
},
"disabled_hooks": {
"default": [],
"type": "array",
"items": {
"type": "string",
"enum": [
"session-start",
"context-injection"
]
}
},
"disabled_agents": {
"default": [],
"type": "array",
"items": {
"type": "string",
"enum": [
"code-reviewer",
"security-reviewer",
"business-logic-reviewer",
"test-reviewer",
"nil-safety-reviewer",
"codebase-explorer",
"write-plan",
"backend-engineer-golang",
"backend-engineer-typescript",
"frontend-engineer",
"frontend-designer",
"devops-engineer",
"sre",
"qa-analyst"
]
}
},
"disabled_skills": {
"default": [],
"type": "array",
"items": {
"type": "string",
"enum": [
"using-ring-opencode",
"test-driven-development",
"requesting-code-review",
"writing-plans",
"executing-plans",
"brainstorming",
"linting-codebase",
"using-git-worktrees",
"exploring-codebase",
"handoff-tracking",
"interviewing-user",
"receiving-code-review",
"using-dev-team",
"writing-skills",
"dev-cycle",
"dev-devops",
"dev-feedback-loop",
"dev-implementation",
"dev-refactor",
"dev-sre",
"dev-testing",
"dev-validation",
"visual-explainer"
]
}
},
"disabled_commands": {
"default": [],
"type": "array",
"items": {
"type": "string",
"enum": [
"brainstorm",
"codereview",
"commit",
"create-handoff",
"dev-cancel",
"dev-cycle",
"dev-refactor",
"dev-report",
"dev-status",
"execute-plan",
"explore-codebase",
"lint",
"md-to-html",
"resume-handoff",
"worktree",
"write-plan"
]
}
},
"experimental": {
"default": {
"preemptiveCompaction": false,
"compactionThreshold": 0.8,
"aggressiveTruncation": false
},
"type": "object",
"properties": {
"preemptiveCompaction": {
"default": false,
"type": "boolean"
},
"compactionThreshold": {
"default": 0.8,
"type": "number",
"minimum": 0.5,
"maximum": 0.95
},
"aggressiveTruncation": {
"default": false,
"type": "boolean"
}
}
},
"hooks": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
}
}
}

View file

@ -0,0 +1,40 @@
{
"$schema": "./ring-config.schema.json",
// Hooks to disable. Allowed: "session-start", "context-injection".
"disabled_hooks": [],
// Agents to disable (35 total). Examples:
// "code-reviewer", "security-reviewer", "business-logic-reviewer", "test-reviewer",
// "nil-safety-reviewer", "consequences-reviewer", "codebase-explorer", "write-plan",
// "backend-engineer-golang", "backend-engineer-typescript", "frontend-engineer",
// "frontend-bff-engineer-typescript", "frontend-designer", "ui-engineer",
// "devops-engineer", "sre", "qa-analyst", "qa-analyst-frontend",
// "prompt-quality-reviewer", "product-designer", "best-practices-researcher",
// "framework-docs-researcher", "repo-research-analyst",
// "api-writer", "functional-writer", "docs-reviewer",
// "portfolio-manager", "resource-planner", "risk-analyst", "governance-specialist",
// "executive-reporter", "delivery-reporter",
// "finops-analyzer", "finops-automation", "infrastructure-cost-estimator"
"disabled_agents": [],
// Skills to disable (86 total). Examples:
// "using-ring", "test-driven-development", "requesting-code-review", "brainstorming",
// "dev-cycle", "dev-implementation", "dev-unit-testing", "dev-delivery-verification",
// "pre-dev-prd-creation", "pre-dev-research", "visual-explainer", "drawing-diagrams"
"disabled_skills": [],
// Commands to disable (33 total). Examples:
// "brainstorm", "codereview", "commit", "dev-cycle", "dev-refactor",
// "explore-codebase", "lint", "write-plan", "diagram", "visualize"
"disabled_commands": [],
"experimental": {
"preemptiveCompaction": false,
"compactionThreshold": 0.8,
"aggressiveTruncation": false
},
// Hook-specific overrides keyed by hook name.
"hooks": {}
}

View file

@ -0,0 +1,34 @@
{
"$schema": "https://raw.githubusercontent.com/LerianStudio/ring-for-opencode/main/assets/ring-config.schema.json",
// Hooks to disable. Allowed: "session-start", "context-injection".
"disabled_hooks": [],
// Agents to disable. Allowed: "code-reviewer", "security-reviewer", "business-logic-reviewer",
// "test-reviewer", "nil-safety-reviewer", "codebase-explorer", "write-plan",
// "backend-engineer-golang", "backend-engineer-typescript", "frontend-engineer",
// "frontend-designer", "devops-engineer", "sre", "qa-analyst".
"disabled_agents": [],
// Skills to disable. Allowed: "using-ring-opencode", "test-driven-development",
// "requesting-code-review", "writing-plans", "executing-plans", "brainstorming",
// "linting-codebase", "using-git-worktrees", "exploring-codebase", "handoff-tracking",
// "interviewing-user", "receiving-code-review", "using-dev-team", "writing-skills",
// "dev-cycle", "dev-devops", "dev-feedback-loop", "dev-implementation", "dev-refactor",
// "dev-sre", "dev-testing", "dev-validation".
"disabled_skills": [],
// Commands to disable. Allowed: "brainstorm", "codereview", "commit", "create-handoff",
// "dev-cancel", "dev-cycle", "dev-refactor", "dev-report", "dev-status", "execute-plan",
// "explore-codebase", "lint", "resume-handoff", "worktree", "write-plan".
"disabled_commands": [],
"experimental": {
"preemptiveCompaction": false,
"compactionThreshold": 0.8,
"aggressiveTruncation": false
},
// Hook-specific overrides keyed by hook name.
"hooks": {}
}

View file

@ -0,0 +1,197 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { dirname, join } from "node:path"
import { RingOpenCodeConfigSchema } from "../config"
import { parseJsonc } from "../shared"
import { PROJECT_CONFIG_PATHS, SCHEMA_URL, USER_CONFIG_PATHS } from "./constants"
import type { ConfigMergeResult, DetectedConfig } from "./types"
interface NodeError extends Error {
code?: string
}
function isPermissionError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM"
}
function isFileNotFoundError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "ENOENT"
}
function formatErrorWithSuggestion(err: unknown, context: string): string {
if (isPermissionError(err)) {
return `Permission denied: Cannot ${context}. Try running with elevated permissions.`
}
if (isFileNotFoundError(err)) {
return `File not found while trying to ${context}.`
}
if (err instanceof SyntaxError) {
return `JSON syntax error while trying to ${context}: ${err.message}`
}
const message = err instanceof Error ? err.message : String(err)
return `Failed to ${context}: ${message}`
}
/**
* Get candidate config paths (project then user)
*/
export function getConfigPaths(): string[] {
const projectPaths = PROJECT_CONFIG_PATHS.map((configPath) => join(process.cwd(), configPath))
const userPaths = USER_CONFIG_PATHS.map((configPath) => join(homedir(), configPath))
return [...projectPaths, ...userPaths]
}
/**
* Get the active config path
*/
export function getConfigPath(): string {
const configPaths = getConfigPaths()
for (const configPath of configPaths) {
if (existsSync(configPath)) {
return configPath
}
}
return configPaths[0]
}
/**
* Detect current Ring configuration
*/
export function detectCurrentConfig(): DetectedConfig {
const configPath = getConfigPath()
const result: DetectedConfig = {
isInstalled: false,
configPath: null,
hasSchema: false,
version: null,
}
if (!existsSync(configPath)) {
return result
}
result.configPath = configPath
try {
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<Record<string, unknown>>(content)
if (config) {
result.isInstalled = true
result.hasSchema = typeof config.$schema === "string"
result.version = typeof config.version === "string" ? config.version : null
}
} catch {
// Config exists but is invalid
result.isInstalled = false
}
return result
}
/**
* Validate configuration against Zod schema
*/
export function validateConfig(configPath: string): { valid: boolean; errors: string[] } {
try {
const content = readFileSync(configPath, "utf-8")
const rawConfig = parseJsonc<Record<string, unknown>>(content)
const result = RingOpenCodeConfigSchema.safeParse(rawConfig)
if (!result.success) {
const errors = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
return { valid: false, errors }
}
return { valid: true, errors: [] }
} catch (err) {
return {
valid: false,
errors: [err instanceof Error ? err.message : "Failed to parse config"],
}
}
}
/**
* Add $schema to existing config for IDE autocomplete
*/
export function addSchemaToConfig(): ConfigMergeResult {
const configPath = getConfigPath()
try {
if (!existsSync(configPath)) {
// Create minimal config with schema
const config = {
$schema: SCHEMA_URL,
version: "1.0.0",
name: "ring-opencode",
description: "Ring configuration",
}
mkdirSync(dirname(configPath), { recursive: true })
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
return { success: true, configPath }
}
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<Record<string, unknown>>(content)
if (!config) {
return { success: false, configPath, error: "Failed to parse existing config" }
}
// Already has schema
if (config.$schema === SCHEMA_URL) {
return { success: true, configPath }
}
// Add/update $schema - destructure to avoid duplicate key
const { $schema: _existingSchema, ...restConfig } = config as { $schema?: string }
const finalConfig = { $schema: SCHEMA_URL, ...restConfig }
writeFileSync(configPath, `${JSON.stringify(finalConfig, null, 2)}\n`)
return { success: true, configPath }
} catch (err) {
return { success: false, configPath, error: formatErrorWithSuggestion(err, "update config") }
}
}
/**
* Check if opencode CLI is installed
*/
export async function isOpenCodeInstalled(): Promise<boolean> {
try {
const proc = Bun.spawn(["opencode", "--version"], {
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
return proc.exitCode === 0
} catch {
return false
}
}
/**
* Get opencode version
*/
export async function getOpenCodeVersion(): Promise<string | null> {
try {
const proc = Bun.spawn(["opencode", "--version"], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return output.trim()
}
return null
} catch {
return null
}
}

View file

@ -0,0 +1,37 @@
import color from "picocolors"
export const PACKAGE_NAME = "ring-opencode"
export const PROJECT_CONFIG_PATHS = [
".opencode/ring.jsonc",
".opencode/ring.json",
".ring/config.jsonc",
".ring/config.json",
]
export const USER_CONFIG_PATHS = [
".config/opencode/ring/config.jsonc",
".config/opencode/ring/config.json",
]
export const SCHEMA_URL =
"https://raw.githubusercontent.com/LerianStudio/ring-for-opencode/main/assets/ring-config.schema.json"
export const SYMBOLS = {
check: color.green("\u2713"),
cross: color.red("\u2717"),
warn: color.yellow("\u26A0"),
info: color.blue("\u2139"),
arrow: color.cyan("\u2192"),
bullet: color.dim("\u2022"),
skip: color.dim("\u25CB"),
} as const
export const STATUS_COLORS = {
pass: color.green,
fail: color.red,
warn: color.yellow,
skip: color.dim,
} as const
export const EXIT_CODES = {
SUCCESS: 0,
FAILURE: 1,
} as const

View file

@ -0,0 +1,113 @@
import { existsSync } from "node:fs"
import { detectCurrentConfig, getConfigPath, validateConfig } from "../../config-manager"
import { SCHEMA_URL } from "../../constants"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import type { CheckDefinition, CheckResult } from "../types"
export async function checkConfigExists(): Promise<CheckResult> {
const configPath = getConfigPath()
const exists = existsSync(configPath)
if (!exists) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_EXISTS],
status: "fail",
message: "Ring config not found",
details: [
"Create .opencode/ring.jsonc or .ring/config.jsonc in project root",
"Or create ~/.config/opencode/ring/config.jsonc",
"Run: ring install",
],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_EXISTS],
status: "pass",
message: "Found",
details: [`Path: ${configPath}`],
}
}
export async function checkConfigValidity(): Promise<CheckResult> {
const configPath = getConfigPath()
if (!existsSync(configPath)) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "skip",
message: "No config file to validate",
}
}
const validation = validateConfig(configPath)
if (!validation.valid) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "fail",
message: "Configuration has validation errors",
details: [`Path: ${configPath}`, ...validation.errors.map((e) => `Error: ${e}`)],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "pass",
message: "Valid configuration",
details: [`Path: ${configPath}`],
}
}
export async function checkSchemaPresent(): Promise<CheckResult> {
const detected = detectCurrentConfig()
if (!detected.isInstalled) {
return {
name: CHECK_NAMES[CHECK_IDS.SCHEMA_PRESENT],
status: "skip",
message: "No config file",
}
}
if (!detected.hasSchema) {
return {
name: CHECK_NAMES[CHECK_IDS.SCHEMA_PRESENT],
status: "warn",
message: "No $schema reference for IDE autocomplete",
details: [`Add to Ring config: "$schema": "${SCHEMA_URL}"`, "Or run: ring install"],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.SCHEMA_PRESENT],
status: "pass",
message: "Schema reference present",
}
}
export function getConfigCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.CONFIG_EXISTS,
name: CHECK_NAMES[CHECK_IDS.CONFIG_EXISTS],
category: "configuration",
check: checkConfigExists,
critical: true,
},
{
id: CHECK_IDS.CONFIG_VALIDATION,
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
category: "configuration",
check: checkConfigValidity,
critical: false,
},
{
id: CHECK_IDS.SCHEMA_PRESENT,
name: CHECK_NAMES[CHECK_IDS.SCHEMA_PRESENT],
category: "configuration",
check: checkSchemaPresent,
critical: false,
},
]
}

View file

@ -0,0 +1,80 @@
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import type { CheckDefinition, CheckResult } from "../types"
async function checkCommandExists(
command: string,
args: string[],
): Promise<{ exists: boolean; version: string | null }> {
try {
const proc = Bun.spawn([command, ...args], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return { exists: true, version: output.trim().split("\n")[0] }
}
return { exists: false, version: null }
} catch {
return { exists: false, version: null }
}
}
export async function checkBunInstalled(): Promise<CheckResult> {
const result = await checkCommandExists("bun", ["--version"])
if (!result.exists) {
return {
name: CHECK_NAMES[CHECK_IDS.BUN_INSTALLED],
status: "fail",
message: "Bun is not installed",
details: ["Install Bun: curl -fsSL https://bun.sh/install | bash"],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.BUN_INSTALLED],
status: "pass",
message: `Version ${result.version}`,
}
}
export async function checkGitInstalled(): Promise<CheckResult> {
const result = await checkCommandExists("git", ["--version"])
if (!result.exists) {
return {
name: CHECK_NAMES[CHECK_IDS.GIT_INSTALLED],
status: "warn",
message: "Git is not installed",
details: ["Git is recommended for version control", "Install: https://git-scm.com/downloads"],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.GIT_INSTALLED],
status: "pass",
message: result.version ?? "Installed",
}
}
export function getDependencyCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.BUN_INSTALLED,
name: CHECK_NAMES[CHECK_IDS.BUN_INSTALLED],
category: "dependencies",
check: checkBunInstalled,
critical: true,
},
{
id: CHECK_IDS.GIT_INSTALLED,
name: CHECK_NAMES[CHECK_IDS.GIT_INSTALLED],
category: "dependencies",
check: checkGitInstalled,
critical: false,
},
]
}

View file

@ -0,0 +1,17 @@
import type { CheckDefinition } from "../types"
import { getConfigCheckDefinitions } from "./config"
import { getDependencyCheckDefinitions } from "./dependencies"
import { getInstallationCheckDefinitions, getPluginCheckDefinitions } from "./installation"
export * from "./config"
export * from "./dependencies"
export * from "./installation"
export function getAllCheckDefinitions(): CheckDefinition[] {
return [
...getInstallationCheckDefinitions(),
...getConfigCheckDefinitions(),
...getPluginCheckDefinitions(),
...getDependencyCheckDefinitions(),
]
}

View file

@ -0,0 +1,126 @@
import { existsSync } from "node:fs"
import { join } from "node:path"
import { getOpenCodeVersion, isOpenCodeInstalled } from "../../config-manager"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import type { CheckDefinition, CheckResult } from "../types"
export async function checkOpenCodeInstallation(): Promise<CheckResult> {
const installed = await isOpenCodeInstalled()
if (!installed) {
return {
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
status: "fail",
message: "OpenCode is not installed",
details: [
"Install OpenCode: https://opencode.ai/docs",
"Run: curl -fsSL https://opencode.ai/install | bash",
],
}
}
const version = await getOpenCodeVersion()
return {
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
status: "pass",
message: version ? `Version ${version}` : "Installed",
}
}
export async function checkPluginDirectory(): Promise<CheckResult> {
const pluginDir = join(process.cwd(), "plugin")
const exists = existsSync(pluginDir)
if (!exists) {
return {
name: CHECK_NAMES[CHECK_IDS.PLUGIN_DIRECTORY],
status: "warn",
message: "Plugin directory not found",
details: ["Expected: ./plugin/"],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.PLUGIN_DIRECTORY],
status: "pass",
message: "Found",
details: [`Path: ${pluginDir}`],
}
}
export async function checkSkillDirectory(): Promise<CheckResult> {
const skillDir = join(process.cwd(), "skill")
const exists = existsSync(skillDir)
if (!exists) {
return {
name: CHECK_NAMES[CHECK_IDS.SKILL_DIRECTORY],
status: "warn",
message: "Skill directory not found",
details: ["Expected: ./skill/"],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.SKILL_DIRECTORY],
status: "pass",
message: "Found",
details: [`Path: ${skillDir}`],
}
}
export async function checkStateDirectory(): Promise<CheckResult> {
const stateDir = join(process.cwd(), ".opencode", "state")
const exists = existsSync(stateDir)
if (!exists) {
return {
name: CHECK_NAMES[CHECK_IDS.STATE_DIRECTORY],
status: "skip",
message: "State directory will be created on first run",
details: ["Expected: ./.opencode/state/"],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.STATE_DIRECTORY],
status: "pass",
message: "Found",
details: [`Path: ${stateDir}`],
}
}
export function getInstallationCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.OPENCODE_INSTALLATION,
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
category: "installation",
check: checkOpenCodeInstallation,
critical: true,
},
]
}
export function getPluginCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.PLUGIN_DIRECTORY,
name: CHECK_NAMES[CHECK_IDS.PLUGIN_DIRECTORY],
category: "plugins",
check: checkPluginDirectory,
},
{
id: CHECK_IDS.SKILL_DIRECTORY,
name: CHECK_NAMES[CHECK_IDS.SKILL_DIRECTORY],
category: "plugins",
check: checkSkillDirectory,
},
{
id: CHECK_IDS.STATE_DIRECTORY,
name: CHECK_NAMES[CHECK_IDS.STATE_DIRECTORY],
category: "configuration",
check: checkStateDirectory,
},
]
}

View file

@ -0,0 +1,33 @@
// Re-export shared constants from parent module
export { EXIT_CODES, STATUS_COLORS, SYMBOLS } from "../constants"
export const CHECK_IDS = {
OPENCODE_INSTALLATION: "opencode-installation",
CONFIG_EXISTS: "config-exists",
CONFIG_VALIDATION: "config-validation",
SCHEMA_PRESENT: "schema-present",
PLUGIN_DIRECTORY: "plugin-directory",
SKILL_DIRECTORY: "skill-directory",
STATE_DIRECTORY: "state-directory",
BUN_INSTALLED: "bun-installed",
GIT_INSTALLED: "git-installed",
} as const
export const CHECK_NAMES: Record<string, string> = {
[CHECK_IDS.OPENCODE_INSTALLATION]: "OpenCode Installation",
[CHECK_IDS.CONFIG_EXISTS]: "Configuration File",
[CHECK_IDS.CONFIG_VALIDATION]: "Configuration Validity",
[CHECK_IDS.SCHEMA_PRESENT]: "Schema Reference",
[CHECK_IDS.PLUGIN_DIRECTORY]: "Plugin Directory",
[CHECK_IDS.SKILL_DIRECTORY]: "Skill Directory",
[CHECK_IDS.STATE_DIRECTORY]: "State Directory",
[CHECK_IDS.BUN_INSTALLED]: "Bun Runtime",
[CHECK_IDS.GIT_INSTALLED]: "Git",
} as const
export const CATEGORY_NAMES: Record<string, string> = {
installation: "Installation",
configuration: "Configuration",
plugins: "Plugins & Skills",
dependencies: "Dependencies",
} as const

View file

@ -0,0 +1,84 @@
import color from "picocolors"
import { CATEGORY_NAMES, STATUS_COLORS, SYMBOLS } from "./constants"
import type { CheckCategory, CheckResult, DoctorResult, DoctorSummary } from "./types"
export function formatStatusSymbol(status: CheckResult["status"]): string {
switch (status) {
case "pass":
return SYMBOLS.check
case "fail":
return SYMBOLS.cross
case "warn":
return SYMBOLS.warn
case "skip":
return SYMBOLS.skip
}
}
export function formatCheckResult(result: CheckResult, verbose: boolean): string {
const symbol = formatStatusSymbol(result.status)
const colorFn = STATUS_COLORS[result.status]
const name = colorFn(result.name)
const message = color.dim(result.message)
let line = ` ${symbol} ${name}`
if (result.message) {
line += ` ${SYMBOLS.arrow} ${message}`
}
if (verbose && result.details && result.details.length > 0) {
const detailLines = result.details
.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`)
.join("\n")
line += `\n${detailLines}`
}
return line
}
export function formatCategoryHeader(category: CheckCategory): string {
const name = CATEGORY_NAMES[category] || category
return `\n${color.bold(color.white(name))}\n${color.dim("\u2500".repeat(40))}`
}
export function formatSummary(summary: DoctorSummary): string {
const lines: string[] = []
lines.push(color.bold(color.white("Summary")))
lines.push(color.dim("\u2500".repeat(40)))
lines.push("")
const passText =
summary.passed > 0 ? color.green(`${summary.passed} passed`) : color.dim("0 passed")
const failText =
summary.failed > 0 ? color.red(`${summary.failed} failed`) : color.dim("0 failed")
const warnText =
summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : color.dim("0 warnings")
const skipText = summary.skipped > 0 ? color.dim(`${summary.skipped} skipped`) : ""
const parts = [passText, failText, warnText]
if (skipText) parts.push(skipText)
lines.push(` ${parts.join(", ")}`)
lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`)
return lines.join("\n")
}
export function formatHeader(): string {
return `\n${color.bgCyan(color.white(" Ring Doctor "))}\n`
}
export function formatFooter(summary: DoctorSummary): string {
if (summary.failed > 0) {
return `\n${SYMBOLS.cross} ${color.red("Issues detected. Please review the errors above.")}\n`
}
if (summary.warnings > 0) {
return `\n${SYMBOLS.warn} ${color.yellow("All systems operational with warnings.")}\n`
}
return `\n${SYMBOLS.check} ${color.green("All systems operational!")}\n`
}
export function formatJsonOutput(result: DoctorResult): string {
return JSON.stringify(result, null, 2)
}

View file

@ -0,0 +1,11 @@
import { runDoctor } from "./runner"
import type { DoctorOptions } from "./types"
export async function doctor(options: DoctorOptions = {}): Promise<number> {
const result = await runDoctor(options)
return result.exitCode
}
export { formatJsonOutput } from "./formatter"
export { runDoctor } from "./runner"
export * from "./types"

View file

@ -0,0 +1,125 @@
import { getAllCheckDefinitions } from "./checks"
import { EXIT_CODES } from "./constants"
import {
formatCategoryHeader,
formatCheckResult,
formatFooter,
formatHeader,
formatJsonOutput,
formatSummary,
} from "./formatter"
import type {
CheckCategory,
CheckDefinition,
CheckResult,
DoctorOptions,
DoctorResult,
DoctorSummary,
} from "./types"
export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
const start = performance.now()
try {
const result = await check.check()
result.duration = Math.round(performance.now() - start)
return result
} catch (err) {
return {
name: check.name,
status: "fail",
message: err instanceof Error ? err.message : "Unknown error",
duration: Math.round(performance.now() - start),
}
}
}
export function calculateSummary(results: CheckResult[], duration: number): DoctorSummary {
return {
total: results.length,
passed: results.filter((r) => r.status === "pass").length,
failed: results.filter((r) => r.status === "fail").length,
warnings: results.filter((r) => r.status === "warn").length,
skipped: results.filter((r) => r.status === "skip").length,
duration: Math.round(duration),
}
}
export function determineExitCode(results: CheckResult[]): number {
const hasFailures = results.some((r) => r.status === "fail")
return hasFailures ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS
}
export function filterChecksByCategory(
checks: CheckDefinition[],
category?: CheckCategory,
): CheckDefinition[] {
if (!category) return checks
return checks.filter((c) => c.category === category)
}
export function groupChecksByCategory(
checks: CheckDefinition[],
): Map<CheckCategory, CheckDefinition[]> {
const groups = new Map<CheckCategory, CheckDefinition[]>()
for (const check of checks) {
const existing = groups.get(check.category) ?? []
existing.push(check)
groups.set(check.category, existing)
}
return groups
}
const CATEGORY_ORDER: CheckCategory[] = ["installation", "configuration", "plugins", "dependencies"]
export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
const start = performance.now()
const allChecks = getAllCheckDefinitions()
const filteredChecks = filterChecksByCategory(allChecks, options.category)
const groupedChecks = groupChecksByCategory(filteredChecks)
const results: CheckResult[] = []
if (!options.json) {
console.log(formatHeader())
}
for (const category of CATEGORY_ORDER) {
const checks = groupedChecks.get(category)
if (!checks || checks.length === 0) continue
if (!options.json) {
console.log(formatCategoryHeader(category))
}
for (const check of checks) {
const result = await runCheck(check)
results.push(result)
if (!options.json) {
console.log(formatCheckResult(result, options.verbose ?? false))
}
}
}
const duration = performance.now() - start
const summary = calculateSummary(results, duration)
const exitCode = determineExitCode(results)
const doctorResult: DoctorResult = {
results,
summary,
exitCode,
}
if (options.json) {
console.log(formatJsonOutput(doctorResult))
} else {
console.log("")
console.log(formatSummary(summary))
console.log(formatFooter(summary))
}
return doctorResult
}

View file

@ -0,0 +1,64 @@
export type CheckStatus = "pass" | "fail" | "warn" | "skip"
export interface CheckResult {
name: string
status: CheckStatus
message: string
details?: string[]
duration?: number
}
export type CheckFunction = () => Promise<CheckResult>
export type CheckCategory = "installation" | "configuration" | "plugins" | "dependencies"
export interface CheckDefinition {
id: string
name: string
category: CheckCategory
check: CheckFunction
critical?: boolean
}
export interface DoctorOptions {
verbose?: boolean
json?: boolean
category?: CheckCategory
}
export interface DoctorSummary {
total: number
passed: number
failed: number
warnings: number
skipped: number
duration: number
}
export interface DoctorResult {
results: CheckResult[]
summary: DoctorSummary
exitCode: number
}
export interface OpenCodeInfo {
installed: boolean
version: string | null
path: string | null
}
export interface ConfigInfo {
exists: boolean
path: string | null
format: "json" | "jsonc" | null
valid: boolean
errors: string[]
hasSchema: boolean
}
export interface PluginInfo {
name: string
loaded: boolean
path: string | null
error?: string
}

View file

@ -0,0 +1,330 @@
/**
* Ring Help Command
*
* Provides comprehensive help for Ring capabilities including
* skills, commands, and agents with their descriptions.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { basename, dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
/**
* Options for the help command.
*/
export interface HelpOptions {
/** Show only skills */
skills?: boolean
/** Show only commands */
commands?: boolean
/** Show only agents */
agents?: boolean
/** Show details for a specific item */
item?: string
/** Output in JSON format */
json?: boolean
}
/**
* Item info structure.
*/
interface ItemInfo {
name: string
description: string
category: "skill" | "command" | "agent"
path: string
}
/**
* Get the assets path for Ring.
* Returns null if assets directory doesn't exist.
*/
function getAssetsPath(): string | null {
// ESM-compatible path resolution
const currentFile = fileURLToPath(import.meta.url)
const currentDir = dirname(currentFile)
// Try relative to CLI (src/cli -> assets)
const fromCli = join(currentDir, "..", "..", "assets")
if (existsSync(fromCli)) {
return fromCli
}
// Try relative to dist (dist/cli -> assets)
const fromDist = join(currentDir, "..", "..", "..", "assets")
if (existsSync(fromDist)) {
return fromDist
}
return null
}
/**
* Parse frontmatter from markdown content.
*/
function parseFrontmatter(content: string): { description?: string } {
const normalizedContent = content.replace(/\r\n/g, "\n")
const frontmatterRegex = /^---\n([\s\S]*?)\n---/
const match = normalizedContent.match(frontmatterRegex)
if (!match) {
return {}
}
const yamlContent = match[1]
const data: { description?: string } = {}
for (const line of yamlContent.split("\n")) {
const colonIndex = line.indexOf(":")
if (colonIndex === -1) continue
const key = line.slice(0, colonIndex).trim()
let value = line.slice(colonIndex + 1).trim()
// Remove quotes
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (key === "description") {
data.description = value
}
}
return data
}
/**
* Load skills from assets/skill directory.
*/
function loadSkills(assetsPath: string): ItemInfo[] {
const skillsDir = join(assetsPath, "skill")
if (!existsSync(skillsDir)) {
return []
}
const skills: ItemInfo[] = []
const entries = readdirSync(skillsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
if (entry.name.startsWith(".") || entry.name === "shared-patterns") continue
const skillFile = join(skillsDir, entry.name, "SKILL.md")
if (!existsSync(skillFile)) continue
try {
const content = readFileSync(skillFile, "utf-8")
const { description } = parseFrontmatter(content)
// If no frontmatter description, try to extract from first heading or paragraph
let desc = description
if (!desc) {
const firstPara = content.match(/^#[^\n]+\n+([^\n#]+)/m)
desc = firstPara?.[1]?.trim().slice(0, 100) ?? "No description available"
}
skills.push({
name: entry.name,
description: desc,
category: "skill",
path: skillFile,
})
} catch {
// Skip unreadable skills
}
}
return skills.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* Load commands from assets/command directory.
*/
function loadCommands(assetsPath: string): ItemInfo[] {
const commandsDir = join(assetsPath, "command")
if (!existsSync(commandsDir)) {
return []
}
const commands: ItemInfo[] = []
const entries = readdirSync(commandsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue
const commandPath = join(commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
try {
const content = readFileSync(commandPath, "utf-8")
const { description } = parseFrontmatter(content)
commands.push({
name: commandName,
description: description ?? "No description available",
category: "command",
path: commandPath,
})
} catch {
// Skip unreadable commands
}
}
return commands.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* Load agents from assets/agent directory.
*/
function loadAgents(assetsPath: string): ItemInfo[] {
const agentsDir = join(assetsPath, "agent")
if (!existsSync(agentsDir)) {
return []
}
const agents: ItemInfo[] = []
const entries = readdirSync(agentsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue
const agentPath = join(agentsDir, entry.name)
const agentName = basename(entry.name, ".md")
try {
const content = readFileSync(agentPath, "utf-8")
const { description } = parseFrontmatter(content)
agents.push({
name: agentName,
description: description ?? "No description available",
category: "agent",
path: agentPath,
})
} catch {
// Skip unreadable agents
}
}
return agents.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* Format items for display.
*/
function formatItems(items: ItemInfo[], category: string): string {
if (items.length === 0) {
return `No ${category} found.\n`
}
const maxNameLen = Math.max(...items.map((i) => i.name.length))
const lines = items.map((item) => {
const paddedName = item.name.padEnd(maxNameLen)
const desc =
item.description.length > 60 ? `${item.description.slice(0, 57)}...` : item.description
return ` ${paddedName} ${desc}`
})
return lines.join("\n")
}
/**
* Show details for a specific item.
*/
function showItemDetails(
item: string,
skills: ItemInfo[],
commands: ItemInfo[],
agents: ItemInfo[],
): string {
const allItems = [...skills, ...commands, ...agents]
const found = allItems.find((i) => i.name === item || i.name === item.replace("ring:", ""))
if (!found) {
return `Item '${item}' not found. Use '/ring:help' to see available items.`
}
try {
const content = readFileSync(found.path, "utf-8")
// Remove frontmatter for display
const withoutFrontmatter = content.replace(/^---\n[\s\S]*?\n---\n/, "")
return `## ${found.category}: ${found.name}\n\n${withoutFrontmatter}`
} catch {
return `Unable to read details for '${item}'.`
}
}
/**
* Execute the help command.
*/
export async function help(options: HelpOptions = {}): Promise<number> {
const assetsPath = getAssetsPath()
if (!assetsPath) {
console.error("Error: Ring assets directory not found.")
console.error("Make sure Ring is properly installed.")
return 1
}
const skills = loadSkills(assetsPath)
const commands = loadCommands(assetsPath)
const agents = loadAgents(assetsPath)
// Show details for specific item
if (options.item) {
const details = showItemDetails(options.item, skills, commands, agents)
console.log(details)
return 0
}
// JSON output
if (options.json) {
const output: Record<string, ItemInfo[]> = {}
if (!options.commands && !options.agents) output.skills = skills
if (!options.skills && !options.agents) output.commands = commands
if (!options.skills && !options.commands) output.agents = agents
console.log(JSON.stringify(output, null, 2))
return 0
}
// Filter by category
const showSkills = options.skills || (!options.commands && !options.agents)
const showCommands = options.commands || (!options.skills && !options.agents)
const showAgents = options.agents || (!options.skills && !options.commands)
const lines: string[] = []
lines.push("Ring for OpenCode - Available Capabilities\n")
lines.push(`${"=".repeat(50)}\n`)
if (showSkills) {
lines.push(`\nSkills (${skills.length})`)
lines.push("-".repeat(30))
lines.push(formatItems(skills, "skills"))
lines.push("\nUsage: Load with 'skill: <name>' tool")
}
if (showCommands) {
lines.push(`\n\nCommands (${commands.length})`)
lines.push("-".repeat(30))
lines.push(formatItems(commands, "commands"))
lines.push("\nUsage: Invoke with '/ring:<name>'")
}
if (showAgents) {
lines.push(`\n\nAgents (${agents.length})`)
lines.push("-".repeat(30))
lines.push(formatItems(agents, "agents"))
lines.push("\nUsage: Dispatch with '@<name>' or Task tool")
}
lines.push(`\n\n${"=".repeat(50)}`)
lines.push("For details on a specific item: /ring:help <name>")
lines.push("Documentation: https://github.com/LerianStudio/ring-for-opencode")
console.log(lines.join("\n"))
return 0
}

View file

@ -0,0 +1,126 @@
#!/usr/bin/env bun
import { Command } from "commander"
import type { DoctorOptions } from "./doctor"
import { doctor } from "./doctor"
import type { HelpOptions } from "./help"
import { help } from "./help"
import { install } from "./install"
import type { InstallArgs } from "./types"
import { version as versionCmd } from "./version"
const packageJson = await import("../../package.json")
const VERSION = packageJson.version ?? "0.0.0"
const program = new Command()
program
.name("ring")
.description("Ring - CLI tools for OpenCode configuration and health checks")
.version(VERSION, "-v, --version", "Show version number")
program
.command("install")
.description("Install and configure Ring with schema validation")
.option("--no-tui", "Run in non-interactive mode")
.option("--skip-validation", "Skip config validation after install")
.addHelpText(
"after",
`
Examples:
$ ring install
$ ring install --no-tui
$ ring install --no-tui --skip-validation
This command:
- Adds $schema to Ring config for IDE autocomplete
- Validates configuration against Ring schema
- Creates config if it doesn't exist (.opencode/ring.jsonc or ~/.config/opencode/ring/config.jsonc)
`,
)
.action(async (options) => {
const args: InstallArgs = {
tui: options.tui !== false,
skipValidation: options.skipValidation ?? false,
}
const exitCode = await install(args)
process.exit(exitCode)
})
program
.command("doctor")
.description("Check Ring installation health and diagnose issues")
.option("--verbose", "Show detailed diagnostic information")
.option("--json", "Output results in JSON format")
.option(
"--category <category>",
"Run only specific category (installation, configuration, plugins, dependencies)",
)
.addHelpText(
"after",
`
Examples:
$ ring doctor
$ ring doctor --verbose
$ ring doctor --json
$ ring doctor --category configuration
Categories:
installation Check OpenCode installation
configuration Validate configuration files
plugins Check plugin and skill directories
dependencies Check runtime dependencies (bun, git)
`,
)
.action(async (options) => {
const doctorOptions: DoctorOptions = {
verbose: options.verbose ?? false,
json: options.json ?? false,
category: options.category,
}
const exitCode = await doctor(doctorOptions)
process.exit(exitCode)
})
program
.command("version")
.description("Show detailed version information")
.option("--json", "Output in JSON format")
.action(async (options) => {
const exitCode = await versionCmd({ json: options.json ?? false })
process.exit(exitCode)
})
program
.command("help [item]")
.description("Show available Ring skills, commands, and agents")
.option("--skills", "Show only skills")
.option("--commands", "Show only commands")
.option("--agents", "Show only agents")
.option("--json", "Output in JSON format")
.addHelpText(
"after",
`
Examples:
$ ring help # Show all categories
$ ring help --skills # Show only skills
$ ring help --commands # Show only commands
$ ring help --agents # Show only agents
$ ring help brainstorm # Show details for 'brainstorm' skill/command
$ ring help --json # Output in JSON format
This command lists all available Ring capabilities that can be used in OpenCode.
`,
)
.action(async (item, options) => {
const helpOptions: HelpOptions = {
skills: options.skills ?? false,
commands: options.commands ?? false,
agents: options.agents ?? false,
json: options.json ?? false,
item: item,
}
const exitCode = await help(helpOptions)
process.exit(exitCode)
})
program.parse()

View file

@ -0,0 +1,172 @@
import * as p from "@clack/prompts"
import color from "picocolors"
import {
addSchemaToConfig,
detectCurrentConfig,
getOpenCodeVersion,
isOpenCodeInstalled,
validateConfig,
} from "./config-manager"
import { SCHEMA_URL, SYMBOLS } from "./constants"
import type { InstallArgs } from "./types"
function printHeader(isUpdate: boolean): void {
const mode = isUpdate ? "Update" : "Install"
console.log()
console.log(color.bgCyan(color.white(` Ring ${mode} `)))
console.log()
}
function printStep(step: number, total: number, message: string): void {
const progress = color.dim(`[${step}/${total}]`)
console.log(`${progress} ${message}`)
}
function printSuccess(message: string): void {
console.log(`${SYMBOLS.check} ${message}`)
}
function printError(message: string): void {
console.log(`${SYMBOLS.cross} ${color.red(message)}`)
}
function printInfo(message: string): void {
console.log(`${SYMBOLS.info} ${message}`)
}
function printWarning(message: string): void {
console.log(`${SYMBOLS.warn} ${color.yellow(message)}`)
}
async function runTuiInstall(): Promise<number> {
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
p.intro(color.bgCyan(color.white(isUpdate ? " Ring Update " : " Ring Install ")))
if (isUpdate && detected.configPath) {
p.log.info(`Existing configuration found: ${detected.configPath}`)
}
const s = p.spinner()
s.start("Checking OpenCode installation")
const installed = await isOpenCodeInstalled()
if (!installed) {
s.stop("OpenCode is not installed")
p.log.error("OpenCode is not installed on this system.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
p.outro(color.red("Please install OpenCode first."))
return 1
}
const version = await getOpenCodeVersion()
s.stop(`OpenCode ${version ?? "installed"} ${color.green("\u2713")}`)
// Confirm installation
const shouldContinue = await p.confirm({
message: isUpdate
? "Update Ring configuration with schema validation?"
: "Install Ring configuration with schema validation?",
initialValue: true,
})
if (p.isCancel(shouldContinue) || !shouldContinue) {
p.cancel("Installation cancelled.")
return 1
}
s.start("Adding schema to configuration")
const schemaResult = addSchemaToConfig()
if (!schemaResult.success) {
s.stop(`Failed: ${schemaResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Schema added to ${color.cyan(schemaResult.configPath)}`)
// Validate the updated config
s.start("Validating configuration")
const validation = validateConfig(schemaResult.configPath)
if (!validation.valid) {
s.stop("Configuration has validation errors")
p.log.warn("Validation errors found:")
for (const err of validation.errors) {
p.log.message(` ${SYMBOLS.bullet} ${err}`)
}
} else {
s.stop("Configuration is valid")
}
p.note(
`Your Ring config now has schema validation.\n` +
`Config path: ${schemaResult.configPath}\n` +
`IDE autocomplete is available via the $schema field.\n\n` +
`Schema URL: ${color.cyan(SCHEMA_URL)}`,
isUpdate ? "Configuration Updated" : "Installation Complete",
)
p.log.success(color.bold(isUpdate ? "Ring configuration updated!" : "Ring installed!"))
p.log.message(`Run ${color.cyan("ring doctor")} to check your setup.`)
p.outro(color.green("Happy coding with Ring!"))
return 0
}
async function runNonTuiInstall(args: InstallArgs): Promise<number> {
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
printHeader(isUpdate)
const totalSteps = args.skipValidation ? 2 : 3
let step = 1
printStep(step++, totalSteps, "Checking OpenCode installation...")
const installed = await isOpenCodeInstalled()
if (!installed) {
printError("OpenCode is not installed on this system.")
printInfo("Visit https://opencode.ai/docs for installation instructions")
return 1
}
const version = await getOpenCodeVersion()
printSuccess(`OpenCode ${version ?? ""} detected`)
printStep(step++, totalSteps, "Adding schema to configuration...")
const schemaResult = addSchemaToConfig()
if (!schemaResult.success) {
printError(`Failed: ${schemaResult.error}`)
return 1
}
printSuccess(`Schema added ${SYMBOLS.arrow} ${color.dim(schemaResult.configPath)}`)
if (!args.skipValidation) {
printStep(step++, totalSteps, "Validating configuration...")
const validation = validateConfig(schemaResult.configPath)
if (!validation.valid) {
printWarning("Configuration has validation errors:")
for (const err of validation.errors) {
console.log(` ${SYMBOLS.bullet} ${err}`)
}
} else {
printSuccess("Configuration is valid")
}
}
console.log()
printSuccess(color.bold(isUpdate ? "Ring configuration updated!" : "Ring installed!"))
console.log(` Run ${color.cyan("ring doctor")} to check your setup.`)
console.log()
return 0
}
export async function install(args: InstallArgs): Promise<number> {
if (!args.tui) {
return runNonTuiInstall(args)
}
return runTuiInstall()
}

View file

@ -0,0 +1,22 @@
export interface InstallArgs {
tui: boolean
skipValidation?: boolean
}
export interface InstallConfig {
configPath: string
isNewInstall: boolean
}
export interface ConfigMergeResult {
success: boolean
configPath: string
error?: string
}
export interface DetectedConfig {
isInstalled: boolean
configPath: string | null
hasSchema: boolean
version: string | null
}

View file

@ -0,0 +1,66 @@
import color from "picocolors"
import { PACKAGE_NAME } from "./constants"
interface VersionOptions {
json?: boolean
}
interface VersionInfo {
name: string
version: string
nodeVersion: string
bunVersion: string | null
platform: string
arch: string
}
async function getBunVersion(): Promise<string | null> {
try {
const proc = Bun.spawn(["bun", "--version"], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return output.trim()
}
return null
} catch {
return null
}
}
export async function version(options: VersionOptions = {}): Promise<number> {
// Read version from package.json
const packageJson = await import("../../package.json")
const ver = packageJson.version ?? "unknown"
const bunVersion = await getBunVersion()
const info: VersionInfo = {
name: PACKAGE_NAME,
version: ver,
nodeVersion: process.version,
bunVersion,
platform: process.platform,
arch: process.arch,
}
if (options.json) {
console.log(JSON.stringify(info, null, 2))
return 0
}
console.log()
console.log(`${color.bold(color.cyan(PACKAGE_NAME))} ${color.green(`v${ver}`)}`)
console.log()
console.log(` ${color.dim("Node:")} ${info.nodeVersion}`)
if (info.bunVersion) {
console.log(` ${color.dim("Bun:")} ${info.bunVersion}`)
}
console.log(` ${color.dim("Platform:")} ${info.platform} (${info.arch})`)
console.log()
return 0
}

View file

@ -0,0 +1,15 @@
export type {
AgentConfig,
AgentPermission,
NotificationConfig,
Permission,
RingHookName,
RingOpenCodeConfig,
SkillDefinition,
SkillsConfig,
StateConfig,
} from "./schema"
export {
RingHookNameSchema,
RingOpenCodeConfigSchema,
} from "./schema"

View file

@ -0,0 +1,184 @@
import { z } from "zod"
// Permission value types
const PermissionValue = z.enum(["ask", "allow", "deny"])
const BashPermission = z.union([PermissionValue, z.record(z.string(), PermissionValue)])
// Agent permission schema
const AgentPermissionSchema = z.object({
edit: PermissionValue.optional(),
bash: BashPermission.optional(),
webfetch: PermissionValue.optional(),
doom_loop: PermissionValue.optional(),
external_directory: PermissionValue.optional(),
})
// Skill permission schema
const SkillPermissionSchema = z.union([PermissionValue, z.record(z.string(), PermissionValue)])
// Global permission schema
const PermissionSchema = z.object({
skill: SkillPermissionSchema.optional(),
edit: PermissionValue.optional(),
bash: BashPermission.optional(),
webfetch: PermissionValue.optional(),
doom_loop: PermissionValue.optional(),
external_directory: PermissionValue.optional(),
})
// Agent mode
const AgentMode = z.enum(["primary", "subagent", "all"])
// Agent configuration schema
const AgentConfigSchema = z.object({
mode: AgentMode.optional(),
model: z.string().optional(),
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
prompt: z.string().optional(),
prompt_append: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),
description: z.string().optional(),
color: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/)
.optional(),
permission: AgentPermissionSchema.optional(),
})
// Agents configuration (keyed by agent name)
const AgentsConfigSchema = z.record(z.string(), AgentConfigSchema)
// Hook names specific to Ring
export const RingHookNameSchema = z.enum([
"session-start",
"context-injection",
"notification",
"task-completion-check",
"session-outcome",
"outcome-inference",
"doubt-resolver",
])
// Skill source schema
const SkillSourceSchema = z.union([
z.string(),
z.object({
path: z.string(),
recursive: z.boolean().optional(),
glob: z.string().optional(),
}),
])
// Skill definition schema
const SkillDefinitionSchema = z.object({
description: z.string().optional(),
template: z.string().optional(),
from: z.string().optional(),
model: z.string().optional(),
agent: z.string().optional(),
subtask: z.boolean().optional(),
"argument-hint": z.string().optional(),
license: z.string().optional(),
compatibility: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
"allowed-tools": z.array(z.string()).optional(),
disable: z.boolean().optional(),
})
const SkillEntrySchema = z.union([z.boolean(), SkillDefinitionSchema])
// Skills configuration - special keys separated from skill entries
// This allows: { "my-skill": true, "sources": ["/path"], "enable": ["x"], "disable": ["y"] }
const SkillsConfigSchema = z.union([
z.array(z.string()),
z
.object({
sources: z.array(SkillSourceSchema).optional(),
enable: z.array(z.string()).optional(),
disable: z.array(z.string()).optional(),
})
.catchall(SkillEntrySchema),
])
// State directory configuration
const StateConfigSchema = z.object({
directory: z.string().optional(),
session_tracking: z.boolean().optional(),
context_warnings: z.boolean().optional(),
})
// Notification configuration
const NotificationConfigSchema = z.object({
enabled: z.boolean().optional(),
sound: z.boolean().optional(),
on_completion: z.boolean().optional(),
on_error: z.boolean().optional(),
})
// Main Ring OpenCode configuration schema
//
// Backwards compatibility:
// - README historically used plural keys (agents/permissions)
// - OpenCode uses singular keys (agent/permission)
//
// We accept both, but normalize to the canonical singular keys.
export const RingOpenCodeConfigSchema = z
.object({
$schema: z.string().optional(),
version: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
// canonical keys
permission: PermissionSchema.optional(),
agent: AgentsConfigSchema.optional(),
// aliases (accepted as input only)
permissions: PermissionSchema.optional(),
agents: AgentsConfigSchema.optional(),
disabled_hooks: z.array(RingHookNameSchema).optional(),
skills: SkillsConfigSchema.optional(),
state: StateConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
})
.passthrough()
.superRefine((value, ctx) => {
if (value.permission && value.permissions) {
ctx.addIssue({
code: "custom",
path: ["permissions"],
message: "Use only one of 'permission' or 'permissions' (alias).",
})
}
if (value.agent && value.agents) {
ctx.addIssue({
code: "custom",
path: ["agents"],
message: "Use only one of 'agent' or 'agents' (alias).",
})
}
})
.transform((value) => {
const { permissions, agents, ...rest } = value
return {
...rest,
permission: rest.permission ?? permissions,
agent: rest.agent ?? agents,
}
})
// Type exports
export type RingOpenCodeConfig = z.infer<typeof RingOpenCodeConfigSchema>
export type AgentConfig = z.infer<typeof AgentConfigSchema>
export type AgentPermission = z.infer<typeof AgentPermissionSchema>
export type Permission = z.infer<typeof PermissionSchema>
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
export type StateConfig = z.infer<typeof StateConfigSchema>
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>
export type RingHookName = z.infer<typeof RingHookNameSchema>

View file

@ -0,0 +1,30 @@
/**
* Ring OpenCode
*
* Configuration schema validation and CLI tools for OpenCode.
*/
export type {
AgentConfig,
AgentPermission,
NotificationConfig,
Permission,
RingHookName,
RingOpenCodeConfig,
SkillDefinition,
SkillsConfig,
StateConfig,
} from "./config"
// Config exports
export {
RingHookNameSchema,
RingOpenCodeConfigSchema,
} from "./config"
export type { JsoncParseResult } from "./shared"
// Shared utilities
export {
detectConfigFile,
parseJsonc,
parseJsoncSafe,
readJsoncFile,
} from "./shared"

View file

@ -0,0 +1 @@
export * from "./jsonc-parser"

View file

@ -0,0 +1,84 @@
import { existsSync, readFileSync } from "node:fs"
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser"
export interface JsoncParseResult<T> {
data: T | null
errors: Array<{ message: string; offset: number; length: number }>
}
/**
* Parse JSONC content (JSON with comments) into typed object.
* Throws SyntaxError if parsing fails.
*/
export function parseJsonc<T = unknown>(content: string): T {
const errors: ParseError[] = []
const result = parse(content, errors, {
allowTrailingComma: true,
disallowComments: false,
})
if (errors.length > 0) {
const errorMessages = errors
.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`)
.join(", ")
throw new SyntaxError(`JSONC parse error: ${errorMessages}`)
}
// Handle empty or whitespace-only input
if (result === undefined) {
throw new SyntaxError("JSONC parse error: empty or invalid input")
}
return result as T
}
/**
* Parse JSONC content safely, returning errors instead of throwing.
*/
export function parseJsoncSafe<T = unknown>(content: string): JsoncParseResult<T> {
const errors: ParseError[] = []
const data = parse(content, errors, {
allowTrailingComma: true,
disallowComments: false,
}) as T | null
return {
data: errors.length > 0 ? null : data,
errors: errors.map((e) => ({
message: printParseErrorCode(e.error),
offset: e.offset,
length: e.length,
})),
}
}
/**
* Read and parse a JSONC file. Returns null if file doesn't exist or parse fails.
*/
export function readJsoncFile<T = unknown>(filePath: string): T | null {
try {
const content = readFileSync(filePath, "utf-8")
return parseJsonc<T>(content)
} catch {
return null
}
}
/**
* Detect whether a config file exists as .json or .jsonc
*/
export function detectConfigFile(basePath: string): {
format: "json" | "jsonc" | "none"
path: string
} {
const jsoncPath = `${basePath}.jsonc`
const jsonPath = `${basePath}.json`
if (existsSync(jsoncPath)) {
return { format: "jsonc", path: jsoncPath }
}
if (existsSync(jsonPath)) {
return { format: "json", path: jsonPath }
}
return { format: "none", path: jsonPath }
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,753 @@
# SRE Standards
> **⚠️ MAINTENANCE:** This file is indexed in `dev-team/skills/shared-patterns/standards-coverage-table.md`.
> When adding/removing `## ` sections, follow FOUR-FILE UPDATE RULE in CLAUDE.md: (1) edit standards file, (2) update TOC, (3) update standards-coverage-table.md, (4) update agent file.
This file defines the specific standards for Site Reliability Engineering and observability.
> **Reference**: Always consult `docs/PROJECT_RULES.md` for common project standards.
---
## Table of Contents
| # | Section | Description |
|---|---------|-------------|
| 1 | [Observability](#observability) | Logs, traces, APM tools |
| 2 | [Logging](#logging) | Structured JSON format, log levels |
| 3 | [Tracing](#tracing) | OpenTelemetry configuration |
| 4 | [OpenTelemetry with lib-commons](#opentelemetry-with-lib-commons-mandatory-for-go) | Go service integration |
| 5 | [Structured Logging with lib-common-js](#structured-logging-with-lib-common-js-mandatory-for-typescript) | TypeScript service integration |
| 6 | [Health Checks](#health-checks) | Liveness and readiness probes |
**Meta-sections (not checked by agents):**
- [Checklist](#checklist) - Self-verification before deploying
---
## Observability
| Component | Primary | Alternatives |
|-----------|---------|--------------|
| Logs | Loki | ELK Stack, Splunk, CloudWatch Logs |
| Traces | Jaeger/Tempo | Zipkin, X-Ray, Honeycomb |
| APM | OpenTelemetry | DataDog APM, New Relic APM |
---
## Logging
### Structured Log Format
```json
{
"timestamp": "2024-01-15T10:30:00.000Z",
"level": "error",
"logger": "api.handler",
"message": "Failed to process request",
"service": "api",
"version": "1.2.3",
"environment": "production",
"trace_id": "abc123def456",
"span_id": "789xyz",
"request_id": "req-001",
"user_id": "usr_456",
"error": {
"type": "ConnectionError",
"message": "connection timeout after 30s",
"stack": "..."
},
"context": {
"method": "POST",
"path": "/api/v1/users",
"status": 500,
"duration_ms": 30045
}
}
```
### Log Levels
| Level | Usage | Examples |
|-------|-------|----------|
| **ERROR** | Failures requiring attention | Database connection failed, API error |
| **WARN** | Potential issues | Retry attempt, connection pool low |
| **INFO** | Normal operations | Request completed, user logged in |
| **DEBUG** | Detailed debugging | Query parameters, internal state |
| **TRACE** | Very detailed (rarely used) | Full request/response bodies |
### What to Log
```yaml
# DO log
- Request start/end with duration
- Error details with stack traces
- Authentication events (login, logout, failed attempts)
- Authorization failures
- External service calls (start, end, duration)
- Business events (order placed, payment processed)
- Configuration changes
- Deployment events
# DO not log
- Passwords or API keys
- Credit card numbers (full)
- Personal identifiable information (PII)
- Session tokens
- Internal security mechanisms
- Health check requests (too noisy)
```
### Log Aggregation (Loki)
```yaml
# loki-config.yaml
auth_enabled: false
server:
http_listen_port: 3100
ingester:
lifecycler:
ring:
kvstore:
store: inmemory
replication_factor: 1
chunk_idle_period: 5m
chunk_retain_period: 30s
schema_config:
configs:
- from: 2024-01-01
store: boltdb-shipper
object_store: filesystem
schema: v11
index:
prefix: index_
period: 24h
storage_config:
boltdb_shipper:
active_index_directory: /loki/index
cache_location: /loki/cache
shared_store: filesystem
filesystem:
directory: /loki/chunks
limits_config:
enforce_metric_name: false
reject_old_samples: true
reject_old_samples_max_age: 168h
```
---
## Tracing
### OpenTelemetry Configuration
```go
// Go - OpenTelemetry setup
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
func initTracer(ctx context.Context) (*trace.TracerProvider, error) {
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("otel-collector:4317"),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("api"),
semconv.ServiceVersion("1.0.0"),
semconv.DeploymentEnvironment("production"),
),
)
if err != nil {
return nil, err
}
tp := trace.NewTracerProvider(
trace.WithBatcher(exporter),
trace.WithResource(res),
trace.WithSampler(trace.TraceIDRatioBased(0.1)), // Sample 10%
)
otel.SetTracerProvider(tp)
return tp, nil
}
// Usage
tracer := otel.Tracer("api")
ctx, span := tracer.Start(ctx, "processOrder")
defer span.End()
span.SetAttributes(
attribute.String("order.id", orderID),
attribute.Int("order.items", len(items)),
)
```
### Span Naming Conventions
```
# Format: <operation>.<entity>
# HTTP handlers
GET /api/users -> http.request
POST /api/orders -> http.request
# Database
SELECT users -> db.query
INSERT orders -> db.query
# External calls
Payment API call -> http.client.payment
Email service call -> http.client.email
# Internal operations
Process order -> order.process
Validate input -> input.validate
```
### Trace Context Propagation
```go
// Propagate trace context in HTTP headers
import (
"go.opentelemetry.io/otel/propagation"
)
// Client - inject context
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
// Server - extract context
ctx := otel.GetTextMapPropagator().Extract(
r.Context(),
propagation.HeaderCarrier(r.Header),
)
```
---
## OpenTelemetry with lib-commons (MANDATORY for Go)
All Go services **MUST** integrate OpenTelemetry using `lib-commons/v2`. This ensures consistent observability patterns across all Lerian Studio services.
> **Reference**: See `dev-team/docs/standards/golang.md` for complete lib-commons integration patterns.
### Required Imports
```go
import (
libCommons "github.com/LerianStudio/lib-commons/v2/commons"
libZap "github.com/LerianStudio/lib-commons/v2/commons/zap" // Logger initialization (bootstrap only)
libLog "github.com/LerianStudio/lib-commons/v2/commons/log" // Logger interface (services, routes, consumers)
libOpentelemetry "github.com/LerianStudio/lib-commons/v2/commons/opentelemetry"
libHTTP "github.com/LerianStudio/lib-commons/v2/commons/net/http"
libServer "github.com/LerianStudio/lib-commons/v2/commons/server"
)
```
### Telemetry Flow (MANDATORY)
```
┌─────────────────────────────────────────────────────────────────┐
│ 1. BOOTSTRAP (config.go) │
│ telemetry := libOpentelemetry.InitializeTelemetry(&config) │
│ → Creates OpenTelemetry provider once at startup │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. ROUTER (routes.go) │
│ tlMid := libHTTP.NewTelemetryMiddleware(tl) │
│ f.Use(tlMid.WithTelemetry(tl)) ← Injects into context │
│ ...routes... │
│ f.Use(tlMid.EndTracingSpans) ← Closes root spans │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. any layer (handlers, services, repositories) │
│ logger, tracer, _, _ := libCommons.NewTrackingFromContext(ctx)│
│ ctx, span := tracer.Start(ctx, "operation_name") │
│ defer span.End() │
│ logger.Infof("Processing...") ← Logger from same context │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 4. SERVER LIFECYCLE (fiber.server.go) │
│ libServer.NewServerManager(nil, &s.telemetry, s.logger) │
│ .WithHTTPServer(s.app, s.serverAddress) │
│ .StartWithGracefulShutdown() │
│ → Handles signal trapping + telemetry flush + clean shutdown │
└─────────────────────────────────────────────────────────────────┘
```
### 1. Bootstrap Initialization (MANDATORY)
```go
// bootstrap/config.go
func InitServers() *Service {
cfg := &Config{}
if err := libCommons.SetConfigFromEnvVars(cfg); err != nil {
panic(err)
}
// Initialize logger FIRST (zap package for initialization in bootstrap)
logger := libZap.InitializeLogger()
// Initialize telemetry with config
telemetry := libOpentelemetry.InitializeTelemetry(&libOpentelemetry.TelemetryConfig{
LibraryName: cfg.OtelLibraryName,
ServiceName: cfg.OtelServiceName,
ServiceVersion: cfg.OtelServiceVersion,
DeploymentEnv: cfg.OtelDeploymentEnv,
CollectorExporterEndpoint: cfg.OtelColExporterEndpoint,
EnableTelemetry: cfg.EnableTelemetry,
Logger: logger,
})
// Pass telemetry to router...
}
```
### 2. Router Middleware Setup (MANDATORY)
```go
// adapters/http/in/routes.go
func NewRouter(lg libLog.Logger, tl *libOpentelemetry.Telemetry, ...) *fiber.App {
f := fiber.New(fiber.Config{
DisableStartupMessage: true,
ErrorHandler: func(ctx *fiber.Ctx, err error) error {
return libHTTP.HandleFiberError(ctx, err)
},
})
// Create telemetry middleware
tlMid := libHTTP.NewTelemetryMiddleware(tl)
// MUST be first middleware - injects tracer+logger into context
f.Use(tlMid.WithTelemetry(tl))
f.Use(libHTTP.WithHTTPLogging(libHTTP.WithCustomLogger(lg)))
// ... define routes ...
// Version endpoint
f.Get("/version", libHTTP.Version)
// MUST be last middleware - closes root spans
f.Use(tlMid.EndTracingSpans)
return f
}
```
### 3. Recovering Logger & Tracer (MANDATORY)
```go
// any file in any layer (handler, service, repository)
func (s *Service) ProcessEntity(ctx context.Context, id string) error {
// Single call recovers BOTH logger and tracer from context
logger, tracer, _, _ := libCommons.NewTrackingFromContext(ctx)
// Create child span for this operation
ctx, span := tracer.Start(ctx, "service.process_entity")
defer span.End()
// Logger is automatically correlated with trace
logger.Infof("Processing entity: %s", id)
// Pass ctx to downstream calls - trace propagates automatically
return s.repo.Update(ctx, id)
}
```
### 4. Error Handling with Spans (MANDATORY)
```go
// For technical errors (unexpected failures)
if err != nil {
libOpentelemetry.HandleSpanError(&span, "Failed to connect database", err)
logger.Errorf("Database error: %v", err)
return nil, err
}
// For business errors (expected validation failures)
if err != nil {
libOpentelemetry.HandleSpanBusinessErrorEvent(&span, "Validation failed", err)
logger.Warnf("Validation error: %v", err)
return nil, err
}
```
### 5. Server Lifecycle with Graceful Shutdown (MANDATORY)
```go
// bootstrap/fiber.server.go
type Server struct {
app *fiber.App
serverAddress string
logger libLog.Logger
telemetry libOpentelemetry.Telemetry
}
func (s *Server) Run(l *libCommons.Launcher) error {
libServer.NewServerManager(nil, &s.telemetry, s.logger).
WithHTTPServer(s.app, s.serverAddress).
StartWithGracefulShutdown() // Handles: SIGINT/SIGTERM, telemetry flush, connections close
return nil
}
```
### Required Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `OTEL_RESOURCE_SERVICE_NAME` | Service name in traces | `service-name` |
| `OTEL_LIBRARY_NAME` | Library identifier | `service-name` |
| `OTEL_RESOURCE_SERVICE_VERSION` | Service version | `1.0.0` |
| `OTEL_RESOURCE_DEPLOYMENT_ENVIRONMENT` | Environment | `production` |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector endpoint | `http://otel-collector:4317` |
| `ENABLE_TELEMETRY` | Enable/disable | `true` |
### lib-commons Telemetry Checklist
| Check | What to Verify | Status |
|-------|----------------|--------|
| Bootstrap Init | `libOpentelemetry.InitializeTelemetry()` called in bootstrap | Required |
| Middleware Order | `WithTelemetry()` is FIRST, `EndTracingSpans` is LAST | Required |
| Context Recovery | All layers use `libCommons.NewTrackingFromContext(ctx)` | Required |
| Span Creation | Operations create spans via `tracer.Start(ctx, "name")` | Required |
| Error Handling | Uses `HandleSpanError` or `HandleSpanBusinessErrorEvent` | Required |
| Graceful Shutdown | `libServer.NewServerManager().StartWithGracefulShutdown()` | Required |
| Env Variables | All OTEL_* variables configured | Required |
### What not to Do
```go
// FORBIDDEN: Manual OpenTelemetry setup without lib-commons
import "go.opentelemetry.io/otel"
tp := trace.NewTracerProvider(...) // DON'T do this manually
// FORBIDDEN: Creating loggers without context
logger := zap.NewLogger() // DON'T do this in services
// FORBIDDEN: Not passing context to downstream calls
s.repo.Update(id) // DON'T forget context
// CORRECT: Always use lib-commons patterns
telemetry := libOpentelemetry.InitializeTelemetry(&config)
logger, tracer, _, _ := libCommons.NewTrackingFromContext(ctx)
s.repo.Update(ctx, id) // Context propagates trace
```
### Standards Compliance Categories
When evaluating a codebase for lib-commons telemetry compliance, check these categories:
| Category | Expected Pattern | Evidence Location |
|----------|------------------|-------------------|
| Telemetry Init | `libOpentelemetry.InitializeTelemetry()` | `internal/bootstrap/config.go` |
| Logger Init | `libZap.InitializeLogger()` (bootstrap only) | `internal/bootstrap/config.go` |
| Middleware Setup | `NewTelemetryMiddleware()` + `WithTelemetry()` | `internal/adapters/http/in/routes.go` |
| Middleware Order | `WithTelemetry` first, `EndTracingSpans` last | `internal/adapters/http/in/routes.go` |
| Context Recovery | `libCommons.NewTrackingFromContext(ctx)` | All handlers, services, repositories |
| Span Creation | `tracer.Start(ctx, "operation")` | All significant operations |
| Error Spans | `HandleSpanError` / `HandleSpanBusinessErrorEvent` | Error handling paths |
| Graceful Shutdown | `libServer.NewServerManager().StartWithGracefulShutdown()` | `internal/bootstrap/fiber.server.go` |
---
## Structured Logging with lib-common-js (MANDATORY for TypeScript)
All TypeScript services **MUST** integrate structured logging using `@LerianStudio/lib-common-js`. This ensures consistent observability patterns across all Lerian Studio services.
> **Note**: lib-common-js currently provides logging infrastructure. Telemetry will be added in future versions.
### Required Dependencies
```json
{
"dependencies": {
"@LerianStudio/lib-common-js": "^1.0.0"
}
}
```
### Required Imports
```typescript
import { initializeLogger, Logger } from '@LerianStudio/lib-common-js/logger';
import { loadConfigFromEnv } from '@LerianStudio/lib-common-js/config';
import { createLoggingMiddleware } from '@LerianStudio/lib-common-js/http';
```
### Logging Flow (MANDATORY)
```
┌─────────────────────────────────────────────────────────────────┐
│ 1. BOOTSTRAP (config.ts) │
│ const logger = initializeLogger() │
│ → Creates structured logger once at startup │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. ROUTER (routes.ts) │
│ const logMid = createLoggingMiddleware(logger) │
│ app.use(logMid) ← Injects logger into request │
│ ...routes... │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. any layer (handlers, services, repositories) │
│ const logger = req.logger || parentLogger │
│ logger.info('Processing...', { entityId, requestId }) │
│ → Structured JSON logs with correlation IDs │
└─────────────────────────────────────────────────────────────────┘
```
### 1. Bootstrap Initialization (MANDATORY)
```typescript
// bootstrap/config.ts
import { initializeLogger } from '@LerianStudio/lib-common-js/logger';
import { loadConfigFromEnv } from '@LerianStudio/lib-common-js/config';
export async function initServers(): Promise<Service> {
// Load configuration from environment
const config = loadConfigFromEnv<Config>();
// Initialize logger
const logger = initializeLogger({
level: config.logLevel,
serviceName: config.serviceName,
serviceVersion: config.serviceVersion,
});
logger.info('Service starting', {
service: config.serviceName,
version: config.serviceVersion,
environment: config.envName,
});
// Pass logger to router...
}
```
### 2. Router Middleware Setup (MANDATORY)
```typescript
// adapters/http/routes.ts
import { createLoggingMiddleware } from '@LerianStudio/lib-common-js/http';
import express from 'express';
export function createRouter(
logger: Logger,
handlers: Handlers
): express.Application {
const app = express();
// Create logging middleware - injects logger into request
const logMid = createLoggingMiddleware(logger);
app.use(logMid);
app.use(express.json());
// ... define routes ...
return app;
}
```
### 3. Using Logger in Handlers/Services (MANDATORY)
```typescript
// handlers/user-handler.ts
async function createUser(req: Request, res: Response): Promise<void> {
const logger = req.logger;
const requestId = req.headers['x-request-id'] as string;
logger.info('Creating user', {
requestId,
email: req.body.email,
});
try {
const user = await userService.create(req.body, logger);
logger.info('User created successfully', {
requestId,
userId: user.id,
});
res.status(201).json(user);
} catch (error) {
logger.error('Failed to create user', {
requestId,
error: error.message,
stack: error.stack,
});
throw error;
}
}
```
### Required Structured Log Format
All logs **MUST** be JSON formatted with these fields:
```json
{
"timestamp": "2024-01-15T10:30:00.000Z",
"level": "info",
"message": "Processing request",
"service": "api-service",
"version": "1.2.3",
"environment": "production",
"requestId": "req-001",
"context": {
"method": "POST",
"path": "/api/v1/users",
"userId": "usr_456"
}
}
```
### Required Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `LOG_LEVEL` | Logging level | `info` |
| `SERVICE_NAME` | Service identifier | `api-service` |
| `SERVICE_VERSION` | Service version | `1.0.0` |
| `ENV_NAME` | Environment name | `production` |
### lib-common-js Logging Checklist
| Check | What to Verify | Status |
|-------|----------------|--------|
| Logger Init | `initializeLogger()` called in bootstrap | Required |
| Middleware | `createLoggingMiddleware(logger)` configured | Required |
| Request Correlation | Logs include `requestId` from headers | Required |
| Structured Format | All logs are JSON formatted | Required |
| Error Logging | Errors include message, stack, and context | Required |
| No Sensitive Data | Passwords, tokens, PII not logged | Required |
| Log Levels | Appropriate levels used (info, warn, error) | Required |
### What not to Do
```typescript
// FORBIDDEN: Using console.log
console.log('Processing user'); // DON'T do this
// FORBIDDEN: Logging sensitive data
logger.info('User login', { password: user.password }); // never
// FORBIDDEN: Unstructured log messages
logger.info(`Processing user ${userId}`); // DON'T use string interpolation
// CORRECT: Always use lib-common-js structured logging
const logger = initializeLogger(config);
logger.info('Processing user', { userId, requestId }); // Structured fields
```
### Standards Compliance Categories (TypeScript Logging)
When evaluating a codebase for lib-common-js logging compliance, check these categories:
| Category | Expected Pattern | Evidence Location |
|----------|------------------|-------------------|
| Logger Init | `initializeLogger()` | `src/bootstrap/config.ts` |
| Middleware Setup | `createLoggingMiddleware(logger)` | `src/adapters/http/routes.ts` |
| Request Correlation | `requestId` in all logs | Handlers, services |
| JSON Format | Structured JSON output | All log statements |
| Error Logging | Error object with stack trace | Error handlers |
| No console.log | No direct console usage | Entire codebase |
| No Sensitive Data | Passwords, tokens excluded | All log statements |
---
## Health Checks
### Required Endpoints
### Implementation
```go
// Go implementation for observability
type ObservabilityChecker struct {
db *sql.DB
redis *redis.Client
}
// Liveness - is the process alive?
func (h *HealthChecker) LivenessHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
// Readiness - can we serve traffic?
func (h *HealthChecker) ReadinessHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
checks := []struct {
name string
fn func(context.Context) error
}{
{"database", func(ctx context.Context) error { return h.db.PingContext(ctx) }},
{"redis", func(ctx context.Context) error { return h.redis.Ping(ctx).Err() }},
}
var failures []string
for _, check := range checks {
if err := check.fn(ctx); err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", check.name, err))
}
}
if len(failures) > 0 {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "unhealthy",
"checks": failures,
})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "healthy",
})
}
```
### Kubernetes Configuration
```yaml
# Observability configuration
# JSON structured logging required
# OpenTelemetry tracing recommended for distributed systems
```
---
## Checklist
Before deploying to production:
- [ ] **Logging**: Structured JSON logs with trace correlation
- [ ] **Tracing**: OpenTelemetry instrumentation (Go with lib-commons)
- [ ] **Structured Logging**: lib-common-js integration (TypeScript)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
# Ring Project Rules
This is a markdown/documentation project for AI agent skills.
## Project Type
- Language: Markdown, YAML, Shell scripts, Python
- Type: Plugin/Skills library
- No compiled code
## Standards
- Skills use YAML frontmatter
- Agents follow output_schema patterns
- All agents must load standards via WebFetch
## Testing Focus
- Validate skill triggers
- Validate agent dispatching
- Validate WebFetch standards loading

View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["bun-types", "node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "plugin", "script"]
}