mirror of
https://github.com/LerianStudio/ring
synced 2026-04-21 13:37:27 +00:00
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:
parent
bc42262597
commit
f85aa61440
71 changed files with 16209 additions and 0 deletions
74
platforms/opencode/README.md
Normal file
74
platforms/opencode/README.md
Normal 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.
|
||||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
35
platforms/opencode/__tests__/plugin/lifecycle/router.test.ts
Normal file
35
platforms/opencode/__tests__/plugin/lifecycle/router.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
66
platforms/opencode/__tests__/src/config/schema.test.ts
Normal file
66
platforms/opencode/__tests__/src/config/schema.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
57
platforms/opencode/biome.json
Normal file
57
platforms/opencode/biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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
588
platforms/opencode/installer.sh
Executable 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 ""
|
||||
50
platforms/opencode/package.json
Normal file
50
platforms/opencode/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
168
platforms/opencode/plugin/config/config-handler.ts
Normal file
168
platforms/opencode/plugin/config/config-handler.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
71
platforms/opencode/plugin/config/index.ts
Normal file
71
platforms/opencode/plugin/config/index.ts
Normal 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"
|
||||
481
platforms/opencode/plugin/config/loader.ts
Normal file
481
platforms/opencode/plugin/config/loader.ts
Normal 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
|
||||
}
|
||||
140
platforms/opencode/plugin/config/schema.ts
Normal file
140
platforms/opencode/plugin/config/schema.ts
Normal 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({})
|
||||
223
platforms/opencode/plugin/hooks/factories/context-injection.ts
Normal file
223
platforms/opencode/plugin/hooks/factories/context-injection.ts
Normal 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",
|
||||
}
|
||||
38
platforms/opencode/plugin/hooks/factories/index.ts
Normal file
38
platforms/opencode/plugin/hooks/factories/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
179
platforms/opencode/plugin/hooks/factories/session-start.ts
Normal file
179
platforms/opencode/plugin/hooks/factories/session-start.ts
Normal 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",
|
||||
}
|
||||
30
platforms/opencode/plugin/hooks/index.ts
Normal file
30
platforms/opencode/plugin/hooks/index.ts
Normal 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"
|
||||
251
platforms/opencode/plugin/hooks/registry.ts
Normal file
251
platforms/opencode/plugin/hooks/registry.ts
Normal 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)
|
||||
}
|
||||
137
platforms/opencode/plugin/hooks/types.ts
Normal file
137
platforms/opencode/plugin/hooks/types.ts
Normal 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>
|
||||
28
platforms/opencode/plugin/index.ts
Normal file
28
platforms/opencode/plugin/index.ts
Normal 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"
|
||||
12
platforms/opencode/plugin/lifecycle/index.ts
Normal file
12
platforms/opencode/plugin/lifecycle/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Ring Lifecycle Module
|
||||
*
|
||||
* Central export for lifecycle routing.
|
||||
*/
|
||||
|
||||
export {
|
||||
createLifecycleRouter,
|
||||
EVENTS,
|
||||
type LifecycleRouterDeps,
|
||||
type OpenCodeEvent,
|
||||
} from "./router.js"
|
||||
96
platforms/opencode/plugin/lifecycle/router.ts
Normal file
96
platforms/opencode/plugin/lifecycle/router.ts
Normal 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
|
||||
307
platforms/opencode/plugin/loaders/agent-loader.ts
Normal file
307
platforms/opencode/plugin/loaders/agent-loader.ts
Normal 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
|
||||
}
|
||||
329
platforms/opencode/plugin/loaders/command-loader.ts
Normal file
329
platforms/opencode/plugin/loaders/command-loader.ts
Normal 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
|
||||
}
|
||||
32
platforms/opencode/plugin/loaders/index.ts
Normal file
32
platforms/opencode/plugin/loaders/index.ts
Normal 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"
|
||||
81
platforms/opencode/plugin/loaders/placeholder-utils.ts
Normal file
81
platforms/opencode/plugin/loaders/placeholder-utils.ts
Normal 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)
|
||||
}
|
||||
221
platforms/opencode/plugin/loaders/skill-loader.ts
Normal file
221
platforms/opencode/plugin/loaders/skill-loader.ts
Normal 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
|
||||
}
|
||||
185
platforms/opencode/plugin/ring-unified.ts
Normal file
185
platforms/opencode/plugin/ring-unified.ts
Normal 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
|
||||
20
platforms/opencode/plugin/tools/index.ts
Normal file
20
platforms/opencode/plugin/tools/index.ts
Normal 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 = {}
|
||||
21
platforms/opencode/plugin/tsconfig.json
Normal file
21
platforms/opencode/plugin/tsconfig.json
Normal 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"]
|
||||
}
|
||||
252
platforms/opencode/plugin/utils/state.ts
Normal file
252
platforms/opencode/plugin/utils/state.ts
Normal 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, "<").replace(/>/g, ">")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, "<")
|
||||
.replace(/>/g, ">")
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
19
platforms/opencode/prompts/path-context.txt
Normal file
19
platforms/opencode/prompts/path-context.txt
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
16
platforms/opencode/prompts/session-start/path-context.txt
Normal file
16
platforms/opencode/prompts/session-start/path-context.txt
Normal 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
|
||||
140
platforms/opencode/ring-config.schema.json
Normal file
140
platforms/opencode/ring-config.schema.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
platforms/opencode/ring.jsonc
Normal file
40
platforms/opencode/ring.jsonc
Normal 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": {}
|
||||
}
|
||||
34
platforms/opencode/ring.jsonc.template
Normal file
34
platforms/opencode/ring.jsonc.template
Normal 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": {}
|
||||
}
|
||||
197
platforms/opencode/src/cli/config-manager.ts
Normal file
197
platforms/opencode/src/cli/config-manager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
37
platforms/opencode/src/cli/constants.ts
Normal file
37
platforms/opencode/src/cli/constants.ts
Normal 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
|
||||
113
platforms/opencode/src/cli/doctor/checks/config.ts
Normal file
113
platforms/opencode/src/cli/doctor/checks/config.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
80
platforms/opencode/src/cli/doctor/checks/dependencies.ts
Normal file
80
platforms/opencode/src/cli/doctor/checks/dependencies.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
17
platforms/opencode/src/cli/doctor/checks/index.ts
Normal file
17
platforms/opencode/src/cli/doctor/checks/index.ts
Normal 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(),
|
||||
]
|
||||
}
|
||||
126
platforms/opencode/src/cli/doctor/checks/installation.ts
Normal file
126
platforms/opencode/src/cli/doctor/checks/installation.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
33
platforms/opencode/src/cli/doctor/constants.ts
Normal file
33
platforms/opencode/src/cli/doctor/constants.ts
Normal 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
|
||||
84
platforms/opencode/src/cli/doctor/formatter.ts
Normal file
84
platforms/opencode/src/cli/doctor/formatter.ts
Normal 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)
|
||||
}
|
||||
11
platforms/opencode/src/cli/doctor/index.ts
Normal file
11
platforms/opencode/src/cli/doctor/index.ts
Normal 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"
|
||||
125
platforms/opencode/src/cli/doctor/runner.ts
Normal file
125
platforms/opencode/src/cli/doctor/runner.ts
Normal 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
|
||||
}
|
||||
64
platforms/opencode/src/cli/doctor/types.ts
Normal file
64
platforms/opencode/src/cli/doctor/types.ts
Normal 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
|
||||
}
|
||||
330
platforms/opencode/src/cli/help.ts
Normal file
330
platforms/opencode/src/cli/help.ts
Normal 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
|
||||
}
|
||||
126
platforms/opencode/src/cli/index.ts
Normal file
126
platforms/opencode/src/cli/index.ts
Normal 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()
|
||||
172
platforms/opencode/src/cli/install.ts
Normal file
172
platforms/opencode/src/cli/install.ts
Normal 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()
|
||||
}
|
||||
22
platforms/opencode/src/cli/types.ts
Normal file
22
platforms/opencode/src/cli/types.ts
Normal 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
|
||||
}
|
||||
66
platforms/opencode/src/cli/version.ts
Normal file
66
platforms/opencode/src/cli/version.ts
Normal 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
|
||||
}
|
||||
15
platforms/opencode/src/config/index.ts
Normal file
15
platforms/opencode/src/config/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export type {
|
||||
AgentConfig,
|
||||
AgentPermission,
|
||||
NotificationConfig,
|
||||
Permission,
|
||||
RingHookName,
|
||||
RingOpenCodeConfig,
|
||||
SkillDefinition,
|
||||
SkillsConfig,
|
||||
StateConfig,
|
||||
} from "./schema"
|
||||
export {
|
||||
RingHookNameSchema,
|
||||
RingOpenCodeConfigSchema,
|
||||
} from "./schema"
|
||||
184
platforms/opencode/src/config/schema.ts
Normal file
184
platforms/opencode/src/config/schema.ts
Normal 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>
|
||||
30
platforms/opencode/src/index.ts
Normal file
30
platforms/opencode/src/index.ts
Normal 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"
|
||||
1
platforms/opencode/src/shared/index.ts
Normal file
1
platforms/opencode/src/shared/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./jsonc-parser"
|
||||
84
platforms/opencode/src/shared/jsonc-parser.ts
Normal file
84
platforms/opencode/src/shared/jsonc-parser.ts
Normal 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 }
|
||||
}
|
||||
1034
platforms/opencode/standards/devops.md
Normal file
1034
platforms/opencode/standards/devops.md
Normal file
File diff suppressed because it is too large
Load diff
2240
platforms/opencode/standards/frontend.md
Normal file
2240
platforms/opencode/standards/frontend.md
Normal file
File diff suppressed because it is too large
Load diff
2869
platforms/opencode/standards/golang.md
Normal file
2869
platforms/opencode/standards/golang.md
Normal file
File diff suppressed because it is too large
Load diff
753
platforms/opencode/standards/sre.md
Normal file
753
platforms/opencode/standards/sre.md
Normal 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)
|
||||
2286
platforms/opencode/standards/typescript.md
Normal file
2286
platforms/opencode/standards/typescript.md
Normal file
File diff suppressed because it is too large
Load diff
18
platforms/opencode/templates/PROJECT_RULES.md
Normal file
18
platforms/opencode/templates/PROJECT_RULES.md
Normal 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
|
||||
24
platforms/opencode/tsconfig.json
Normal file
24
platforms/opencode/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue