@itzptk/ocps 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 itzptk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # ocps
2
+
3
+ [![npm version](https://img.shields.io/npm/v/ocps.svg?logo=npm&style=flat-square)](https://www.npmjs.com/package/ocps)
4
+ [![npm downloads](https://img.shields.io/npm/dm/ocps.svg?style=flat-square)](https://www.npmjs.com/package/ocps)
5
+ [![license](https://img.shields.io/github/license/itzptk/ocps.svg?style=flat-square)](./LICENSE)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/itzptk/ocps/ci.yml?branch=main&label=ci&style=flat-square)](https://github.com/itzptk/ocps/actions/workflows/ci.yml)
7
+ [![Bun](https://img.shields.io/badge/powered%20by-bun-f9f1e1?style=flat-square&logo=bun)](https://bun.sh)
8
+ [![opencode](https://img.shields.io/badge/opencode-plugin-0ea5e9?style=flat-square)](https://opencode.ai)
9
+
10
+ > **ocps** = **o**pencode **c**reate **p**lugin **s**tarter
11
+
12
+ Scaffold a new [opencode](https://opencode.ai) plugin project with a single command. Generate a clean, typed, test-driven TypeScript plugin boilerplate — ready to develop, test, and publish to npm.
13
+
14
+ ## Quickstart
15
+
16
+ ```bash
17
+ npx ocps init my-plugin --name opencode-greeter
18
+ ```
19
+
20
+ This creates an `opencode-greeter/` directory with a working plugin, installs dependencies, and prints the next steps.
21
+
22
+ ## Usage
23
+
24
+ ```text
25
+ ocps init [directory] --name <package-name> [options]
26
+
27
+ Options:
28
+ --name <name> npm package name for the plugin (required)
29
+ --description <text> Short description for package.json and README
30
+ --author <name> Copyright author name for LICENSE
31
+ --force Write into a non-empty directory (does not delete existing files)
32
+ --no-install Skip running bun install after scaffolding
33
+ -h, --help Show help
34
+
35
+ Examples:
36
+ ocps init my-plugin --name opencode-greeter
37
+ ocps init --name @scope/opencode-greeter --author "Jane Doe"
38
+ ```
39
+
40
+ ## What it creates
41
+
42
+ ```text
43
+ opencode-greeter/
44
+ package.json
45
+ tsconfig.json
46
+ .gitignore
47
+ README.md
48
+ LICENSE
49
+ src/
50
+ index.ts # plugin entry exporting the hook function
51
+ index.test.ts # initialization + hook test
52
+ .github/workflows/
53
+ ci.yml
54
+ release-please.yml
55
+ ```
56
+
57
+ The generated `src/index.ts` exports a typed opencode plugin that:
58
+
59
+ - logs to `client.app.log` on load,
60
+ - subscribes to `session.idle` events,
61
+ - and short-circuits when `enabled: false`.
62
+
63
+ Register the published plugin in `opencode.json`:
64
+
65
+ ```jsonc
66
+ {
67
+ "$schema": "https://opencode.ai/config.json",
68
+ "plugin": ["opencode-greeter"]
69
+ }
70
+ ```
71
+
72
+ Restart opencode after changing config. See the [opencode plugin docs](https://opencode.ai/docs/plugins/) for the full hook surface.
73
+
74
+ ## Develop this scaffolder
75
+
76
+ ```bash
77
+ bun install
78
+ bun test
79
+ bun run typecheck
80
+ ```
81
+
82
+ Scripts:
83
+
84
+ | Script | Description |
85
+ | ------------------- | ---------------------------------------- |
86
+ | `bun test` | Run the test suite |
87
+ | `bun run typecheck` | Type-check source without emitting |
88
+
89
+ ## Contributing
90
+
91
+ Contributions are welcome! Please:
92
+
93
+ 1. Fork the repository
94
+ 2. Create a feature branch (`git checkout -b feat/my-feature`)
95
+ 3. Commit with [conventional commits](https://www.conventionalcommits.org/) (`feat: ...`, `fix: ...`, `docs: ...`)
96
+ 4. Open a pull request against `main`
97
+
98
+ Make sure `bun test` and `bun run typecheck` pass locally before submitting.
99
+
100
+ ## Releasing
101
+
102
+ Releases are automated via [release-please](https://github.com/googleapis/release-please). Merge commits to `main` with conventional-commit titles to trigger release PRs. Publishing to npm uses provenance and the `NPM_TOKEN` secret.
103
+
104
+ ## License
105
+
106
+ [MIT](./LICENSE) © itzptk
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@itzptk/ocps",
3
+ "version": "1.0.0",
4
+ "description": "opencode plugin starter — scaffold a new opencode plugin project via `npx ocps init`",
5
+ "type": "module",
6
+ "bin": {
7
+ "ocps": "./src/cli.ts"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "exports": "./src/index.ts",
13
+ "files": [
14
+ "src",
15
+ "templates",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "test": "bun test",
20
+ "typecheck": "tsc -p tsconfig.json --noEmit"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "latest",
24
+ "bun-types": "^1.3.14",
25
+ "typescript": "latest"
26
+ },
27
+ "keywords": [
28
+ "opencode",
29
+ "opencode-plugin",
30
+ "plugin",
31
+ "scaffold",
32
+ "template",
33
+ "boilerplate",
34
+ "starter",
35
+ "generator",
36
+ "create"
37
+ ],
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/itzptk/ocps.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/itzptk/ocps/issues"
45
+ },
46
+ "homepage": "https://github.com/itzptk/ocps#readme"
47
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readdirSync, statSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "node:child_process";
7
+ import { scaffold, ScaffoldError, type ScaffoldOptions } from "./scaffold";
8
+
9
+ const SCRIPT_DIR = typeof __dirname !== "undefined"
10
+ ? __dirname
11
+ : dirname(fileURLToPath(import.meta.url));
12
+ const PACKAGE_ROOT = join(SCRIPT_DIR, "..");
13
+
14
+ type ParsedArgs = {
15
+ command: string | null;
16
+ directory?: string;
17
+ name?: string;
18
+ description?: string;
19
+ author?: string;
20
+ force: boolean;
21
+ noInstall: boolean;
22
+ help: boolean;
23
+ };
24
+
25
+ function parseArgs(argv: string[]): ParsedArgs {
26
+ const out: ParsedArgs = {
27
+ command: null,
28
+ force: false,
29
+ noInstall: false,
30
+ help: false,
31
+ };
32
+ const positional: string[] = [];
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const arg = argv[i]!;
35
+ if (arg === "-h" || arg === "--help") {
36
+ out.help = true;
37
+ } else if (arg === "--force") {
38
+ out.force = true;
39
+ } else if (arg === "--no-install") {
40
+ out.noInstall = true;
41
+ } else if (arg === "--name") {
42
+ out.name = argv[++i];
43
+ } else if (arg.startsWith("--name=")) {
44
+ out.name = arg.slice("--name=".length);
45
+ } else if (arg === "--description") {
46
+ out.description = argv[++i];
47
+ } else if (arg.startsWith("--description=")) {
48
+ out.description = arg.slice("--description=".length);
49
+ } else if (arg === "--author") {
50
+ out.author = argv[++i];
51
+ } else if (arg.startsWith("--author=")) {
52
+ out.author = arg.slice("--author=".length);
53
+ } else if (arg.startsWith("--")) {
54
+ throw new CliError(`Unknown option: ${arg}`);
55
+ } else {
56
+ positional.push(arg);
57
+ }
58
+ }
59
+ if (positional.length > 0) out.command = positional[0]!;
60
+ if (positional.length > 1) out.directory = positional[1];
61
+ return out;
62
+ }
63
+
64
+ class CliError extends Error {}
65
+
66
+ function printHelp(): void {
67
+ const lines = [
68
+ "ocps - scaffold a new opencode plugin",
69
+ "",
70
+ "Usage:",
71
+ " ocps init [directory] --name <package-name> [options]",
72
+ "",
73
+ "Options:",
74
+ " --name <name> npm package name for the plugin (required)",
75
+ " --description <text> Short description for package.json and README",
76
+ " --author <name> Copyright author name for LICENSE",
77
+ " --force Write into a non-empty directory (does not delete existing files)",
78
+ " --no-install Skip running bun install after scaffolding",
79
+ " -h, --help Show this help",
80
+ "",
81
+ "Examples:",
82
+ " ocps init my-plugin --name opencode-greeter",
83
+ " ocps init --name @scope/opencode-greeter --author \"Jane Doe\"",
84
+ ];
85
+ process.stdout.write(lines.join("\n") + "\n");
86
+ }
87
+
88
+ function detectBun(): string | null {
89
+ const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
90
+ return r.status === 0 ? "bun" : null;
91
+ }
92
+
93
+ function runInstall(directory: string, noInstall: boolean): void {
94
+ if (noInstall) {
95
+ printNext(directory, false);
96
+ return;
97
+ }
98
+ const bun = detectBun();
99
+ if (!bun) {
100
+ process.stdout.write("\nBun was not detected. Next step:\n cd " + directory + " && bun install\n");
101
+ return;
102
+ }
103
+ process.stdout.write("\nRunning bun install...\n");
104
+ const r = spawnSync(bun, ["install"], { cwd: directory, stdio: "inherit" });
105
+ if (r.status !== 0) {
106
+ process.stdout.write("\nbun install exited with code " + String(r.status) + ". You can retry manually.\n");
107
+ return;
108
+ }
109
+ printNext(directory, true);
110
+ }
111
+
112
+ function printNext(directory: string, installed: boolean): void {
113
+ const cd = directory === process.cwd() ? "" : `cd ${directory}\n`;
114
+ const installLine = installed ? "" : "bun install\n";
115
+ process.stdout.write(
116
+ [
117
+ "",
118
+ "Done. Next steps:",
119
+ cd ? " " + cd.replace(/\n$/, "") : null,
120
+ " " + installLine.replace(/\n$/, ""),
121
+ " bun test",
122
+ " bun run typecheck",
123
+ "",
124
+ "Then register the plugin in your opencode.json:",
125
+ ' { "$schema": "https://opencode.ai/config.json", "plugin": ["<name>"] }',
126
+ "",
127
+ ]
128
+ .filter((l) => l !== null && l !== "" || l === "")
129
+ .join("\n") + "\n",
130
+ );
131
+ }
132
+
133
+ function runInit(args: ParsedArgs): void {
134
+ if (!args.name) {
135
+ throw new CliError("Missing required option: --name <package-name>");
136
+ }
137
+ const opts: ScaffoldOptions = {
138
+ name: args.name,
139
+ directory: args.directory,
140
+ description: args.description,
141
+ authorName: args.author,
142
+ force: args.force,
143
+ };
144
+ const result = scaffold(opts, PACKAGE_ROOT);
145
+ process.stdout.write(
146
+ `Created ${result.written.length} files in ${result.directory}\n` +
147
+ ` package: ${result.name}\n` +
148
+ ` export: ${result.exportName}\n`,
149
+ );
150
+ runInstall(result.directory, args.noInstall);
151
+ }
152
+
153
+ function main(): void {
154
+ let args: ParsedArgs;
155
+ try {
156
+ args = parseArgs(process.argv.slice(2));
157
+ } catch (err) {
158
+ if (err instanceof CliError) {
159
+ process.stderr.write(`Error: ${err.message}\n`);
160
+ process.exitCode = 1;
161
+ return;
162
+ }
163
+ throw err;
164
+ }
165
+
166
+ if (args.help || args.command === null) {
167
+ printHelp();
168
+ return;
169
+ }
170
+
171
+ if (args.command !== "init") {
172
+ process.stderr.write(`Unknown command: ${args.command}\n`);
173
+ process.stderr.write("Run with --help for usage.\n");
174
+ process.exitCode = 2;
175
+ return;
176
+ }
177
+
178
+ try {
179
+ runInit(args);
180
+ } catch (err) {
181
+ if (err instanceof ScaffoldError || err instanceof CliError) {
182
+ process.stderr.write(`Error: ${err.message}\n`);
183
+ process.exitCode = 1;
184
+ return;
185
+ }
186
+ throw err;
187
+ }
188
+ }
189
+
190
+ main();
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { scaffold, ScaffoldError, resolveDirectory, type ScaffoldOptions, type ScaffoldResult } from "./scaffold";
2
+ export {
3
+ parseName,
4
+ isValidName,
5
+ toPascalCase,
6
+ deriveExportName,
7
+ derivePluginExportName,
8
+ type ParsedName,
9
+ } from "./name";
10
+ export { render, listTemplateFiles, defaultTemplateDir, type TemplateVars, type TemplateFile } from "./template-files";
package/src/name.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * npm package name validation and TypeScript export name derivation.
3
+ */
4
+
5
+ const NAME_RE = /^(?:@([a-z0-9][a-z0-9._-]*)\/)?([a-z0-9][a-z0-9._-]*)$/;
6
+
7
+ export type ParsedName = {
8
+ raw: string;
9
+ scope: string | null;
10
+ local: string;
11
+ };
12
+
13
+ export function parseName(raw: string): ParsedName | null {
14
+ const trimmed = raw.trim();
15
+ if (trimmed.length === 0) return null;
16
+ if (trimmed.length > 214) return null;
17
+ const match = NAME_RE.exec(trimmed);
18
+ if (!match) return null;
19
+ const scope = match[1] ?? null;
20
+ const local = match[2]!;
21
+ if (scope !== null && scope.length > 214) return null;
22
+ if (local.length > 214) return null;
23
+ if (trimmed.toLowerCase() === "node_modules" || trimmed.toLowerCase() === "favicon.ico") return null;
24
+ return { raw: trimmed, scope, local };
25
+ }
26
+
27
+ export function isValidName(raw: string): boolean {
28
+ return parseName(raw) !== null;
29
+ }
30
+
31
+ const RESERVED_WORDS = new Set([
32
+ "abstract", "await", "boolean", "break", "byte", "case", "catch", "char",
33
+ "class", "const", "continue", "debugger", "default", "delete", "do", "double",
34
+ "else", "enum", "export", "extends", "false", "final", "finally", "float",
35
+ "for", "function", "goto", "if", "implements", "import", "in", "instanceof",
36
+ "int", "interface", "let", "long", "native", "new", "null", "package", "private",
37
+ "protected", "public", "return", "short", "static", "super", "switch",
38
+ "synchronized", "this", "throw", "throws", "transient", "true", "try",
39
+ "typeof", "var", "void", "volatile", "while", "with", "yield",
40
+ ]);
41
+
42
+ export function toPascalCase(input: string): string {
43
+ const cleaned = input.replace(/[^a-zA-Z0-9]+/g, " ").trim();
44
+ if (cleaned.length === 0) return "Plugin";
45
+ const parts = cleaned.split(/\s+/).filter(Boolean);
46
+ const pascal = parts
47
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
48
+ .join("");
49
+ if (RESERVED_WORDS.has(pascal.toLowerCase())) return `${pascal}Plugin`;
50
+ if (/^[0-9]/.test(pascal)) return `Plugin${pascal}`;
51
+ return pascal;
52
+ }
53
+
54
+ export function deriveExportName(raw: string, opts: { stripScope?: boolean } = {}): string {
55
+ const parsed = parseName(raw);
56
+ if (!parsed) return "MyPlugin";
57
+ const source = opts.stripScope ? parsed.local : (parsed.scope ? `${parsed.scope}-${parsed.local}` : parsed.local);
58
+ return `${toPascalCase(source)}Plugin`;
59
+ }
60
+
61
+ export function derivePluginExportName(raw: string): string {
62
+ const parsed = parseName(raw);
63
+ if (!parsed) return "MyPlugin";
64
+ return `${toPascalCase(parsed.local)}Plugin`;
65
+ }
66
+
67
+ export function sanitizePathSegment(raw: string): string {
68
+ const parsed = parseName(raw);
69
+ if (!parsed) return "opencode-plugin";
70
+ return parsed.scope ? `${parsed.scope}/${parsed.local}` : parsed.local;
71
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import {
4
+ derivePluginExportName,
5
+ parseName,
6
+ type ParsedName,
7
+ } from "./name";
8
+ import { defaultTemplateDir, listTemplateFiles, render, type TemplateVars } from "./template-files";
9
+
10
+ export type ScaffoldOptions = {
11
+ name: string;
12
+ directory?: string;
13
+ description?: string;
14
+ authorName?: string;
15
+ force?: boolean;
16
+ };
17
+
18
+ export type ScaffoldResult = {
19
+ directory: string;
20
+ written: string[];
21
+ name: string;
22
+ exportName: string;
23
+ };
24
+
25
+ export class ScaffoldError extends Error {}
26
+
27
+ const IGNORED_EXISTING = new Set([".git", ".DS_Store"]);
28
+
29
+ function dirIsEmptyOrVcsOnly(dir: string): boolean {
30
+ if (!existsSync(dir)) return true;
31
+ const entries = readdirSync(dir).filter((e) => !IGNORED_EXISTING.has(e));
32
+ return entries.length === 0;
33
+ }
34
+
35
+ export function resolveDirectory(opts: ScaffoldOptions, parsed: ParsedName): string {
36
+ if (opts.directory && opts.directory.trim().length > 0) {
37
+ return resolve(opts.directory);
38
+ }
39
+ return resolve(parsed.local);
40
+ }
41
+
42
+ export function scaffold(
43
+ opts: ScaffoldOptions,
44
+ templateRootDir: string,
45
+ now: Date = new Date(),
46
+ ): ScaffoldResult {
47
+ const parsed = parseName(opts.name);
48
+ if (!parsed) {
49
+ throw new ScaffoldError(`Invalid package name: "${opts.name}". Use a valid npm name like "opencode-foo" or "@scope/opencode-foo".`);
50
+ }
51
+
52
+ const directory = resolveDirectory(opts, parsed);
53
+ if (existsSync(directory) && statSync(directory).isFile()) {
54
+ throw new ScaffoldError(`Target path exists and is a file: ${directory}`);
55
+ }
56
+ if (existsSync(directory) && !statSync(directory).isDirectory()) {
57
+ throw new ScaffoldError(`Target path is not a directory: ${directory}`);
58
+ }
59
+ if (!opts.force && existsSync(directory) && !dirIsEmptyOrVcsOnly(directory)) {
60
+ throw new ScaffoldError(
61
+ `Target directory is not empty: ${directory}. Pass --force to overwrite, or choose an empty directory.`,
62
+ );
63
+ }
64
+
65
+ const exportName = derivePluginExportName(opts.name);
66
+ const description = (opts.description ?? "An opencode plugin").trim();
67
+ const year = String(now.getFullYear());
68
+
69
+ const vars: TemplateVars = {
70
+ name: parsed.raw,
71
+ exportName,
72
+ description,
73
+ year,
74
+ authorName: opts.authorName ?? parsed.local,
75
+ };
76
+
77
+ const templateDir = defaultTemplateDir(templateRootDir);
78
+ if (!existsSync(templateDir)) {
79
+ throw new ScaffoldError(`Template directory not found: ${templateDir}`);
80
+ }
81
+
82
+ const files = listTemplateFiles(templateDir);
83
+ const written: string[] = [];
84
+
85
+ for (const file of files) {
86
+ const target = join(directory, file.relative);
87
+ mkdirSync(dirname(target), { recursive: true });
88
+ const body = render(file.content, vars);
89
+ writeFileSync(target, body);
90
+ written.push(file.relative);
91
+ }
92
+
93
+ return { directory, written, name: parsed.raw, exportName };
94
+ }
@@ -0,0 +1,55 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+
4
+ export type TemplateVars = {
5
+ name: string;
6
+ exportName: string;
7
+ description: string;
8
+ year: string;
9
+ repository?: string;
10
+ authorName?: string;
11
+ };
12
+
13
+ const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
14
+
15
+ export function render(content: string, vars: TemplateVars): string {
16
+ return content.replace(PLACEHOLDER_RE, (match, key: string) => {
17
+ if (key === "name") return vars.name;
18
+ if (key === "exportName") return vars.exportName;
19
+ if (key === "description") return vars.description;
20
+ if (key === "year") return vars.year;
21
+ if (key === "repository") return vars.repository ?? "";
22
+ if (key === "authorName") return vars.authorName ?? "";
23
+ return match;
24
+ });
25
+ }
26
+
27
+ export type TemplateFile = {
28
+ relative: string;
29
+ content: string;
30
+ };
31
+
32
+ export function listTemplateFiles(templateDir: string): TemplateFile[] {
33
+ const results: TemplateFile[] = [];
34
+ const walk = (dir: string) => {
35
+ for (const entry of readdirSync(dir)) {
36
+ const full = join(dir, entry);
37
+ const st = statSync(full);
38
+ if (st.isDirectory()) {
39
+ walk(full);
40
+ continue;
41
+ }
42
+ const rel = relative(templateDir, full).split(sep).join("/");
43
+ const content = readFileSync(full, "utf-8");
44
+ const stripped = rel.endsWith(".tpl") ? rel.slice(0, -".tpl".length) : rel;
45
+ const target = stripped === "gitignore" ? ".gitignore" : stripped;
46
+ results.push({ relative: target, content });
47
+ }
48
+ };
49
+ walk(templateDir);
50
+ return results.sort((a, b) => a.relative.localeCompare(b.relative));
51
+ }
52
+
53
+ export function defaultTemplateDir(rootDir: string): string {
54
+ return join(rootDir, "templates", "default");
55
+ }
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout
13
+ uses: actions/checkout@v4
14
+
15
+ - name: Setup Bun
16
+ uses: oven-sh/setup-bun@v2
17
+ with:
18
+ bun-version: latest
19
+
20
+ - name: Install dependencies
21
+ run: bun install --frozen-lockfile
22
+
23
+ - name: Typecheck
24
+ run: bun run typecheck
25
+
26
+ - name: Test
27
+ run: bun test
@@ -0,0 +1,37 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ permissions:
8
+ contents: write
9
+ pull-requests: write
10
+ id-token: write
11
+
12
+ jobs:
13
+ release-please:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Release Please
17
+ id: release
18
+ uses: googleapis/release-please-action@v5
19
+ with:
20
+ release-type: node
21
+
22
+ - name: Checkout
23
+ if: ${{ steps.release.outputs.release_created }}
24
+ uses: actions/checkout@v5
25
+
26
+ - name: Setup Node
27
+ if: ${{ steps.release.outputs.release_created }}
28
+ uses: actions/setup-node@v5
29
+ with:
30
+ node-version: lts/*
31
+ registry-url: https://registry.npmjs.org
32
+
33
+ - name: Publish to npm
34
+ if: ${{ steps.release.outputs.release_created }}
35
+ run: npm publish --access public --provenance
36
+ env:
37
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) {{year}} {{authorName}}
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,48 @@
1
+ # {{name}}
2
+
3
+ {{description}}
4
+
5
+ ## Install
6
+
7
+ Add the plugin to your OpenCode config:
8
+
9
+ ```jsonc
10
+ {
11
+ "$schema": "https://opencode.ai/config.json",
12
+ "plugin": ["{{name}}"]
13
+ }
14
+ ```
15
+
16
+ Restart OpenCode after changing config.
17
+
18
+ ## Configure with options
19
+
20
+ You can also pass options inline:
21
+
22
+ ```jsonc
23
+ {
24
+ "$schema": "https://opencode.ai/config.json",
25
+ "plugin": [
26
+ ["{{name}}", { "enabled": true }]
27
+ ]
28
+ }
29
+ ```
30
+
31
+ ## Development
32
+
33
+ ```bash
34
+ bun install
35
+ bun test
36
+ bun run typecheck
37
+ ```
38
+
39
+ ## Plugin structure
40
+
41
+ - `src/index.ts` exports the plugin function. Edit the hooks here.
42
+ - `src/index.test.ts` verifies the plugin initializes and returns hooks.
43
+
44
+ See the [opencode plugin docs](https://opencode.ai/docs/plugins/) for the full hook surface.
45
+
46
+ ## License
47
+
48
+ [MIT](./LICENSE) © {{year}}
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ *.tgz
3
+ .DS_Store
4
+ .env
5
+ .env.*
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "0.1.0",
4
+ "description": "{{description}}",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "exports": "./src/index.ts",
8
+ "files": [
9
+ "src",
10
+ "README.md"
11
+ ],
12
+ "scripts": {
13
+ "test": "bun test",
14
+ "typecheck": "tsc -p tsconfig.json --noEmit"
15
+ },
16
+ "peerDependencies": {
17
+ "@opencode-ai/plugin": "*"
18
+ },
19
+ "devDependencies": {
20
+ "@opencode-ai/plugin": "latest",
21
+ "@types/node": "latest",
22
+ "bun-types": "^1.3.14",
23
+ "typescript": "latest"
24
+ },
25
+ "keywords": [
26
+ "opencode",
27
+ "plugin"
28
+ ],
29
+ "license": "MIT",
30
+ "homepage": "https://opencode.ai/docs/plugins/",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": ""
34
+ }
35
+ }
@@ -0,0 +1,60 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ import { expect, test, mock } from "bun:test";
4
+ import { {{exportName}} } from "./index";
5
+ import type { PluginInput, Hooks } from "@opencode-ai/plugin";
6
+
7
+ function makeFakeCtx(): { ctx: PluginInput; logCalls: Array<Record<string, unknown>> } {
8
+ const logCalls: Array<Record<string, unknown>> = [];
9
+ const ctx = {
10
+ client: {
11
+ app: {
12
+ log: mock((opts: unknown) => {
13
+ logCalls.push(opts as Record<string, unknown>);
14
+ }),
15
+ },
16
+ session: {},
17
+ tui: {},
18
+ },
19
+ directory: "/test",
20
+ worktree: "/test",
21
+ project: { id: "proj-1" },
22
+ serverUrl: new URL("http://localhost"),
23
+ $: {} as PluginInput["$"],
24
+ experimental_workspace: {} as PluginInput["experimental_workspace"],
25
+ } as unknown as PluginInput;
26
+ return { ctx, logCalls };
27
+ }
28
+
29
+ test("plugin initializes, logs, and returns hooks", async () => {
30
+ const { ctx, logCalls } = makeFakeCtx();
31
+ const hooks: Hooks = await {{exportName}}(ctx, {});
32
+
33
+ expect(hooks.event).toBeDefined();
34
+ expect(hooks.dispose).toBeDefined();
35
+ expect(logCalls.length).toBe(1);
36
+ expect((logCalls[0]!.body as Record<string, unknown>).service).toBe("{{name}}");
37
+ });
38
+
39
+ test("disabled plugin returns no-op hooks without logging", async () => {
40
+ const { ctx, logCalls } = makeFakeCtx();
41
+ const hooks: Hooks = await {{exportName}}(ctx, { enabled: false });
42
+
43
+ expect(hooks.event).toBeUndefined();
44
+ expect(logCalls.length).toBe(0);
45
+ await hooks.dispose?.();
46
+ });
47
+
48
+ test("event hook logs session.idle", async () => {
49
+ const { ctx, logCalls } = makeFakeCtx();
50
+ const hooks: Hooks = await {{exportName}}(ctx, {});
51
+
52
+ await hooks.event?.({ event: { type: "session.idle", properties: {} } as never });
53
+ for (let i = 0; i < 4; i++) await Promise.resolve();
54
+
55
+ const idleLog = logCalls.find(
56
+ (c) => (c.body as Record<string, unknown>).message === "session idle",
57
+ );
58
+ expect(idleLog).toBeDefined();
59
+ await hooks.dispose?.();
60
+ });
@@ -0,0 +1,45 @@
1
+ import type { Hooks, Plugin } from "@opencode-ai/plugin";
2
+
3
+ export type {{exportName}}Options = {
4
+ enabled?: boolean;
5
+ };
6
+
7
+ const DEFAULT_OPTIONS: {{exportName}}Options = {
8
+ enabled: true,
9
+ };
10
+
11
+ export const {{exportName}}: Plugin = async (ctx, rawOptions = {}): Promise<Hooks> => {
12
+ const options: {{exportName}}Options = {
13
+ ...DEFAULT_OPTIONS,
14
+ ...(rawOptions as {{exportName}}Options),
15
+ };
16
+
17
+ if (!options.enabled) {
18
+ return { dispose: async () => {} };
19
+ }
20
+
21
+ await ctx.client.app.log({
22
+ body: {
23
+ service: "{{name}}",
24
+ level: "info",
25
+ message: "plugin loaded",
26
+ },
27
+ });
28
+
29
+ return {
30
+ event: async ({ event }) => {
31
+ if (event.type === "session.idle") {
32
+ await ctx.client.app.log({
33
+ body: {
34
+ service: "{{name}}",
35
+ level: "debug",
36
+ message: "session idle",
37
+ },
38
+ });
39
+ }
40
+ },
41
+ dispose: async () => {},
42
+ };
43
+ };
44
+
45
+ export default {{exportName}};
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "types": ["node"]
9
+ },
10
+ "include": ["src/**/*.ts"]
11
+ }