From 8a8fda9a9ba40879f54fc058a199c18e9e3666c2 Mon Sep 17 00:00:00 2001 From: Elvis Pranskevichus Date: Fri, 17 Apr 2026 13:38:26 -0700 Subject: [PATCH] Fix OpenCode plugin template to keep subprocess output out of the TUI --- lat.md/cli.md | 2 +- lat.md/tests/gen.md | 18 +++ lat.md/tests/tests.md | 1 + templates/opencode-plugin.ts | 251 +++++++++++++++++++++-------------- tests/gen.test.ts | 17 +++ 5 files changed, 187 insertions(+), 102 deletions(-) create mode 100644 lat.md/tests/gen.md create mode 100644 tests/gen.test.ts diff --git a/lat.md/cli.md b/lat.md/cli.md index be0e157..a9057b8 100644 --- a/lat.md/cli.md +++ b/lat.md/cli.md @@ -205,7 +205,7 @@ Sets up `copilot-instructions.md` and registers the MCP server for VS Code Copil Sets up an OpenCode plugin that registers lat tools as native OpenCode tools and hooks into the session lifecycle. - `AGENTS.md` — shared instruction file (created in the shared step) -- `.opencode/plugins/lat.ts` — TypeScript plugin generated from `templates/opencode-plugin.ts` with the lat invocation command injected. Uses `@opencode-ai/plugin` to register six tools (`lat_search`, `lat_section`, `lat_locate`, `lat_check`, `lat_expand`, `lat_refs`) that shell out to the `lat` CLI. Hooks into `session.idle` (runs `lat check` + diff analysis, logs a warning via `client.app.log` if something needs fixing). +- `.opencode/plugins/lat.ts` — TypeScript plugin generated from `templates/opencode-plugin.ts` with the lat invocation command injected. Uses `@opencode-ai/plugin` to register six tools (`lat_search`, `lat_section`, `lat_locate`, `lat_check`, `lat_expand`, `lat_refs`) that shell out to the `lat` CLI with piped stdio so subprocess output does not pollute the OpenCode TUI. It watches `session.idle` via the plugin `event` hook, runs `lat check` plus diff analysis, and logs a warning via `client.app.log` if something needs fixing. - `.agents/skills/lat-md/SKILL.md` — skill spec for authoring `lat.md/` files, placed in the cross-agent standard skills directory - `.opencode` directory added to `.gitignore` (plugin contains local absolute paths) diff --git a/lat.md/tests/gen.md b/lat.md/tests/gen.md new file mode 100644 index 0000000..34a20a9 --- /dev/null +++ b/lat.md/tests/gen.md @@ -0,0 +1,18 @@ +--- +lat: + require-code-mention: true +--- + +# Gen + +Regression tests for built-in template generation. These tests keep shipped agent templates aligned with the current runtime contracts. + +Tests in `tests/gen.test.ts`. + +## OpenCode plugin template uses event hook + +The generated OpenCode plugin listens for `session.idle` through the plugin `event` callback so it matches the current OpenCode plugin API instead of relying on an older hook shape. + +## OpenCode plugin template pipes child process output + +The generated OpenCode plugin runs `lat` and `git` child processes with piped stdio so subprocess output stays buffered and does not leak into the OpenCode TUI. diff --git a/lat.md/tests/tests.md b/lat.md/tests/tests.md index a182b3f..ee47e1f 100644 --- a/lat.md/tests/tests.md +++ b/lat.md/tests/tests.md @@ -28,4 +28,5 @@ Shared patterns for writing and organizing tests in this project. - [[check-sections]] — Validating section leading paragraphs - [[section]] — getSection core function and formatSectionOutput formatter - [[hook]] — Stop hook conditional blocking and diff analysis +- [[gen]] — Built-in template generation regression checks - [[ts-fallback]] — Pure-TypeScript code-ref scanner fallback without ripgrep diff --git a/templates/opencode-plugin.ts b/templates/opencode-plugin.ts index ab2295f..ce939a0 100644 --- a/templates/opencode-plugin.ts +++ b/templates/opencode-plugin.ts @@ -1,22 +1,53 @@ -import { type Plugin, tool } from "@opencode-ai/plugin" -import { execSync } from "child_process" +import { type Plugin, tool } from '@opencode-ai/plugin'; +import { spawnSync } from 'child_process'; /** Absolute path to the lat binary, injected by `lat init`. */ -const LAT = "__LAT_BIN__" +const LAT = '__LAT_BIN__'; -function run(args: string[]): string { - return execSync(`${LAT} ${args.join(" ")}`, { - cwd: process.cwd(), - encoding: "utf-8", +function quote(arg: string): string { + return JSON.stringify(arg); +} + +function projectRoot(directory: string, worktree: string): string { + return worktree && worktree !== '/' ? worktree : directory; +} + +function command(command: string, cwd: string) { + const result = spawnSync(command, { + cwd, + encoding: 'utf-8', timeout: 30_000, - }) + maxBuffer: 1024 * 1024, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + if (result.error) throw result.error; + if (result.status === 0) return result.stdout ?? ''; + + const error = new Error( + result.stderr || + result.stdout || + `Command failed with exit code ${result.status ?? 'unknown'}`, + ) as Error & { + stdout?: string; + stderr?: string; + }; + error.stdout = result.stdout ?? ''; + error.stderr = result.stderr ?? ''; + throw error; } -function tryRun(args: string[]): string { +function run(args: string[], cwd: string): string { + return command([LAT, ...args.map(quote)].join(' '), cwd); +} + +function tryRun(args: string[], cwd: string): string { try { - return run(args) + return run(args, cwd); } catch { - return "" + return ''; } } @@ -25,44 +56,54 @@ export const LatPlugin: Plugin = async (ctx) => { tool: { lat_search: tool({ description: - "Semantic search across lat.md sections using embeddings. Use before starting any task to find relevant design context.", + 'Semantic search across lat.md sections using embeddings. Use before starting any task to find relevant design context.', args: { - query: tool.schema.string("Search query in natural language"), + query: tool.schema.string('Search query in natural language'), limit: tool.schema.optional( - tool.schema.number("Max results (default 5)"), + tool.schema.number('Max results (default 5)'), ), }, - async execute(args) { - const cliArgs = ["search", JSON.stringify(args.query)] - if (args.limit) cliArgs.push("--limit", String(args.limit)) - const output = tryRun(cliArgs) - return output || "No results found." + async execute(args, context) { + const cliArgs = ['search', args.query]; + if (args.limit !== undefined) + cliArgs.push('--limit', String(args.limit)); + const output = tryRun( + cliArgs, + projectRoot(context.directory, context.worktree), + ); + return output || 'No results found.'; }, }), lat_section: tool({ description: - "Show full content of a lat.md section with outgoing/incoming refs", + 'Show full content of a lat.md section with outgoing/incoming refs', args: { query: tool.schema.string( 'Section ID or name (e.g. "cli#init", "Tests#User login")', ), }, - async execute(args) { - const output = tryRun(["section", JSON.stringify(args.query)]) - return output || "Section not found." + async execute(args, context) { + const output = tryRun( + ['section', args.query], + projectRoot(context.directory, context.worktree), + ); + return output || 'Section not found.'; }, }), lat_locate: tool({ description: - "Find a section by name (exact, subsection tail, or fuzzy match)", + 'Find a section by name (exact, subsection tail, or fuzzy match)', args: { - query: tool.schema.string("Section name to locate"), + query: tool.schema.string('Section name to locate'), }, - async execute(args) { - const output = tryRun(["locate", JSON.stringify(args.query)]) - return output || "No sections matching query." + async execute(args, context) { + const output = tryRun( + ['locate', args.query], + projectRoot(context.directory, context.worktree), + ); + return output || 'No sections matching query.'; }, }), @@ -70,104 +111,112 @@ export const LatPlugin: Plugin = async (ctx) => { description: "Validate all wiki links and code refs in lat.md. Returns errors or 'All checks passed'", args: {}, - async execute() { + async execute(_args, context) { try { - return run(["check"]) + return run( + ['check'], + projectRoot(context.directory, context.worktree), + ); } catch (err: unknown) { - const e = err as { stdout?: string; stderr?: string } - return e.stdout || e.stderr || "Check failed" + const e = err as { stdout?: string; stderr?: string }; + return e.stdout || e.stderr || 'Check failed'; } }, }), lat_expand: tool({ description: - "Expand [[refs]] in text to resolved file locations and context", + 'Expand [[refs]] in text to resolved file locations and context', args: { - text: tool.schema.string("Text containing [[refs]] to expand"), + text: tool.schema.string('Text containing [[refs]] to expand'), }, - async execute(args) { - const output = tryRun(["expand", JSON.stringify(args.text)]) - return output || args.text + async execute(args, context) { + const output = tryRun( + ['expand', args.text], + projectRoot(context.directory, context.worktree), + ); + return output || args.text; }, }), lat_refs: tool({ description: - "Find what references a given section via wiki links or @lat code comments", + 'Find what references a given section via wiki links or @lat code comments', args: { query: tool.schema.string( 'Section ID (e.g. "cli#init", "file#Section")', ), }, - async execute(args) { - const output = tryRun(["refs", JSON.stringify(args.query)]) - return output || "No references found." + async execute(args, context) { + const output = tryRun( + ['refs', args.query], + projectRoot(context.directory, context.worktree), + ); + return output || 'No references found.'; }, }), }, - hooks: { - "session.idle": async () => { - let checkFailed = false - let checkOutput = "" - try { - checkOutput = run(["check"]) - } catch (err: unknown) { - checkFailed = true - checkOutput = (err as { stdout?: string }).stdout || "" - } - - // Check git diff for lat.md/ sync status - let needsSync = false - let codeLines = 0 - try { - const numstat = execSync("git diff HEAD --numstat", { - encoding: "utf-8", - cwd: process.cwd(), - }) - - let latMdLines = 0 - for (const line of numstat.split("\n")) { - const parts = line.split("\t") - if (parts.length < 3) continue - const added = parseInt(parts[0], 10) || 0 - const removed = parseInt(parts[1], 10) || 0 - const file = parts[2] - const changed = added + removed - if (file.startsWith("lat.md/")) { - latMdLines += changed - } else if (/\.(ts|tsx|js|jsx|py|rs|go|c|h)$/.test(file)) { - codeLines += changed - } - } - - if (codeLines >= 5) { - const effectiveLatMd = - latMdLines === 0 ? 0 : Math.max(latMdLines, 1) - needsSync = effectiveLatMd < codeLines * 0.05 + event: async ({ event }) => { + if (event.type !== 'session.idle') return; + + const cwd = projectRoot(ctx.directory, ctx.worktree); + + let checkFailed = false; + let checkOutput = ''; + try { + checkOutput = run(['check'], cwd); + } catch (err: unknown) { + checkFailed = true; + const error = err as { stdout?: string; stderr?: string }; + checkOutput = error.stdout || error.stderr || ''; + } + + // Check git diff for lat.md/ sync status + let needsSync = false; + let codeLines = 0; + try { + const numstat = command('git diff HEAD --numstat', cwd); + + let latMdLines = 0; + for (const line of numstat.split('\n')) { + const parts = line.split('\t'); + if (parts.length < 3) continue; + const added = parseInt(parts[0], 10) || 0; + const removed = parseInt(parts[1], 10) || 0; + const file = parts[2]; + const changed = added + removed; + if (file.startsWith('lat.md/')) { + latMdLines += changed; + } else if (/\.(ts|tsx|js|jsx|py|rs|go|c|h)$/.test(file)) { + codeLines += changed; } - } catch { - // git not available or no HEAD — skip diff check } - if (!checkFailed && !needsSync) return - - const message = - checkFailed && needsSync - ? `lat check failed and lat.md/ may be out of sync (${codeLines} code lines changed). Run lat_check, fix errors, and update lat.md/.` - : checkFailed - ? `lat check failed. Run lat_check and fix the errors.` - : `lat.md/ may be out of sync — ${codeLines} code lines changed but lat.md/ was not updated. Update lat.md/ and run lat_check.` - - await ctx.client.app.log({ - body: { - service: "lat.md", - level: "warn", - message, - }, - }) - }, + if (codeLines >= 5) { + const effectiveLatMd = latMdLines === 0 ? 0 : Math.max(latMdLines, 1); + needsSync = effectiveLatMd < codeLines * 0.05; + } + } catch { + // git not available or no HEAD — skip diff check + } + + if (!checkFailed && !needsSync) return; + + const message = + checkFailed && needsSync + ? `lat check failed and lat.md/ may be out of sync (${codeLines} code lines changed). Run lat_check, fix errors, and update lat.md/.` + : checkFailed + ? `lat check failed. Run lat_check and fix the errors.` + : `lat.md/ may be out of sync — ${codeLines} code lines changed but lat.md/ was not updated. Update lat.md/ and run lat_check.`; + + await ctx.client.app.log({ + body: { + service: 'lat.md', + level: 'warn', + message, + }, + }); }, - } -} + }; +}; diff --git a/tests/gen.test.ts b/tests/gen.test.ts new file mode 100644 index 0000000..fb94eff --- /dev/null +++ b/tests/gen.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { readOpenCodePluginTemplate } from '../src/cli/gen.js'; + +describe('gen', () => { + // @lat: [[tests/gen#OpenCode plugin template uses event hook]] + it('renders the OpenCode plugin template with a session idle event handler', () => { + const template = readOpenCodePluginTemplate(); + expect(template).toContain('event: async ({ event }) => {'); + expect(template).toContain("event.type !== 'session.idle'"); + }); + + // @lat: [[tests/gen#OpenCode plugin template pipes child process output]] + it('renders the OpenCode plugin template with piped child stdio', () => { + const template = readOpenCodePluginTemplate(); + expect(template).toContain("stdio: ['ignore', 'pipe', 'pipe']"); + }); +});