From 950968cae6d5055497718fe8fb8cec48a0445067 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sat, 14 Mar 2026 13:40:07 -0300 Subject: [PATCH] chore(opencode): harden Lucky plugins and workflows --- .cursor/skills/mcp-docs-search/SKILL.md | 7 + .../skills/opencode-lucky-workflows/SKILL.md | 104 +++++++++++ .opencode/.gitignore | 3 + .opencode/plugins/lucky-context.mjs | 64 +++++++ .opencode/plugins/lucky-doc-reminders.mjs | 76 ++++++++ .opencode/plugins/lucky-policy-lib.mjs | 144 +++++++++++++++ .opencode/plugins/lucky-policy.mjs | 118 +++++++++++++ .opencode/skills/backend-express | 1 + .opencode/skills/brainstorming | 1 + .opencode/skills/database-migration | 1 + .opencode/skills/discord-commands | 1 + .opencode/skills/e2e-playwright | 1 + .opencode/skills/event-handlers | 1 + .opencode/skills/frontend-react-vite | 1 + .opencode/skills/lucky-ci-gate-recovery | 1 + .opencode/skills/lucky-docker-dev | 1 + .opencode/skills/management-features | 1 + .opencode/skills/mcp-docs-search | 1 + .opencode/skills/moderation-automod | 1 + .opencode/skills/music-queue-player | 1 + .opencode/skills/nodejs-backend-patterns | 1 + .opencode/skills/opencode-lucky-workflows | 1 + .opencode/skills/prisma-redis-lucky | 1 + .opencode/skills/requesting-code-review | 1 + .opencode/skills/systematic-debugging | 1 + .opencode/skills/test-driven-development | 1 + .opencode/skills/testing-lucky | 1 + .opencode/skills/typescript-advanced-types | 1 + .opencode/skills/vercel-react-best-practices | 1 + .../skills/verification-before-completion | 1 + .opencode/skills/web-design-guidelines | 1 + .opencode/tests/lucky-policy.test.mjs | 59 +++++++ AGENTS.md | 9 + CHANGELOG.md | 14 ++ README.md | 66 +++++++ docs/MCP_SETUP.md | 165 ++++++++++++++++++ opencode.jsonc | 118 +++++++++++++ scripts/opencode-attach-server-do-luk.sh | 53 ++++++ scripts/opencode-install-community-plugins.sh | 108 ++++++++++++ scripts/opencode-sync-global-skills.sh | 73 ++++++++ scripts/opencode-sync-project-skills.sh | 52 ++++++ scripts/opencode-sync-server-do-luk-skills.sh | 18 ++ scripts/opencode-verify.sh | 75 ++++++++ 43 files changed, 1350 insertions(+) create mode 100644 .cursor/skills/opencode-lucky-workflows/SKILL.md create mode 100644 .opencode/.gitignore create mode 100644 .opencode/plugins/lucky-context.mjs create mode 100644 .opencode/plugins/lucky-doc-reminders.mjs create mode 100644 .opencode/plugins/lucky-policy-lib.mjs create mode 100644 .opencode/plugins/lucky-policy.mjs create mode 120000 .opencode/skills/backend-express create mode 120000 .opencode/skills/brainstorming create mode 120000 .opencode/skills/database-migration create mode 120000 .opencode/skills/discord-commands create mode 120000 .opencode/skills/e2e-playwright create mode 120000 .opencode/skills/event-handlers create mode 120000 .opencode/skills/frontend-react-vite create mode 120000 .opencode/skills/lucky-ci-gate-recovery create mode 120000 .opencode/skills/lucky-docker-dev create mode 120000 .opencode/skills/management-features create mode 120000 .opencode/skills/mcp-docs-search create mode 120000 .opencode/skills/moderation-automod create mode 120000 .opencode/skills/music-queue-player create mode 120000 .opencode/skills/nodejs-backend-patterns create mode 120000 .opencode/skills/opencode-lucky-workflows create mode 120000 .opencode/skills/prisma-redis-lucky create mode 120000 .opencode/skills/requesting-code-review create mode 120000 .opencode/skills/systematic-debugging create mode 120000 .opencode/skills/test-driven-development create mode 120000 .opencode/skills/testing-lucky create mode 120000 .opencode/skills/typescript-advanced-types create mode 120000 .opencode/skills/vercel-react-best-practices create mode 120000 .opencode/skills/verification-before-completion create mode 120000 .opencode/skills/web-design-guidelines create mode 100644 .opencode/tests/lucky-policy.test.mjs create mode 100644 opencode.jsonc create mode 100755 scripts/opencode-attach-server-do-luk.sh create mode 100755 scripts/opencode-install-community-plugins.sh create mode 100755 scripts/opencode-sync-global-skills.sh create mode 100755 scripts/opencode-sync-project-skills.sh create mode 100755 scripts/opencode-sync-server-do-luk-skills.sh create mode 100755 scripts/opencode-verify.sh diff --git a/.cursor/skills/mcp-docs-search/SKILL.md b/.cursor/skills/mcp-docs-search/SKILL.md index ae23e527f..6f0bd02ab 100644 --- a/.cursor/skills/mcp-docs-search/SKILL.md +++ b/.cursor/skills/mcp-docs-search/SKILL.md @@ -33,3 +33,10 @@ description: Use MCP tools for docs lookup and web search in Lucky. Use when nee - Prefer Context7 for official library docs before assuming API behavior. - Use Tavily for errors, version-specific notes, or when Context7 doesn’t cover the topic. - Don’t force MCPs; use the one that fits the task. + +## OpenCode-specific guidance + +- For OpenCode plugin, command, skill, or MCP configuration work, prefer official OpenCode docs first. +- Prefer local SDK and type inspection before broad web search when authoring OpenCode plugins or config. +- Use the official OpenCode ecosystem and community plugin index when evaluating third-party plugins. +- Treat community plugin marketing pages as discovery only; confirm install syntax from official docs or the plugin repository before wiring it into Lucky. diff --git a/.cursor/skills/opencode-lucky-workflows/SKILL.md b/.cursor/skills/opencode-lucky-workflows/SKILL.md new file mode 100644 index 000000000..51fd628e4 --- /dev/null +++ b/.cursor/skills/opencode-lucky-workflows/SKILL.md @@ -0,0 +1,104 @@ +--- +name: opencode-lucky-workflows +description: Use for Lucky OpenCode setup, plugin and command expectations, local versus host-local config split, and server-do-luk remote attach or recovery. +--- + +# OpenCode Lucky Workflows + +Use this skill when the task involves OpenCode configuration, plugins, commands, skills, local verification, or `server-do-luk` remote attach for Lucky. + +## Scope + +- Repo-local OpenCode behavior in `opencode.jsonc` +- Repo-local plugins under `.opencode/plugins` +- Project skill bridge under `.opencode/skills` +- Host-local config in `~/.config/opencode/opencode.jsonc` +- Remote attach flow for `server-do-luk` + +## Config Split + +- Repo-local: + - `opencode.jsonc` + - `.opencode/plugins` + - `.opencode/skills` + - helper scripts under `scripts/opencode-*` +- Host-local: + - `~/.config/opencode/opencode.jsonc` + - provider auth and MCP credentials + - host-only scripts and package cache +- Remote host-local: + - `/home/luk-server/.config/opencode/opencode.jsonc` + - `/home/luk-server/.local/bin/opencode-lucky-serve` + +Do not commit host-local auth, tokens, or MCP credentials. + +## Expected Plugin and Command Behavior + +- Repo-local plugins enforce Lucky policy, context bootstrap, and doc reminders. +- Dangerous shell/file actions must be blocked, not merely documented. +- OpenCode commands must mirror `.cursor/COMMANDS.md`: + - `verify` + - `e2e` + - `db` + +## Skill and MCP Expectations + +- Prefer `.opencode/skills` as the project bridge for OpenCode. +- Keep host-local MCP/auth state out of git. +- Remote `server-do-luk` stays on the portable core MCP set: + - `serena` + - `context7` + +## Validation Flow + +Run this sequence after OpenCode changes: + +```bash +./scripts/opencode-sync-project-skills.sh +./scripts/opencode-verify.sh +./scripts/opencode-verify.sh --remote server-do-luk +``` + +Useful direct checks: + +```bash +opencode debug config +opencode debug skill +opencode mcp list +opencode run --format json "Say only OK" +``` + +## `server-do-luk` Attach + +- Sync global skills before attach when host-local skills changed: + +```bash +./scripts/opencode-sync-server-do-luk-skills.sh +``` + +- Attach through SSH tunnel: + +```bash +./scripts/opencode-attach-server-do-luk.sh +``` + +- Override the remote working directory when validating a remote worktree: + +```bash +OPENCODE_REMOTE_DIR=/home/luk-server/Lucky/.worktrees/ \ + ./scripts/opencode-attach-server-do-luk.sh +``` + +## Auth Recovery + +If the remote model auth is stale: + +```bash +ssh server-do-luk 'cd /home/luk-server/Lucky && ~/.opencode/bin/opencode providers login -p openai' +``` + +## Guardrails + +- Prefer isolated worktrees for repo changes. +- Keep README and CHANGELOG aligned with shipped tooling behavior. +- Use exact failing commands and signatures when verification breaks. diff --git a/.opencode/.gitignore b/.opencode/.gitignore new file mode 100644 index 000000000..f2a1888aa --- /dev/null +++ b/.opencode/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package.json +bun.lock diff --git a/.opencode/plugins/lucky-context.mjs b/.opencode/plugins/lucky-context.mjs new file mode 100644 index 000000000..e49c75080 --- /dev/null +++ b/.opencode/plugins/lucky-context.mjs @@ -0,0 +1,64 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +function git(cwd, ...args) { + try { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() + } catch { + return '' + } +} + +function readSnippet(filePath, maxLines = 8) { + try { + return fs + .readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .slice(0, maxLines) + .join('\n') + } catch { + return '' + } +} + +export default async function luckyContext(ctx) { + const repoRoot = git(ctx.directory, 'rev-parse', '--show-toplevel') || path.resolve(ctx.directory) + const branch = git(repoRoot, 'branch', '--show-current') || 'unknown' + const worktreeRoot = path.resolve(ctx.worktree || ctx.directory) + const currentState = readSnippet(path.join(repoRoot, '.serena/memories/current-state.md')) + const nextPriorities = readSnippet(path.join(repoRoot, '.serena/memories/next-priorities.md')) + + return { + 'experimental.chat.system.transform': async (_input, output) => { + output.system.push( + [ + 'Lucky bootstrap:', + '- Read AGENTS.md before acting.', + '- Read .serena/memories/current-state.md and .serena/memories/next-priorities.md.', + '- Prefer isolated worktrees over the primary checkout.', + '- Use PR-only workflow; never push directly to main.', + '- Update README.md and CHANGELOG.md for behavior or setup changes.', + '- Run verification before completion and record failing commands exactly.', + ].join('\n'), + ) + }, + 'experimental.session.compacting': async (_input, output) => { + const changedFiles = git(repoRoot, 'status', '--short') || 'clean' + output.context.push( + [ + `Branch: ${branch}`, + `Worktree: ${worktreeRoot}`, + `Repository: ${repoRoot}`, + currentState ? `Current state:\n${currentState}` : '', + nextPriorities ? `Next priorities:\n${nextPriorities}` : '', + `Changed files:\n${changedFiles}`, + 'Preserve unresolved verification failures with exact command lines and error signatures.', + ] + .filter(Boolean) + .join('\n\n'), + ) + }, + } +} diff --git a/.opencode/plugins/lucky-doc-reminders.mjs b/.opencode/plugins/lucky-doc-reminders.mjs new file mode 100644 index 000000000..74a055820 --- /dev/null +++ b/.opencode/plugins/lucky-doc-reminders.mjs @@ -0,0 +1,76 @@ +import { execFileSync } from 'node:child_process' +import path from 'node:path' + +const IMPACTED_PATHS = [ + /^packages\//, + /^scripts\//, + /^\.github\/workflows\//, + /^docker-compose(?:\.[^/]+)?\.ya?ml$/, + /^\.env\.example$/, + /^opencode\.jsonc$/, + /^AGENTS\.md$/, + /^docs\//, + /^\.opencode\//, +] + +function git(cwd, ...args) { + try { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() + } catch { + return '' + } +} + +function changedFiles(repoRoot) { + const output = git(repoRoot, 'status', '--short') + if (!output) return [] + return output + .split(/\r?\n/) + .map((line) => line.slice(3).trim()) + .filter(Boolean) +} + +function hasDocsTouch(files) { + return files.includes('README.md') || files.includes('CHANGELOG.md') +} + +function hasRelevantChanges(files) { + return files.some((file) => IMPACTED_PATHS.some((pattern) => pattern.test(file))) +} + +function isVerifyLike(command) { + return /npm run (verify|test|build|lint|type:check)|vitest|jest|playwright|pnpm test|bun test/i.test( + command, + ) +} + +function isFailure(output) { + const exitCode = Number(output.metadata?.exitCode ?? output.metadata?.code ?? 0) + if (Number.isFinite(exitCode) && exitCode !== 0) return true + return /error|failed/i.test(output.title || '') +} + +function appendNote(output, note) { + if ((output.output || '').includes(note)) return + output.output = [output.output, note].filter(Boolean).join('\n\n') +} + +export default async function luckyDocReminders(ctx) { + const repoRoot = git(ctx.directory, 'rev-parse', '--show-toplevel') || path.resolve(ctx.directory) + + return { + 'tool.execute.after': async (input, output) => { + const files = changedFiles(repoRoot) + if (hasRelevantChanges(files) && !hasDocsTouch(files)) { + appendNote(output, '[OpenCode reminder] README.md and CHANGELOG.md are still untouched.') + } + + if (input.tool === 'bash' && isVerifyLike(input.args?.command || '') && isFailure(output)) { + appendNote( + output, + '[OpenCode reminder] Record the failing command and error signature before moving on.', + ) + } + }, + } +} diff --git a/.opencode/plugins/lucky-policy-lib.mjs b/.opencode/plugins/lucky-policy-lib.mjs new file mode 100644 index 000000000..2e4e2506b --- /dev/null +++ b/.opencode/plugins/lucky-policy-lib.mjs @@ -0,0 +1,144 @@ +import os from 'node:os' +import path from 'node:path' + +const DESTRUCTIVE_COMMAND_PATTERNS = [ + /(^|[;&|]\s*)git\s+reset\s+--hard(?:\s|$)/i, + /(^|[;&|]\s*)git\s+checkout\s+--(?:\s|$)/i, + /(^|[;&|]\s*)git\s+clean\s+-[^\n;&|]*f[^\n;&|]*d[^\n;&|]*(?:x|X)?/i, + /(^|[;&|]\s*)rm\s+-rf\s+\/(?:\s|$)/i, + /(^|[;&|]\s*)mkfs(?:\.[^\s]+)?(?:\s|$)/i, + /(^|[;&|]\s*)dd\s+[^\n]*\bof=\/dev\//i, +] + +const MUTATING_COMMAND_PATTERNS = [ + /(^|[;&|]\s*)git\s+(add|am|apply|branch|checkout|cherry-pick|clean|commit|merge|mv|rebase|reset|restore|rm|stash|switch|tag)(?:\s|$)/i, + /(^|[;&|]\s*)(bun\s+add|npm\s+(install|update)|pnpm\s+(add|install|update)|yarn\s+(add|install|upgrade))(?:\s|$)/i, + /(^|[;&|]\s*)(cp|install|ln|mkdir|mv|rm|rmdir|sed\s+-i|tee|touch)(?:\s|$)/i, +] + +const DIRECT_MAIN_PUSH_PATTERNS = [ + /^git\s+push(?:\s+--\S+|\s+-\S+)*\s+(?:(?:origin|upstream)\s+)?(?:refs\/heads\/)?main(?:\s|$)/i, + /^git\s+push(?:\s+--\S+|\s+-\S+)*\s+(?:origin|upstream)\s+HEAD:(?:refs\/heads\/)?main(?:\s|$)/i, +] + +function expandHome(value, home) { + if (!value) return value + if (value === '~') return home + if (value.startsWith('~/')) return path.join(home, value.slice(2)) + return value +} + +function resolveTarget(rawPath, { cwd = process.cwd(), home = os.homedir() } = {}) { + if (!rawPath || typeof rawPath !== 'string') return null + const trimmed = rawPath.trim() + if (!trimmed) return null + const expanded = expandHome(trimmed, home) + return path.resolve(cwd, expanded) +} + +function isEnvFile(resolvedPath, repoRoot) { + const basename = path.basename(resolvedPath) + if (basename === '.env.example') return false + if (basename === '.env') return true + if (basename.startsWith('.env.')) return true + if (repoRoot && resolvedPath === path.resolve(repoRoot, '.cursor/.env.mcp')) return true + return false +} + +function isInside(candidate, targetRoot) { + const normalizedRoot = path.resolve(targetRoot) + const normalizedCandidate = path.resolve(candidate) + return ( + normalizedCandidate === normalizedRoot || + normalizedCandidate.startsWith(normalizedRoot + path.sep) + ) +} + +export function isSensitivePath( + rawPath, + { cwd = process.cwd(), home = os.homedir(), repoRoot } = {}, +) { + const resolvedPath = resolveTarget(rawPath, { cwd, home }) + if (!resolvedPath) return false + if (repoRoot && resolvedPath === path.resolve(repoRoot, '.env.example')) return false + if (isEnvFile(resolvedPath, repoRoot)) return true + if (isInside(resolvedPath, path.join(home, '.ssh'))) return true + if (isInside(resolvedPath, path.join(home, '.aws'))) return true + if (resolvedPath === path.join(home, '.config', 'fish', 'config.fish')) return true + if (resolvedPath === path.join(home, '.local', 'share', 'opencode', 'auth.json')) return true + if (isInside(resolvedPath, path.join(home, '.config', 'opencode', 'auth'))) return true + return false +} + +export function commandTouchesSensitivePath( + command, + { cwd = process.cwd(), home = os.homedir(), repoRoot } = {}, +) { + if (!command || typeof command !== 'string') return false + const candidates = [ + '.env', + '.cursor/.env.mcp', + '~/.ssh/', + '~/.aws/', + '~/.config/fish/config.fish', + '~/.local/share/opencode/auth.json', + `${repoRoot ?? ''}/.env`, + `${repoRoot ?? ''}/.cursor/.env.mcp`, + ].filter(Boolean) + + for (const candidate of candidates) { + if (command.includes(candidate) && !command.includes('.env.example')) { + return true + } + } + + return false +} + +export function isDirectMainPush(command) { + if (!command || typeof command !== 'string') return false + const normalized = command.trim().replace(/\s+/g, ' ') + return DIRECT_MAIN_PUSH_PATTERNS.some((pattern) => pattern.test(normalized)) +} + +export function isDestructiveShellCommand(command) { + if (!command || typeof command !== 'string') return false + return DESTRUCTIVE_COMMAND_PATTERNS.some((pattern) => pattern.test(command)) +} + +export function isMutatingShellCommand(command) { + if (!command || typeof command !== 'string') return false + if (isDirectMainPush(command) || isDestructiveShellCommand(command)) return true + return MUTATING_COMMAND_PATTERNS.some((pattern) => pattern.test(command)) +} + +export function isPrimaryCheckout(repoRoot) { + return !path.resolve(repoRoot).split(path.sep).includes('.worktrees') +} + +export function shouldBlockRootMutation({ + cwd = process.cwd(), + repoRoot, + allowRootMutation = false, + command, +}) { + if (!repoRoot || allowRootMutation) return false + if (!isPrimaryCheckout(repoRoot)) return false + if (!isMutatingShellCommand(command)) return false + const normalizedRoot = path.resolve(repoRoot) + const normalizedCwd = path.resolve(cwd) + return ( + normalizedCwd === normalizedRoot || + normalizedCwd.startsWith(normalizedRoot + path.sep) + ) +} + +export function buildPolicyEnv({ cwd = process.cwd(), repoRoot, worktreeRoot }) { + const resolvedRepoRoot = repoRoot ? path.resolve(repoRoot) : process.cwd() + const resolvedWorktreeRoot = path.resolve(worktreeRoot || cwd) + return { + OPENCODE_GIT_REPOSITORY: resolvedRepoRoot, + OPENCODE_SERENA_PROJECT: resolvedWorktreeRoot, + LUCKY_WORKTREE_ROOT: resolvedWorktreeRoot, + } +} diff --git a/.opencode/plugins/lucky-policy.mjs b/.opencode/plugins/lucky-policy.mjs new file mode 100644 index 000000000..16a412259 --- /dev/null +++ b/.opencode/plugins/lucky-policy.mjs @@ -0,0 +1,118 @@ +import { execFileSync } from 'node:child_process' +import os from 'node:os' +import path from 'node:path' + +import { + buildPolicyEnv, + commandTouchesSensitivePath, + isDestructiveShellCommand, + isDirectMainPush, + isSensitivePath, + shouldBlockRootMutation, +} from './lucky-policy-lib.mjs' + +function gitTopLevel(cwd) { + try { + return execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd, + encoding: 'utf8', + }).trim() + } catch { + return path.resolve(cwd) + } +} + +function collectPathCandidates(value, key = '', result = []) { + if (typeof value === 'string' && /file|path|dir|target|source|destination|cwd/i.test(key)) { + result.push(value) + return result + } + + if (Array.isArray(value)) { + for (const item of value) collectPathCandidates(item, key, result) + return result + } + + if (value && typeof value === 'object') { + for (const [childKey, childValue] of Object.entries(value)) { + collectPathCandidates(childValue, childKey, result) + } + } + + return result +} + +function ensureSafeFileAccess(tool, args, context) { + if (!args || tool === 'bash') return + const candidates = collectPathCandidates(args) + for (const candidate of candidates) { + if (isSensitivePath(candidate, context)) { + throw new Error(`Blocked access to sensitive path: ${candidate}`) + } + } + + if (!['write', 'edit'].includes(tool)) return + const filePath = args.filePath || args.path + if (!filePath) return + const resolved = path.resolve(args.cwd || context.repoRoot, filePath) + if ( + shouldBlockRootMutation({ + cwd: args.cwd || context.repoRoot, + repoRoot: context.repoRoot, + allowRootMutation: context.allowRootMutation, + command: `touch ${resolved}`, + }) + ) { + throw new Error('Blocked mutating repo files from the Lucky root checkout. Use a worktree.') + } +} + +function ensureSafeShellCommand(command, cwd, context) { + if (!command) return + if (commandTouchesSensitivePath(command, context)) { + throw new Error('Blocked shell command targeting sensitive files or auth stores.') + } + if (isDirectMainPush(command)) { + throw new Error('Blocked direct push to main. Use a feature branch and PR.') + } + if (isDestructiveShellCommand(command)) { + throw new Error('Blocked destructive shell command.') + } + if ( + shouldBlockRootMutation({ + cwd, + repoRoot: context.repoRoot, + allowRootMutation: context.allowRootMutation, + command, + }) + ) { + throw new Error('Blocked mutating work from the Lucky root checkout. Use a worktree.') + } +} + +export default async function luckyPolicy(ctx) { + const repoRoot = gitTopLevel(ctx.directory) + const worktreeRoot = path.resolve(ctx.worktree || ctx.directory) + const home = os.homedir() + const allowRootMutation = process.env.LUCKY_ALLOW_ROOT_MUTATION === '1' + const context = { allowRootMutation, cwd: ctx.directory, home, repoRoot, worktreeRoot } + + return { + 'command.execute.before': async (input) => { + if (input.command === 'db') { + ensureSafeShellCommand('npm run db:migrate', repoRoot, context) + } + }, + 'tool.execute.before': async (input, output) => { + const args = output.args || {} + if (input.tool === 'bash') { + ensureSafeShellCommand(args.command, args.cwd || repoRoot, context) + return + } + ensureSafeFileAccess(input.tool, args, context) + }, + 'shell.env': async (_input, output) => { + Object.assign(output.env, buildPolicyEnv({ cwd: ctx.directory, repoRoot, worktreeRoot })) + }, + } +} diff --git a/.opencode/skills/backend-express b/.opencode/skills/backend-express new file mode 120000 index 000000000..baf9600ca --- /dev/null +++ b/.opencode/skills/backend-express @@ -0,0 +1 @@ +../../.cursor/skills/backend-express \ No newline at end of file diff --git a/.opencode/skills/brainstorming b/.opencode/skills/brainstorming new file mode 120000 index 000000000..ef8d39337 --- /dev/null +++ b/.opencode/skills/brainstorming @@ -0,0 +1 @@ +../../.agent-skills/brainstorming \ No newline at end of file diff --git a/.opencode/skills/database-migration b/.opencode/skills/database-migration new file mode 120000 index 000000000..aafd14997 --- /dev/null +++ b/.opencode/skills/database-migration @@ -0,0 +1 @@ +../../.agent-skills/database-migration \ No newline at end of file diff --git a/.opencode/skills/discord-commands b/.opencode/skills/discord-commands new file mode 120000 index 000000000..1ad755641 --- /dev/null +++ b/.opencode/skills/discord-commands @@ -0,0 +1 @@ +../../.cursor/skills/discord-commands \ No newline at end of file diff --git a/.opencode/skills/e2e-playwright b/.opencode/skills/e2e-playwright new file mode 120000 index 000000000..be969d992 --- /dev/null +++ b/.opencode/skills/e2e-playwright @@ -0,0 +1 @@ +../../.cursor/skills/e2e-playwright \ No newline at end of file diff --git a/.opencode/skills/event-handlers b/.opencode/skills/event-handlers new file mode 120000 index 000000000..ee567da20 --- /dev/null +++ b/.opencode/skills/event-handlers @@ -0,0 +1 @@ +../../.cursor/skills/event-handlers \ No newline at end of file diff --git a/.opencode/skills/frontend-react-vite b/.opencode/skills/frontend-react-vite new file mode 120000 index 000000000..01861f077 --- /dev/null +++ b/.opencode/skills/frontend-react-vite @@ -0,0 +1 @@ +../../.cursor/skills/frontend-react-vite \ No newline at end of file diff --git a/.opencode/skills/lucky-ci-gate-recovery b/.opencode/skills/lucky-ci-gate-recovery new file mode 120000 index 000000000..79acb1a42 --- /dev/null +++ b/.opencode/skills/lucky-ci-gate-recovery @@ -0,0 +1 @@ +../../.cursor/skills/lucky-ci-gate-recovery \ No newline at end of file diff --git a/.opencode/skills/lucky-docker-dev b/.opencode/skills/lucky-docker-dev new file mode 120000 index 000000000..20215a960 --- /dev/null +++ b/.opencode/skills/lucky-docker-dev @@ -0,0 +1 @@ +../../.cursor/skills/lucky-docker-dev \ No newline at end of file diff --git a/.opencode/skills/management-features b/.opencode/skills/management-features new file mode 120000 index 000000000..55cc38bdd --- /dev/null +++ b/.opencode/skills/management-features @@ -0,0 +1 @@ +../../.cursor/skills/management-features \ No newline at end of file diff --git a/.opencode/skills/mcp-docs-search b/.opencode/skills/mcp-docs-search new file mode 120000 index 000000000..169a3ace5 --- /dev/null +++ b/.opencode/skills/mcp-docs-search @@ -0,0 +1 @@ +../../.cursor/skills/mcp-docs-search \ No newline at end of file diff --git a/.opencode/skills/moderation-automod b/.opencode/skills/moderation-automod new file mode 120000 index 000000000..794771351 --- /dev/null +++ b/.opencode/skills/moderation-automod @@ -0,0 +1 @@ +../../.cursor/skills/moderation-automod \ No newline at end of file diff --git a/.opencode/skills/music-queue-player b/.opencode/skills/music-queue-player new file mode 120000 index 000000000..cd18cbb64 --- /dev/null +++ b/.opencode/skills/music-queue-player @@ -0,0 +1 @@ +../../.cursor/skills/music-queue-player \ No newline at end of file diff --git a/.opencode/skills/nodejs-backend-patterns b/.opencode/skills/nodejs-backend-patterns new file mode 120000 index 000000000..1021d68ff --- /dev/null +++ b/.opencode/skills/nodejs-backend-patterns @@ -0,0 +1 @@ +../../.agent-skills/nodejs-backend-patterns \ No newline at end of file diff --git a/.opencode/skills/opencode-lucky-workflows b/.opencode/skills/opencode-lucky-workflows new file mode 120000 index 000000000..3db1ac910 --- /dev/null +++ b/.opencode/skills/opencode-lucky-workflows @@ -0,0 +1 @@ +../../.cursor/skills/opencode-lucky-workflows \ No newline at end of file diff --git a/.opencode/skills/prisma-redis-lucky b/.opencode/skills/prisma-redis-lucky new file mode 120000 index 000000000..e8e9fd337 --- /dev/null +++ b/.opencode/skills/prisma-redis-lucky @@ -0,0 +1 @@ +../../.cursor/skills/prisma-redis-lucky \ No newline at end of file diff --git a/.opencode/skills/requesting-code-review b/.opencode/skills/requesting-code-review new file mode 120000 index 000000000..229141181 --- /dev/null +++ b/.opencode/skills/requesting-code-review @@ -0,0 +1 @@ +../../.agent-skills/requesting-code-review \ No newline at end of file diff --git a/.opencode/skills/systematic-debugging b/.opencode/skills/systematic-debugging new file mode 120000 index 000000000..dd8f6c5d6 --- /dev/null +++ b/.opencode/skills/systematic-debugging @@ -0,0 +1 @@ +../../.agent-skills/systematic-debugging \ No newline at end of file diff --git a/.opencode/skills/test-driven-development b/.opencode/skills/test-driven-development new file mode 120000 index 000000000..a3d7f5834 --- /dev/null +++ b/.opencode/skills/test-driven-development @@ -0,0 +1 @@ +../../.agent-skills/test-driven-development \ No newline at end of file diff --git a/.opencode/skills/testing-lucky b/.opencode/skills/testing-lucky new file mode 120000 index 000000000..2a7b9005f --- /dev/null +++ b/.opencode/skills/testing-lucky @@ -0,0 +1 @@ +../../.cursor/skills/testing-lucky \ No newline at end of file diff --git a/.opencode/skills/typescript-advanced-types b/.opencode/skills/typescript-advanced-types new file mode 120000 index 000000000..986563266 --- /dev/null +++ b/.opencode/skills/typescript-advanced-types @@ -0,0 +1 @@ +../../.agent-skills/typescript-advanced-types \ No newline at end of file diff --git a/.opencode/skills/vercel-react-best-practices b/.opencode/skills/vercel-react-best-practices new file mode 120000 index 000000000..9914ae242 --- /dev/null +++ b/.opencode/skills/vercel-react-best-practices @@ -0,0 +1 @@ +../../.agent-skills/vercel-react-best-practices \ No newline at end of file diff --git a/.opencode/skills/verification-before-completion b/.opencode/skills/verification-before-completion new file mode 120000 index 000000000..d21d2bd1b --- /dev/null +++ b/.opencode/skills/verification-before-completion @@ -0,0 +1 @@ +../../.agent-skills/verification-before-completion \ No newline at end of file diff --git a/.opencode/skills/web-design-guidelines b/.opencode/skills/web-design-guidelines new file mode 120000 index 000000000..d09353db2 --- /dev/null +++ b/.opencode/skills/web-design-guidelines @@ -0,0 +1 @@ +../../.agent-skills/web-design-guidelines \ No newline at end of file diff --git a/.opencode/tests/lucky-policy.test.mjs b/.opencode/tests/lucky-policy.test.mjs new file mode 100644 index 000000000..4340082d0 --- /dev/null +++ b/.opencode/tests/lucky-policy.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + isSensitivePath, + isDirectMainPush, + isDestructiveShellCommand, + shouldBlockRootMutation, +} from '../plugins/lucky-policy-lib.mjs' + +const home = '/Users/tester' +const repoRoot = '/Users/tester/Desenvolvimento/Lucky' +const worktreeRoot = '/Users/tester/Desenvolvimento/Lucky/.worktrees/feature-x' + +test('allows .env.example', () => { + assert.equal( + isSensitivePath('.env.example', { cwd: repoRoot, home, repoRoot }), + false, + ) +}) + +test('denies .env', () => { + assert.equal( + isSensitivePath('.env', { cwd: repoRoot, home, repoRoot }), + true, + ) +}) + +test('denies direct push to main', () => { + assert.equal(isDirectMainPush('git push origin main'), true) +}) + +test('denies git reset --hard', () => { + assert.equal(isDestructiveShellCommand('git reset --hard HEAD~1'), true) +}) + +test('denies root checkout mutation by default', () => { + assert.equal( + shouldBlockRootMutation({ + cwd: repoRoot, + repoRoot, + allowRootMutation: false, + command: 'git add README.md', + }), + true, + ) +}) + +test('allows worktree mutation', () => { + assert.equal( + shouldBlockRootMutation({ + cwd: worktreeRoot, + repoRoot: worktreeRoot, + allowRootMutation: false, + command: 'git add README.md', + }), + false, + ) +}) diff --git a/AGENTS.md b/AGENTS.md index 90bf490b2..6b675b681 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ At the start of every session: | Unit tests, Jest ESM mocks, fixing disabled tests | `testing-lucky` | | CI gate triage and required-check recovery | `lucky-ci-gate-recovery` | | Recover GitHub MCP transport/auth failures | `mcp-github-recovery` | +| OpenCode config, plugins, attach, verification | `opencode-lucky-workflows` | ### Ecosystem skills (`.agent-skills/` — from skills.sh) @@ -142,3 +143,11 @@ Use the **specific specialist and skills** for the task; use **MCP tools** to fi - Quality: `npm run lint`, `npm run type:check`, `npm run test` Use Docker for local when available (`docker-compose.dev.yml`). Prefer scripts in `scripts/` for documented operations. + + +- Repo-local OpenCode behavior lives in `opencode.jsonc`, `.opencode/plugins`, and `.opencode/skills`. +- Host-local OpenCode auth, provider state, and MCP credentials stay in `~/.config/opencode`. +- Use `scripts/opencode-sync-project-skills.sh` after changing project skills or OpenCode skill bridges. +- Use `scripts/opencode-install-community-plugins.sh` to prime approved OpenCode community add-ons locally or on `server-do-luk`. +- Use `scripts/opencode-verify.sh` after OpenCode config/plugin changes. +- `scripts/opencode-attach-server-do-luk.sh` supports `OPENCODE_REMOTE_DIR` when the remote session should target a remote worktree instead of `/home/luk-server/Lucky`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6425d0c28..8917582c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Expanded CI triage skill with deterministic required-vs-informational status classification and explicit ruleset-mismatch handling. +### Added + +- Added repo-local OpenCode guardrail plugins, verification/install helper + scripts, and the `opencode-lucky-workflows` project skill for Lucky Codex + sessions on local and `server-do-luk`. + ### Fixed - Bot music stability hotfix: `/autoplay` now acknowledges interactions before @@ -302,6 +308,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Standardized Lucky OpenCode sessions around repo-local policy/context/doc + plugins, approved community add-ons (`opencode-shell-strategy`, + `@tarquinen/opencode-dcp@latest`), and repo-local workflow commands + (`/verify`, `/e2e`, `/db`). +- Documented the OpenCode config split, hard-block guardrails, remote attach + flow, and the intentionally excluded heavy orchestration plugin set in + `README.md`, `docs/MCP_SETUP.md`, and `AGENTS.md`. + - Bot command registration now loads moderation/automod/management command groups through the active register pipeline, so centralized management commands are consistently available after startup diff --git a/README.md b/README.md index 482d6beaf..06042c253 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,72 @@ findings first with minimal dependency blast radius, then closes active moderate chains in focused follow-up PRs. Current audit baseline on `main` after the latest cycle is `low=0`, `moderate=0`, `high=0`, `critical=0`. +### OpenCode + Codex + +Lucky now ships repo-local OpenCode behavior in `opencode.jsonc`. + +- Repo-local: + - `opencode.jsonc` + - `.opencode/plugins` + - `.opencode/skills` + - `scripts/opencode-*` +- Host-local: + - `~/.config/opencode/opencode.jsonc` + - provider auth and MCP credentials + - OpenCode community plugin cache and host-only scripts + +The default Lucky stack is: + +- local Lucky plugins: + - `lucky-policy` + - `lucky-context` + - `lucky-doc-reminders` +- community add-ons: + - `opencode-shell-strategy` + - `@tarquinen/opencode-dcp@latest` + +High-risk actions are blocked in-session: + +- direct push to `main` +- `git reset --hard` +- `git checkout --` +- `git clean -fd` / `git clean -fdx` +- sensitive file access (`.env`, `.cursor/.env.mcp`, `~/.ssh/**`, `~/.aws/**`, OpenCode auth stores) +- mutating work from the primary Lucky checkout unless `LUCKY_ALLOW_ROOT_MUTATION=1` + +OpenCode project commands mirror `.cursor/COMMANDS.md`: + +- `/verify` +- `/e2e` +- `/db` + +Project setup and verification: + +```bash +./scripts/opencode-sync-project-skills.sh +./scripts/opencode-install-community-plugins.sh +./scripts/opencode-verify.sh +``` + +Remote `server-do-luk` flow: + +```bash +./scripts/opencode-sync-server-do-luk-skills.sh +./scripts/opencode-install-community-plugins.sh --remote server-do-luk +./scripts/opencode-verify.sh --remote server-do-luk +./scripts/opencode-attach-server-do-luk.sh +``` + +Optional add-ons are documented but intentionally disabled in v1: + +- `opencode-vibeguard` +- `opencode-helicone-session` +- `opencode-sentry-monitor` +- `opencode-type-inject` + +Heavy orchestration plugins stay out of the default stack because Lucky already +uses worktrees, skills, and PR discipline explicitly. + ### Local Database Bootstrap `db:*` scripts now pin Prisma config explicitly via diff --git a/docs/MCP_SETUP.md b/docs/MCP_SETUP.md index 7037a9fc1..10a9baa3c 100644 --- a/docs/MCP_SETUP.md +++ b/docs/MCP_SETUP.md @@ -99,3 +99,168 @@ Project-level Cursor Hooks are in **`.cursor/hooks.json`** (and scripts in `.cur For guidance on when to use which MCP tools and how AI agents should work on this repo, see [AGENTS.md](../AGENTS.md) at the project root. It maps MCPs (filesystem, GitHub, Context7, Tavily, Playwright, etc.) to tasks and references Cursor rules, **subagents** (`.cursor/rules/subagent-*.mdc`), **skills** (`.cursor/skills/`), and **commands** (`.cursor/COMMANDS.md` for verify, E2E, DB, deploy, specialist workflows). **Superpowers (Codex):** Superpowers are installed at `~/.codex/superpowers`. To use a skill in Cursor chat or in a prompt, run `~/.codex/superpowers/.codex/superpowers-codex use-skill ` with a real skill name (e.g. `superpowers:brainstorming`, `superpowers:test-driven-development`). See the “Superpowers (Codex)” section in AGENTS.md for the full skill list and agent behavior. + +## OpenCode + +Lucky now uses repo-local OpenCode config in `opencode.jsonc`. + +### Config split + +- Repo-local: + - `opencode.jsonc` + - `.opencode/plugins` + - `.opencode/skills` + - `scripts/opencode-*` +- Host-local: + - `~/.config/opencode/opencode.jsonc` + - provider auth + - MCP credentials + - host-only package cache and helper scripts + +Never commit host-local auth, tokens, or MCP headers. + +### Plugin posture + +Default Lucky OpenCode stack: + +- local Lucky plugins: + - `./.opencode/plugins/lucky-policy.mjs` + - `./.opencode/plugins/lucky-context.mjs` + - `./.opencode/plugins/lucky-doc-reminders.mjs` +- approved community add-ons: + - `opencode-shell-strategy` + - `@tarquinen/opencode-dcp@latest` + +`opencode-shell-strategy` is runtime-resolved through OpenCode’s plugin +resolver. `@tarquinen/opencode-dcp@latest` is the installable DCP package +behind dynamic context pruning. + +These remain documented-only in v1 and are not enabled by default: + +- `opencode-vibeguard` +- `opencode-helicone-session` +- `opencode-sentry-monitor` +- `opencode-type-inject` + +Heavy orchestration plugins are intentionally excluded in v1: + +- `Oh My OpenCode` +- `opencode-workspace` +- `opencode-background-agents` +- `opencode-worktree` + +### Guardrails + +Lucky OpenCode sessions hard-block: + +- direct push to `main` +- `git reset --hard` +- `git checkout --` +- `git clean -fd` and `git clean -fdx` +- access to `.env`, `.env.*`, `.cursor/.env.mcp`, `~/.ssh/**`, `~/.aws/**`, + `~/.config/fish/config.fish`, and OpenCode auth stores +- mutating work from the primary Lucky checkout unless + `LUCKY_ALLOW_ROOT_MUTATION=1` + +The plugins also inject: + +- `OPENCODE_GIT_REPOSITORY` +- `OPENCODE_SERENA_PROJECT` +- `LUCKY_WORKTREE_ROOT` + +### Commands + +Repo-local OpenCode commands mirror `.cursor/COMMANDS.md`: + +- `/verify` +- `/e2e` +- `/db` + +### Project and global skills + +Rebuild the project skill bridge: + +```bash +./scripts/opencode-sync-project-skills.sh +``` + +Rebuild the global host-local skill bridge: + +```bash +./scripts/opencode-sync-global-skills.sh +``` + +Sync global skills to `server-do-luk`: + +```bash +./scripts/opencode-sync-server-do-luk-skills.sh +``` + +### Community plugin install + +Prime the approved community add-ons locally: + +```bash +./scripts/opencode-install-community-plugins.sh +``` + +Prime them on `server-do-luk`: + +```bash +./scripts/opencode-install-community-plugins.sh --remote server-do-luk +``` + +### Verification + +Local verification: + +```bash +./scripts/opencode-verify.sh +``` + +Remote verification: + +```bash +./scripts/opencode-verify.sh --remote server-do-luk +``` + +Useful direct checks: + +```bash +opencode debug config +opencode debug skill +opencode mcp list +opencode run --format json "Say only OK" +``` + +### `server-do-luk` attach + +Attach a local OpenCode client to the remote Lucky workspace: + +```bash +./scripts/opencode-attach-server-do-luk.sh +``` + +To target a remote worktree instead of `/home/luk-server/Lucky`: + +```bash +OPENCODE_REMOTE_DIR=/home/luk-server/Lucky/.worktrees/ \ + ./scripts/opencode-attach-server-do-luk.sh +``` + +The remote helper remains: + +```bash +~/.local/bin/opencode-lucky-serve +``` + +It starts OpenCode on `127.0.0.1:4096` and respects `OPENCODE_REMOTE_DIR` when +present. + +### Remote auth recovery + +If `server-do-luk` loses OpenAI auth: + +```bash +ssh server-do-luk 'cd /home/luk-server/Lucky && ~/.opencode/bin/opencode providers login -p openai' +``` diff --git a/opencode.jsonc b/opencode.jsonc new file mode 100644 index 000000000..a7572dd7c --- /dev/null +++ b/opencode.jsonc @@ -0,0 +1,118 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["AGENTS.md"], + "skills": { + "paths": [".opencode/skills"] + }, + "plugin": [ + "./.opencode/plugins/lucky-policy.mjs", + "./.opencode/plugins/lucky-context.mjs", + "./.opencode/plugins/lucky-doc-reminders.mjs", + "opencode-shell-strategy", + "@tarquinen/opencode-dcp@latest" + ], + "command": { + "verify": { + "description": "Run Lucky's verify workflow.", + "template": "Follow the Lucky verify workflow from .cursor/COMMANDS.md. Use an isolated worktree for code changes, follow AGENTS.md, run the full verification sequence, and report exact failing commands and error signatures.", + "subtask": false + }, + "e2e": { + "description": "Run Lucky's E2E workflow.", + "template": "Follow the Lucky E2E workflow from .cursor/COMMANDS.md. Use the repo's Playwright conventions, keep scope tight to the requested flow, and report deterministic failures with the exact command and failing assertion.", + "subtask": false + }, + "db": { + "description": "Run Lucky's database workflow.", + "template": "Follow the Lucky DB workflow from .cursor/COMMANDS.md. Respect Prisma config pinning, do not broaden database scope, and record the exact migration or bootstrap command if anything fails.", + "subtask": false + } + }, + "watcher": { + "ignore": [ + "**/node_modules/**", + "**/dist/**", + "**/coverage/**", + "**/.turbo/**", + "**/playwright-report/**", + ".worktrees/**", + ".cursor/hooks.log", + ".serena/**/logs/**", + ".opencode/node_modules/**" + ] + }, + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "enabled": true, + "timeout": 10000 + }, + "stitch": { + "type": "remote", + "url": "https://stitch.googleapis.com/mcp", + "enabled": false, + "timeout": 10000 + }, + "supabase": { + "type": "remote", + "url": "https://mcp.supabase.com/mcp", + "enabled": false, + "timeout": 10000 + }, + "gmail": { + "type": "remote", + "url": "https://gmail.mcp.claude.com/mcp", + "enabled": false, + "timeout": 10000 + }, + "google-calendar": { + "type": "remote", + "url": "https://gcal.mcp.claude.com/mcp", + "enabled": false, + "timeout": 10000 + }, + "linear": { + "type": "remote", + "url": "https://mcp.linear.app/mcp", + "enabled": false, + "timeout": 10000 + }, + "mcp-gateway": { + "type": "remote", + "url": "https://stormmcp.ai/gateway/a0fd0056-dfd5-49bd-85d7-e19ba7e65bd0/mcp", + "enabled": false, + "timeout": 10000 + }, + "vercel": { + "type": "remote", + "url": "https://mcp.vercel.com", + "enabled": false, + "timeout": 10000 + }, + "jam": { + "type": "remote", + "url": "https://mcp.jam.dev/mcp", + "enabled": false, + "timeout": 10000 + }, + "cloudflare": { + "type": "remote", + "url": "https://bindings.mcp.cloudflare.com/mcp", + "enabled": false, + "timeout": 10000 + }, + "sentry": { + "type": "remote", + "url": "https://mcp.sentry.dev/mcp", + "enabled": false, + "timeout": 10000 + }, + "huggingface": { + "type": "remote", + "url": "https://huggingface.co/mcp?login&gradio=none", + "enabled": false, + "timeout": 10000 + } + } +} diff --git a/scripts/opencode-attach-server-do-luk.sh b/scripts/opencode-attach-server-do-luk.sh new file mode 100755 index 000000000..ca7ed6b1f --- /dev/null +++ b/scripts/opencode-attach-server-do-luk.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +remote_host="${1:-server-do-luk}" +remote_dir="${OPENCODE_REMOTE_DIR:-/home/luk-server/Lucky}" +remote_serve_cmd="${OPENCODE_REMOTE_SERVE_CMD:-\$HOME/.local/bin/opencode-lucky-serve}" +remote_port="${OPENCODE_REMOTE_PORT:-4096}" +local_port="${OPENCODE_LOCAL_PORT:-4096}" +local_bin="${OPENCODE_LOCAL_BIN:-$HOME/.opencode/bin/opencode}" +ssh_args=( + -o ExitOnForwardFailure=yes + -o ServerAliveInterval=30 + -o ServerAliveCountMax=3 +) + +ssh "$remote_host" "bash -lc ' + set -euo pipefail + if [[ ! -x $remote_serve_cmd ]]; then + echo \"Missing OpenCode serve helper at $remote_serve_cmd\" >&2 + exit 1 + fi + if ! ss -ltnH | awk '\''{print \$4}'\'' | grep -qx \"127.0.0.1:$remote_port\"; then + mkdir -p \"\$HOME/.local/share/opencode\" + OPENCODE_REMOTE_DIR=\"$remote_dir\" nohup $remote_serve_cmd \ + >\"\$HOME/.local/share/opencode/lucky-serve.log\" 2>&1 & + for _ in 1 2 3 4 5; do + sleep 1 + if ss -ltnH | awk '\''{print \$4}'\'' | grep -qx \"127.0.0.1:$remote_port\"; then + break + fi + done + fi + if ! ss -ltnH | awk '\''{print \$4}'\'' | grep -qx \"127.0.0.1:$remote_port\"; then + echo \"OpenCode server did not start on 127.0.0.1:$remote_port\" >&2 + exit 1 + fi +'" + +ssh "${ssh_args[@]}" -N -L "${local_port}:127.0.0.1:${remote_port}" "$remote_host" & +tunnel_pid=$! + +cleanup() { + if kill -0 "$tunnel_pid" >/dev/null 2>&1; then + kill "$tunnel_pid" + wait "$tunnel_pid" 2>/dev/null || true + fi +} + +trap cleanup EXIT INT TERM + +sleep 1 +"$local_bin" attach "http://127.0.0.1:${local_port}" --dir "$remote_dir" diff --git a/scripts/opencode-install-community-plugins.sh b/scripts/opencode-install-community-plugins.sh new file mode 100755 index 000000000..a90216b97 --- /dev/null +++ b/scripts/opencode-install-community-plugins.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +set -euo pipefail + +remote_host="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --remote) + remote_host="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +install_local() { + local config_dir="${OPENCODE_HOST_CONFIG_DIR:-$HOME/.config/opencode}" + local opencode_bin="${OPENCODE_LOCAL_BIN:-$HOME/.opencode/bin/opencode}" + local model="${OPENCODE_PLUGIN_MODEL:-openai/gpt-5.3-codex}" + + mkdir -p "$config_dir" + + if command -v bun >/dev/null 2>&1; then + if [[ ! -f "$config_dir/package.json" ]]; then + cat > "$config_dir/package.json" <<'JSON' +{ + "name": "opencode-local-config", + "private": true +} +JSON + fi + (cd "$config_dir" && bun add @tarquinen/opencode-dcp@latest >/dev/null) + fi + + local temp_config + local temp_project + temp_config="$(mktemp -d)" + temp_project="$(mktemp -d)" + + cat > "$temp_config/opencode.jsonc" </dev/null + ) + + rm -rf "$temp_config" "$temp_project" +} + +if [[ -n "$remote_host" ]]; then + ssh "$remote_host" 'bash -s' <<'EOF_REMOTE' +set -euo pipefail +export OPENCODE_HOST_CONFIG_DIR="${OPENCODE_HOST_CONFIG_DIR:-$HOME/.config/opencode}" +export OPENCODE_LOCAL_BIN="${OPENCODE_LOCAL_BIN:-$HOME/.opencode/bin/opencode}" +export OPENCODE_PLUGIN_MODEL="${OPENCODE_PLUGIN_MODEL:-openai/gpt-5.3-codex}" + +mkdir -p "$OPENCODE_HOST_CONFIG_DIR" + +if command -v bun >/dev/null 2>&1; then + if [[ ! -f "$OPENCODE_HOST_CONFIG_DIR/package.json" ]]; then + cat > "$OPENCODE_HOST_CONFIG_DIR/package.json" <<'JSON' +{ + "name": "opencode-local-config", + "private": true +} +JSON + fi + (cd "$OPENCODE_HOST_CONFIG_DIR" && bun add @tarquinen/opencode-dcp@latest >/dev/null) +fi + +temp_config="$(mktemp -d)" +temp_project="$(mktemp -d)" + +cat > "$temp_config/opencode.jsonc" </dev/null +) + +rm -rf "$temp_config" "$temp_project" +EOF_REMOTE + exit 0 +fi + +install_local diff --git a/scripts/opencode-sync-global-skills.sh b/scripts/opencode-sync-global-skills.sh new file mode 100755 index 000000000..911b748ef --- /dev/null +++ b/scripts/opencode-sync-global-skills.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -euo pipefail + +dest_root="${OPENCODE_GLOBAL_SKILLS_DIR:-$HOME/.opencode/skills}" +agents_dest="$dest_root/agents" +codex_dest="$dest_root/codex" +agents_src="$HOME/.agents/skills" +codex_src="$HOME/.codex/skills" + +python3 - "$dest_root" "$agents_dest" "$codex_dest" <<'PY' +import pathlib +import shutil +import sys + +root = pathlib.Path(sys.argv[1]) +root.mkdir(parents=True, exist_ok=True) + +for child in root.iterdir(): + if child.name in {"agents", "codex"}: + continue + if child.is_symlink() or child.is_file(): + child.unlink() + elif child.is_dir(): + shutil.rmtree(child) + +for raw in sys.argv[2:]: + path = pathlib.Path(raw) + path.mkdir(parents=True, exist_ok=True) + for child in path.iterdir(): + if child.is_symlink() or child.is_file(): + child.unlink() + elif child.is_dir(): + shutil.rmtree(child) +PY + +relative_path() { + python3 - "$1" "$2" <<'PY' +import os +import sys + +print(os.path.relpath(sys.argv[2], sys.argv[1])) +PY +} + +link_dir() { + local dest_dir="$1" + local source_dir="$2" + local skill_name="$3" + local target="$dest_dir/$skill_name" + + [[ -e "$target" ]] && return 0 + ln -s "$(relative_path "$dest_dir" "$source_dir")" "$target" +} + +if [[ -d "$agents_src" ]]; then + while IFS= read -r skill_dir; do + link_dir "$agents_dest" "$skill_dir" "$(basename "$skill_dir")" + done < <(find "$agents_src" -mindepth 1 -maxdepth 1 -type d -exec test -f '{}/SKILL.md' ';' -print | sort) +fi + +if [[ -d "$codex_src" ]]; then + while IFS= read -r skill_file; do + skill_dir="$(dirname "$skill_file")" + skill_name="$(basename "$skill_dir")" + + if [[ -e "$agents_dest/$skill_name" ]]; then + continue + fi + + link_dir "$codex_dest" "$skill_dir" "$skill_name" + done < <(find "$codex_src" -name SKILL.md | sort) +fi diff --git a/scripts/opencode-sync-project-skills.sh b/scripts/opencode-sync-project-skills.sh new file mode 100755 index 000000000..03facc7fb --- /dev/null +++ b/scripts/opencode-sync-project-skills.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +dest_dir="$repo_root/.opencode/skills" + +python3 - "$dest_dir" <<'PY' +import pathlib +import shutil +import sys + +dest = pathlib.Path(sys.argv[1]) +dest.mkdir(parents=True, exist_ok=True) + +for child in dest.iterdir(): + if child.is_symlink() or child.is_file(): + child.unlink() + elif child.is_dir(): + shutil.rmtree(child) +PY + +link_skill() { + local source_dir="$1" + local skill_name="$2" + local target="$dest_dir/$skill_name" + + if [[ -e "$target" ]]; then + return 0 + fi + + local relative_target + relative_target="$(python3 - "$dest_dir" "$source_dir" <<'PY' +import os +import sys + +print(os.path.relpath(sys.argv[2], sys.argv[1])) +PY +)" + + ln -s "$relative_target" "$target" +} + +for base_dir in "$repo_root/.cursor/skills" "$repo_root/.agent-skills"; do + if [[ ! -d "$base_dir" ]]; then + continue + fi + + while IFS= read -r skill_dir; do + link_skill "$skill_dir" "$(basename "$skill_dir")" + done < <(find "$base_dir" -mindepth 1 -maxdepth 1 -type d -exec test -f '{}/SKILL.md' ';' -print | sort) +done diff --git a/scripts/opencode-sync-server-do-luk-skills.sh b/scripts/opencode-sync-server-do-luk-skills.sh new file mode 100755 index 000000000..db206ce86 --- /dev/null +++ b/scripts/opencode-sync-server-do-luk-skills.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +remote_host="${1:-server-do-luk}" +remote_opencode_dir="/home/luk-server/.opencode/skills" + +"$repo_root/scripts/opencode-sync-global-skills.sh" +"$repo_root/scripts/opencode-sync-project-skills.sh" + +ssh "$remote_host" "mkdir -p '$remote_opencode_dir/agents' '$remote_opencode_dir/codex'" + +rsync -aL --delete "$HOME/.opencode/skills/agents/" \ + "$remote_host:$remote_opencode_dir/agents/" + +rsync -aL --delete "$HOME/.opencode/skills/codex/" \ + "$remote_host:$remote_opencode_dir/codex/" diff --git a/scripts/opencode-verify.sh b/scripts/opencode-verify.sh new file mode 100755 index 000000000..069ffda4b --- /dev/null +++ b/scripts/opencode-verify.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +remote_host="" +attach_smoke=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --remote) + remote_host="$2" + shift 2 + ;; + --attach-smoke) + attach_smoke=1 + shift + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +verify_local() { + cd "$repo_root" + node --check .opencode/plugins/lucky-policy-lib.mjs + node --check .opencode/plugins/lucky-policy.mjs + node --check .opencode/plugins/lucky-context.mjs + node --check .opencode/plugins/lucky-doc-reminders.mjs + node --test .opencode/tests/lucky-policy.test.mjs + opencode debug config >/dev/null + opencode debug skill >/dev/null + opencode mcp list >/dev/null + opencode run --format json "Say only OK" >/dev/null +} + +verify_remote() { + local remote_dir="${OPENCODE_REMOTE_DIR:-/home/luk-server/Lucky}" + ssh "$remote_host" "cd '$remote_dir' && ~/.opencode/bin/opencode debug config >/dev/null && ~/.opencode/bin/opencode debug skill >/dev/null && ~/.opencode/bin/opencode mcp list >/dev/null && ~/.opencode/bin/opencode run --format json 'Say only OK' >/dev/null" +} + +attach_smoke_check() { + python3 - <<'PY' +import os +import signal +import subprocess +import sys + +repo_root = os.environ['REPO_ROOT'] +cmd = [os.path.join(repo_root, 'scripts', 'opencode-attach-server-do-luk.sh')] +try: + proc = subprocess.Popen(cmd, cwd=repo_root, start_new_session=True) + proc.wait(timeout=10) +except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGTERM) + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + sys.exit(0) +PY +} + +if [[ -n "$remote_host" ]]; then + verify_remote +else + verify_local +fi + +if [[ $attach_smoke -eq 1 ]]; then + export REPO_ROOT="$repo_root" + attach_smoke_check +fi