Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-ralph-loop",
"version": "1.0.9",
"version": "1.0.10",
"description": "Minimal Ralph Loop plugin for opencode - auto-continues until task completion",
"main": "src/index.ts",
"type": "module",
Expand Down
95 changes: 95 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Non-function constants live here (not in index.ts) so opencode's plugin
// loader — which iterates Object.values(module) and rejects anything that
// isn't a function or `{server: fn}` — never sees them as exports of the
// entrypoint. See AGENTS.md gotcha.

export const STATE_FILENAME = "ralph-loop.local.md";
export const STATE_DIR = ".opencode";
export const STATE_PATH = `${STATE_DIR}/${STATE_FILENAME}`;

export interface RalphCommandDef {
description: string;
template: string;
agent: string;
}

// Inline slash-command templates. Mirror what commands/*.md used to be,
// minus the per-file YAML frontmatter. Registered at runtime via the
// `config` hook in index.ts.
export const RALPH_COMMANDS: Record<string, RalphCommandDef> = {
"ralph-loop": {
description: "Start Ralph Loop - auto-continues until task completion",
template: `Start an iterative development loop that automatically continues until the task is complete.

Create the state file in the project directory:

\`\`\`bash
mkdir -p ${STATE_DIR} && cat > ${STATE_PATH} << 'EOF'
---
active: true
iteration: 0
maxIterations: 100
---

$ARGUMENTS
EOF
\`\`\`

Now begin working on the task: **$ARGUMENTS**

When the task is FULLY completed, signal completion by outputting:

\`\`\`
<promise>DONE</promise>
\`\`\`

**IMPORTANT:** ONLY output this when the task is COMPLETELY and VERIFIABLY finished. Do NOT output false promises to escape the loop.

Use \`/cancel-ralph\` to stop early.`,
agent: "build",
},
"cancel-ralph": {
description: "Cancel active Ralph Loop",
template: `Cancel the active Ralph Loop.

\`\`\`bash
if [ -f ${STATE_PATH} ]; then
grep '^iteration:' ${STATE_PATH}
rm -f ${STATE_PATH}
echo "Ralph Loop cancelled."
else
echo "No active Ralph Loop to cancel."
fi
\`\`\`

Report the result to the user.`,
agent: "build",
},
"help": {
description: "Show Ralph Loop plugin help and available commands",
template: `# Ralph Loop Help

## Available Commands

- \`/ralph-loop <task>\` - Start an auto-continuation loop for the given task
- \`/cancel-ralph\` - Stop an active Ralph Loop

## Quick Start

\`\`\`
/ralph-loop Build a REST API with user authentication
\`\`\`

The AI will work on your task and automatically continue until it outputs \`<promise>DONE</promise>\` to signal completion.

## How It Works

1. Creates state file at \`${STATE_PATH}\`
2. Works on task until idle
3. If no \`<promise>DONE</promise>\` found, auto-continues
4. Repeats until complete or max iterations (100) reached

For more details, the AI can use the \`help\` skill.`,
agent: "build",
},
};
75 changes: 34 additions & 41 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { homedir } from "os";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { COMPLETION_TAG } from "./completion.ts";
import { RALPH_COMMANDS, STATE_FILENAME } from "./commands.ts";

// Types
export interface RalphState {
Expand All @@ -14,8 +15,6 @@ export interface RalphState {
prompt?: string;
}

// Constants
const STATE_FILENAME = "ralph-loop.local.md";
const OPENCODE_CONFIG_DIR = join(homedir(), ".config/opencode");

// Get plugin root directory
Expand All @@ -30,46 +29,28 @@ function getPluginRoot(): string {
}
}

// Auto-copy skills and commands to opencode config on first run
function setupSkillsAndCommands(): void {
// Auto-copy skills to opencode config on first run. opencode reads from
// ~/.config/opencode/skills/ (plural). Earlier versions wrote to /skill/
// (singular) — that path is no longer scanned. Slash commands are NOT
// copied here anymore; they self-register via the `config` hook below.
function setupSkills(): void {
const pluginRoot = getPluginRoot();
const skillsDir = join(OPENCODE_CONFIG_DIR, "skill");
const commandsDir = join(OPENCODE_CONFIG_DIR, "command");
const skillsDir = join(OPENCODE_CONFIG_DIR, "skills");

// Copy skills
const pluginSkillsDir = join(pluginRoot, "skills");
if (existsSync(pluginSkillsDir)) {
const skills = ["ralph-loop", "cancel-ralph", "help"];
for (const skill of skills) {
const srcSkillDir = join(pluginSkillsDir, skill);
const destSkillDir = join(skillsDir, skill);

if (existsSync(srcSkillDir) && !existsSync(destSkillDir)) {
try {
mkdirSync(destSkillDir, { recursive: true });
cpSync(srcSkillDir, destSkillDir, { recursive: true });
} catch {
// Silent fail
}
}
}
}

// Copy commands
const pluginCommandsDir = join(pluginRoot, "commands");
if (existsSync(pluginCommandsDir)) {
const commands = ["ralph-loop.md", "cancel-ralph.md", "help.md"];
for (const cmd of commands) {
const srcCmd = join(pluginCommandsDir, cmd);
const destCmd = join(commandsDir, cmd);

if (existsSync(srcCmd) && !existsSync(destCmd)) {
try {
mkdirSync(commandsDir, { recursive: true });
cpSync(srcCmd, destCmd);
} catch {
// Silent fail
}
if (!existsSync(pluginSkillsDir)) return;

// Skills directories are co-located with command names; deriving from
// RALPH_COMMANDS keeps the two in lockstep when new commands are added.
for (const skill of Object.keys(RALPH_COMMANDS)) {
const srcSkillDir = join(pluginSkillsDir, skill);
const destSkillDir = join(skillsDir, skill);
if (existsSync(srcSkillDir) && !existsSync(destSkillDir)) {
try {
mkdirSync(destSkillDir, { recursive: true });
cpSync(srcSkillDir, destSkillDir, { recursive: true });
} catch {
// Silent fail
}
}
}
Expand Down Expand Up @@ -188,10 +169,22 @@ export default async function RalphLoopPlugin(ctx: any) {
const directory = ctx.directory || process.cwd();
const client = ctx.client;

// Auto-setup skills and commands on first run
setupSkillsAndCommands();
// Auto-setup skills on first run. Slash commands self-register via the
// `config` hook below.
setupSkills();

return {
// Self-register slash commands. opencode's `config` hook fires while
// assembling the runtime config; mutating `input.command` adds entries
// to the command dict, no file copying needed. User-defined entries in
// opencode.json take precedence (we only set absent names).
config: async (input: any) => {
input.command = input.command ?? {};
for (const [name, def] of Object.entries(RALPH_COMMANDS)) {
if (!input.command[name]) input.command[name] = def;
}
},

// Register tools using the @opencode-ai/plugin SDK format.
// The `args` field (Zod schema shape) is required — opencode calls
// Object.entries(tool.args) internally. Using the old JSON Schema
Expand Down
Loading