@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 +21 -0
- package/README.md +106 -0
- package/package.json +47 -0
- package/src/cli.ts +190 -0
- package/src/index.ts +10 -0
- package/src/name.ts +71 -0
- package/src/scaffold.ts +94 -0
- package/src/template-files.ts +55 -0
- package/templates/default/.github/workflows/ci.yml +27 -0
- package/templates/default/.github/workflows/release-please.yml +37 -0
- package/templates/default/LICENSE.tpl +21 -0
- package/templates/default/README.md.tpl +48 -0
- package/templates/default/gitignore.tpl +5 -0
- package/templates/default/package.json.tpl +35 -0
- package/templates/default/src/index.test.ts.tpl +60 -0
- package/templates/default/src/index.ts.tpl +45 -0
- package/templates/default/tsconfig.json +11 -0
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
|
+
[](https://www.npmjs.com/package/ocps)
|
|
4
|
+
[](https://www.npmjs.com/package/ocps)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](https://github.com/itzptk/ocps/actions/workflows/ci.yml)
|
|
7
|
+
[](https://bun.sh)
|
|
8
|
+
[](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
|
+
}
|
package/src/scaffold.ts
ADDED
|
@@ -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,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}};
|