@itzptk/ocps 1.0.0 → 1.0.1

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # ocps
2
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)
3
+ [![npm version](https://img.shields.io/npm/v/@itzptk/ocps.svg?logo=npm&style=flat-square)](https://www.npmjs.com/package/@itzptk/ocps)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@itzptk/ocps.svg?style=flat-square)](https://www.npmjs.com/package/@itzptk/ocps)
5
5
  [![license](https://img.shields.io/github/license/itzptk/ocps.svg?style=flat-square)](./LICENSE)
6
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
7
  [![Bun](https://img.shields.io/badge/powered%20by-bun-f9f1e1?style=flat-square&logo=bun)](https://bun.sh)
@@ -14,7 +14,7 @@ Scaffold a new [opencode](https://opencode.ai) plugin project with a single comm
14
14
  ## Quickstart
15
15
 
16
16
  ```bash
17
- npx ocps init my-plugin --name opencode-greeter
17
+ npx @itzptk/ocps init my-plugin --name opencode-greeter
18
18
  ```
19
19
 
20
20
  This creates an `opencode-greeter/` directory with a working plugin, installs dependencies, and prints the next steps.
package/dist/cli.js ADDED
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { dirname as dirname2, join as join3 } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "node:child_process";
7
+
8
+ // src/scaffold.ts
9
+ import { existsSync, mkdirSync, readdirSync as readdirSync2, statSync as statSync2, writeFileSync } from "node:fs";
10
+ import { dirname, join as join2, resolve } from "node:path";
11
+
12
+ // src/name.ts
13
+ var NAME_RE = /^(?:@([a-z0-9][a-z0-9._-]*)\/)?([a-z0-9][a-z0-9._-]*)$/;
14
+ function parseName(raw) {
15
+ const trimmed = raw.trim();
16
+ if (trimmed.length === 0)
17
+ return null;
18
+ if (trimmed.length > 214)
19
+ return null;
20
+ const match = NAME_RE.exec(trimmed);
21
+ if (!match)
22
+ return null;
23
+ const scope = match[1] ?? null;
24
+ const local = match[2];
25
+ if (scope !== null && scope.length > 214)
26
+ return null;
27
+ if (local.length > 214)
28
+ return null;
29
+ if (trimmed.toLowerCase() === "node_modules" || trimmed.toLowerCase() === "favicon.ico")
30
+ return null;
31
+ return { raw: trimmed, scope, local };
32
+ }
33
+ function isValidName(raw) {
34
+ return parseName(raw) !== null;
35
+ }
36
+ var RESERVED_WORDS = new Set([
37
+ "abstract",
38
+ "await",
39
+ "boolean",
40
+ "break",
41
+ "byte",
42
+ "case",
43
+ "catch",
44
+ "char",
45
+ "class",
46
+ "const",
47
+ "continue",
48
+ "debugger",
49
+ "default",
50
+ "delete",
51
+ "do",
52
+ "double",
53
+ "else",
54
+ "enum",
55
+ "export",
56
+ "extends",
57
+ "false",
58
+ "final",
59
+ "finally",
60
+ "float",
61
+ "for",
62
+ "function",
63
+ "goto",
64
+ "if",
65
+ "implements",
66
+ "import",
67
+ "in",
68
+ "instanceof",
69
+ "int",
70
+ "interface",
71
+ "let",
72
+ "long",
73
+ "native",
74
+ "new",
75
+ "null",
76
+ "package",
77
+ "private",
78
+ "protected",
79
+ "public",
80
+ "return",
81
+ "short",
82
+ "static",
83
+ "super",
84
+ "switch",
85
+ "synchronized",
86
+ "this",
87
+ "throw",
88
+ "throws",
89
+ "transient",
90
+ "true",
91
+ "try",
92
+ "typeof",
93
+ "var",
94
+ "void",
95
+ "volatile",
96
+ "while",
97
+ "with",
98
+ "yield"
99
+ ]);
100
+ function toPascalCase(input) {
101
+ const cleaned = input.replace(/[^a-zA-Z0-9]+/g, " ").trim();
102
+ if (cleaned.length === 0)
103
+ return "Plugin";
104
+ const parts = cleaned.split(/\s+/).filter(Boolean);
105
+ const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
106
+ if (RESERVED_WORDS.has(pascal.toLowerCase()))
107
+ return `${pascal}Plugin`;
108
+ if (/^[0-9]/.test(pascal))
109
+ return `Plugin${pascal}`;
110
+ return pascal;
111
+ }
112
+ function deriveExportName(raw, opts = {}) {
113
+ const parsed = parseName(raw);
114
+ if (!parsed)
115
+ return "MyPlugin";
116
+ const source = opts.stripScope ? parsed.local : parsed.scope ? `${parsed.scope}-${parsed.local}` : parsed.local;
117
+ return `${toPascalCase(source)}Plugin`;
118
+ }
119
+ function derivePluginExportName(raw) {
120
+ const parsed = parseName(raw);
121
+ if (!parsed)
122
+ return "MyPlugin";
123
+ return `${toPascalCase(parsed.local)}Plugin`;
124
+ }
125
+
126
+ // src/template-files.ts
127
+ import { readdirSync, readFileSync, statSync } from "node:fs";
128
+ import { join, relative, sep } from "node:path";
129
+ var PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
130
+ function render(content, vars) {
131
+ return content.replace(PLACEHOLDER_RE, (match, key) => {
132
+ if (key === "name")
133
+ return vars.name;
134
+ if (key === "exportName")
135
+ return vars.exportName;
136
+ if (key === "description")
137
+ return vars.description;
138
+ if (key === "year")
139
+ return vars.year;
140
+ if (key === "repository")
141
+ return vars.repository ?? "";
142
+ if (key === "authorName")
143
+ return vars.authorName ?? "";
144
+ return match;
145
+ });
146
+ }
147
+ function listTemplateFiles(templateDir) {
148
+ const results = [];
149
+ const walk = (dir) => {
150
+ for (const entry of readdirSync(dir)) {
151
+ const full = join(dir, entry);
152
+ const st = statSync(full);
153
+ if (st.isDirectory()) {
154
+ walk(full);
155
+ continue;
156
+ }
157
+ const rel = relative(templateDir, full).split(sep).join("/");
158
+ const content = readFileSync(full, "utf-8");
159
+ const stripped = rel.endsWith(".tpl") ? rel.slice(0, -".tpl".length) : rel;
160
+ const target = stripped === "gitignore" ? ".gitignore" : stripped;
161
+ results.push({ relative: target, content });
162
+ }
163
+ };
164
+ walk(templateDir);
165
+ return results.sort((a, b) => a.relative.localeCompare(b.relative));
166
+ }
167
+ function defaultTemplateDir(rootDir) {
168
+ return join(rootDir, "templates", "default");
169
+ }
170
+
171
+ // src/scaffold.ts
172
+ class ScaffoldError extends Error {
173
+ }
174
+ var IGNORED_EXISTING = new Set([".git", ".DS_Store"]);
175
+ function dirIsEmptyOrVcsOnly(dir) {
176
+ if (!existsSync(dir))
177
+ return true;
178
+ const entries = readdirSync2(dir).filter((e) => !IGNORED_EXISTING.has(e));
179
+ return entries.length === 0;
180
+ }
181
+ function resolveDirectory(opts, parsed) {
182
+ if (opts.directory && opts.directory.trim().length > 0) {
183
+ return resolve(opts.directory);
184
+ }
185
+ return resolve(parsed.local);
186
+ }
187
+ function scaffold(opts, templateRootDir, now = new Date) {
188
+ const parsed = parseName(opts.name);
189
+ if (!parsed) {
190
+ throw new ScaffoldError(`Invalid package name: "${opts.name}". Use a valid npm name like "opencode-foo" or "@scope/opencode-foo".`);
191
+ }
192
+ const directory = resolveDirectory(opts, parsed);
193
+ if (existsSync(directory) && statSync2(directory).isFile()) {
194
+ throw new ScaffoldError(`Target path exists and is a file: ${directory}`);
195
+ }
196
+ if (existsSync(directory) && !statSync2(directory).isDirectory()) {
197
+ throw new ScaffoldError(`Target path is not a directory: ${directory}`);
198
+ }
199
+ if (!opts.force && existsSync(directory) && !dirIsEmptyOrVcsOnly(directory)) {
200
+ throw new ScaffoldError(`Target directory is not empty: ${directory}. Pass --force to overwrite, or choose an empty directory.`);
201
+ }
202
+ const exportName = derivePluginExportName(opts.name);
203
+ const description = (opts.description ?? "An opencode plugin").trim();
204
+ const year = String(now.getFullYear());
205
+ const vars = {
206
+ name: parsed.raw,
207
+ exportName,
208
+ description,
209
+ year,
210
+ authorName: opts.authorName ?? parsed.local
211
+ };
212
+ const templateDir = defaultTemplateDir(templateRootDir);
213
+ if (!existsSync(templateDir)) {
214
+ throw new ScaffoldError(`Template directory not found: ${templateDir}`);
215
+ }
216
+ const files = listTemplateFiles(templateDir);
217
+ const written = [];
218
+ for (const file of files) {
219
+ const target = join2(directory, file.relative);
220
+ mkdirSync(dirname(target), { recursive: true });
221
+ const body = render(file.content, vars);
222
+ writeFileSync(target, body);
223
+ written.push(file.relative);
224
+ }
225
+ return { directory, written, name: parsed.raw, exportName };
226
+ }
227
+
228
+ // src/cli.ts
229
+ var __dirname = "/home/activadee/dev/opencode-plugin-template/src";
230
+ var SCRIPT_DIR = typeof __dirname !== "undefined" ? __dirname : dirname2(fileURLToPath(import.meta.url));
231
+ var PACKAGE_ROOT = join3(SCRIPT_DIR, "..");
232
+ function parseArgs(argv) {
233
+ const out = {
234
+ command: null,
235
+ force: false,
236
+ noInstall: false,
237
+ help: false
238
+ };
239
+ const positional = [];
240
+ for (let i = 0;i < argv.length; i++) {
241
+ const arg = argv[i];
242
+ if (arg === "-h" || arg === "--help") {
243
+ out.help = true;
244
+ } else if (arg === "--force") {
245
+ out.force = true;
246
+ } else if (arg === "--no-install") {
247
+ out.noInstall = true;
248
+ } else if (arg === "--name") {
249
+ out.name = argv[++i];
250
+ } else if (arg.startsWith("--name=")) {
251
+ out.name = arg.slice("--name=".length);
252
+ } else if (arg === "--description") {
253
+ out.description = argv[++i];
254
+ } else if (arg.startsWith("--description=")) {
255
+ out.description = arg.slice("--description=".length);
256
+ } else if (arg === "--author") {
257
+ out.author = argv[++i];
258
+ } else if (arg.startsWith("--author=")) {
259
+ out.author = arg.slice("--author=".length);
260
+ } else if (arg.startsWith("--")) {
261
+ throw new CliError(`Unknown option: ${arg}`);
262
+ } else {
263
+ positional.push(arg);
264
+ }
265
+ }
266
+ if (positional.length > 0)
267
+ out.command = positional[0];
268
+ if (positional.length > 1)
269
+ out.directory = positional[1];
270
+ return out;
271
+ }
272
+
273
+ class CliError extends Error {
274
+ }
275
+ function printHelp() {
276
+ const lines = [
277
+ "ocps - scaffold a new opencode plugin",
278
+ "",
279
+ "Usage:",
280
+ " ocps init [directory] --name <package-name> [options]",
281
+ "",
282
+ "Options:",
283
+ " --name <name> npm package name for the plugin (required)",
284
+ " --description <text> Short description for package.json and README",
285
+ " --author <name> Copyright author name for LICENSE",
286
+ " --force Write into a non-empty directory (does not delete existing files)",
287
+ " --no-install Skip running bun install after scaffolding",
288
+ " -h, --help Show this help",
289
+ "",
290
+ "Examples:",
291
+ " ocps init my-plugin --name opencode-greeter",
292
+ ' ocps init --name @scope/opencode-greeter --author "Jane Doe"'
293
+ ];
294
+ process.stdout.write(lines.join(`
295
+ `) + `
296
+ `);
297
+ }
298
+ function detectBun() {
299
+ const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
300
+ return r.status === 0 ? "bun" : null;
301
+ }
302
+ function runInstall(directory, noInstall) {
303
+ if (noInstall) {
304
+ printNext(directory, false);
305
+ return;
306
+ }
307
+ const bun = detectBun();
308
+ if (!bun) {
309
+ process.stdout.write(`
310
+ Bun was not detected. Next step:
311
+ cd ` + directory + ` && bun install
312
+ `);
313
+ return;
314
+ }
315
+ process.stdout.write(`
316
+ Running bun install...
317
+ `);
318
+ const r = spawnSync(bun, ["install"], { cwd: directory, stdio: "inherit" });
319
+ if (r.status !== 0) {
320
+ process.stdout.write(`
321
+ bun install exited with code ` + String(r.status) + `. You can retry manually.
322
+ `);
323
+ return;
324
+ }
325
+ printNext(directory, true);
326
+ }
327
+ function printNext(directory, installed) {
328
+ const cd = directory === process.cwd() ? "" : `cd ${directory}
329
+ `;
330
+ const installLine = installed ? "" : `bun install
331
+ `;
332
+ process.stdout.write([
333
+ "",
334
+ "Done. Next steps:",
335
+ cd ? " " + cd.replace(/\n$/, "") : null,
336
+ " " + installLine.replace(/\n$/, ""),
337
+ " bun test",
338
+ " bun run typecheck",
339
+ "",
340
+ "Then register the plugin in your opencode.json:",
341
+ ' { "$schema": "https://opencode.ai/config.json", "plugin": ["<name>"] }',
342
+ ""
343
+ ].filter((l) => l !== null && l !== "" || l === "").join(`
344
+ `) + `
345
+ `);
346
+ }
347
+ function runInit(args) {
348
+ if (!args.name) {
349
+ throw new CliError("Missing required option: --name <package-name>");
350
+ }
351
+ const opts = {
352
+ name: args.name,
353
+ directory: args.directory,
354
+ description: args.description,
355
+ authorName: args.author,
356
+ force: args.force
357
+ };
358
+ const result = scaffold(opts, PACKAGE_ROOT);
359
+ process.stdout.write(`Created ${result.written.length} files in ${result.directory}
360
+ ` + ` package: ${result.name}
361
+ ` + ` export: ${result.exportName}
362
+ `);
363
+ runInstall(result.directory, args.noInstall);
364
+ }
365
+ function main() {
366
+ let args;
367
+ try {
368
+ args = parseArgs(process.argv.slice(2));
369
+ } catch (err) {
370
+ if (err instanceof CliError) {
371
+ process.stderr.write(`Error: ${err.message}
372
+ `);
373
+ process.exitCode = 1;
374
+ return;
375
+ }
376
+ throw err;
377
+ }
378
+ if (args.help || args.command === null) {
379
+ printHelp();
380
+ return;
381
+ }
382
+ if (args.command !== "init") {
383
+ process.stderr.write(`Unknown command: ${args.command}
384
+ `);
385
+ process.stderr.write(`Run with --help for usage.
386
+ `);
387
+ process.exitCode = 2;
388
+ return;
389
+ }
390
+ try {
391
+ runInit(args);
392
+ } catch (err) {
393
+ if (err instanceof ScaffoldError || err instanceof CliError) {
394
+ process.stderr.write(`Error: ${err.message}
395
+ `);
396
+ process.exitCode = 1;
397
+ return;
398
+ }
399
+ throw err;
400
+ }
401
+ }
402
+ main();
package/dist/index.js ADDED
@@ -0,0 +1,232 @@
1
+ // src/scaffold.ts
2
+ import { existsSync, mkdirSync, readdirSync as readdirSync2, statSync as statSync2, writeFileSync } from "node:fs";
3
+ import { dirname, join as join2, resolve } from "node:path";
4
+
5
+ // src/name.ts
6
+ var NAME_RE = /^(?:@([a-z0-9][a-z0-9._-]*)\/)?([a-z0-9][a-z0-9._-]*)$/;
7
+ function parseName(raw) {
8
+ const trimmed = raw.trim();
9
+ if (trimmed.length === 0)
10
+ return null;
11
+ if (trimmed.length > 214)
12
+ return null;
13
+ const match = NAME_RE.exec(trimmed);
14
+ if (!match)
15
+ return null;
16
+ const scope = match[1] ?? null;
17
+ const local = match[2];
18
+ if (scope !== null && scope.length > 214)
19
+ return null;
20
+ if (local.length > 214)
21
+ return null;
22
+ if (trimmed.toLowerCase() === "node_modules" || trimmed.toLowerCase() === "favicon.ico")
23
+ return null;
24
+ return { raw: trimmed, scope, local };
25
+ }
26
+ function isValidName(raw) {
27
+ return parseName(raw) !== null;
28
+ }
29
+ var RESERVED_WORDS = new Set([
30
+ "abstract",
31
+ "await",
32
+ "boolean",
33
+ "break",
34
+ "byte",
35
+ "case",
36
+ "catch",
37
+ "char",
38
+ "class",
39
+ "const",
40
+ "continue",
41
+ "debugger",
42
+ "default",
43
+ "delete",
44
+ "do",
45
+ "double",
46
+ "else",
47
+ "enum",
48
+ "export",
49
+ "extends",
50
+ "false",
51
+ "final",
52
+ "finally",
53
+ "float",
54
+ "for",
55
+ "function",
56
+ "goto",
57
+ "if",
58
+ "implements",
59
+ "import",
60
+ "in",
61
+ "instanceof",
62
+ "int",
63
+ "interface",
64
+ "let",
65
+ "long",
66
+ "native",
67
+ "new",
68
+ "null",
69
+ "package",
70
+ "private",
71
+ "protected",
72
+ "public",
73
+ "return",
74
+ "short",
75
+ "static",
76
+ "super",
77
+ "switch",
78
+ "synchronized",
79
+ "this",
80
+ "throw",
81
+ "throws",
82
+ "transient",
83
+ "true",
84
+ "try",
85
+ "typeof",
86
+ "var",
87
+ "void",
88
+ "volatile",
89
+ "while",
90
+ "with",
91
+ "yield"
92
+ ]);
93
+ function toPascalCase(input) {
94
+ const cleaned = input.replace(/[^a-zA-Z0-9]+/g, " ").trim();
95
+ if (cleaned.length === 0)
96
+ return "Plugin";
97
+ const parts = cleaned.split(/\s+/).filter(Boolean);
98
+ const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
99
+ if (RESERVED_WORDS.has(pascal.toLowerCase()))
100
+ return `${pascal}Plugin`;
101
+ if (/^[0-9]/.test(pascal))
102
+ return `Plugin${pascal}`;
103
+ return pascal;
104
+ }
105
+ function deriveExportName(raw, opts = {}) {
106
+ const parsed = parseName(raw);
107
+ if (!parsed)
108
+ return "MyPlugin";
109
+ const source = opts.stripScope ? parsed.local : parsed.scope ? `${parsed.scope}-${parsed.local}` : parsed.local;
110
+ return `${toPascalCase(source)}Plugin`;
111
+ }
112
+ function derivePluginExportName(raw) {
113
+ const parsed = parseName(raw);
114
+ if (!parsed)
115
+ return "MyPlugin";
116
+ return `${toPascalCase(parsed.local)}Plugin`;
117
+ }
118
+
119
+ // src/template-files.ts
120
+ import { readdirSync, readFileSync, statSync } from "node:fs";
121
+ import { join, relative, sep } from "node:path";
122
+ var PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
123
+ function render(content, vars) {
124
+ return content.replace(PLACEHOLDER_RE, (match, key) => {
125
+ if (key === "name")
126
+ return vars.name;
127
+ if (key === "exportName")
128
+ return vars.exportName;
129
+ if (key === "description")
130
+ return vars.description;
131
+ if (key === "year")
132
+ return vars.year;
133
+ if (key === "repository")
134
+ return vars.repository ?? "";
135
+ if (key === "authorName")
136
+ return vars.authorName ?? "";
137
+ return match;
138
+ });
139
+ }
140
+ function listTemplateFiles(templateDir) {
141
+ const results = [];
142
+ const walk = (dir) => {
143
+ for (const entry of readdirSync(dir)) {
144
+ const full = join(dir, entry);
145
+ const st = statSync(full);
146
+ if (st.isDirectory()) {
147
+ walk(full);
148
+ continue;
149
+ }
150
+ const rel = relative(templateDir, full).split(sep).join("/");
151
+ const content = readFileSync(full, "utf-8");
152
+ const stripped = rel.endsWith(".tpl") ? rel.slice(0, -".tpl".length) : rel;
153
+ const target = stripped === "gitignore" ? ".gitignore" : stripped;
154
+ results.push({ relative: target, content });
155
+ }
156
+ };
157
+ walk(templateDir);
158
+ return results.sort((a, b) => a.relative.localeCompare(b.relative));
159
+ }
160
+ function defaultTemplateDir(rootDir) {
161
+ return join(rootDir, "templates", "default");
162
+ }
163
+
164
+ // src/scaffold.ts
165
+ class ScaffoldError extends Error {
166
+ }
167
+ var IGNORED_EXISTING = new Set([".git", ".DS_Store"]);
168
+ function dirIsEmptyOrVcsOnly(dir) {
169
+ if (!existsSync(dir))
170
+ return true;
171
+ const entries = readdirSync2(dir).filter((e) => !IGNORED_EXISTING.has(e));
172
+ return entries.length === 0;
173
+ }
174
+ function resolveDirectory(opts, parsed) {
175
+ if (opts.directory && opts.directory.trim().length > 0) {
176
+ return resolve(opts.directory);
177
+ }
178
+ return resolve(parsed.local);
179
+ }
180
+ function scaffold(opts, templateRootDir, now = new Date) {
181
+ const parsed = parseName(opts.name);
182
+ if (!parsed) {
183
+ throw new ScaffoldError(`Invalid package name: "${opts.name}". Use a valid npm name like "opencode-foo" or "@scope/opencode-foo".`);
184
+ }
185
+ const directory = resolveDirectory(opts, parsed);
186
+ if (existsSync(directory) && statSync2(directory).isFile()) {
187
+ throw new ScaffoldError(`Target path exists and is a file: ${directory}`);
188
+ }
189
+ if (existsSync(directory) && !statSync2(directory).isDirectory()) {
190
+ throw new ScaffoldError(`Target path is not a directory: ${directory}`);
191
+ }
192
+ if (!opts.force && existsSync(directory) && !dirIsEmptyOrVcsOnly(directory)) {
193
+ throw new ScaffoldError(`Target directory is not empty: ${directory}. Pass --force to overwrite, or choose an empty directory.`);
194
+ }
195
+ const exportName = derivePluginExportName(opts.name);
196
+ const description = (opts.description ?? "An opencode plugin").trim();
197
+ const year = String(now.getFullYear());
198
+ const vars = {
199
+ name: parsed.raw,
200
+ exportName,
201
+ description,
202
+ year,
203
+ authorName: opts.authorName ?? parsed.local
204
+ };
205
+ const templateDir = defaultTemplateDir(templateRootDir);
206
+ if (!existsSync(templateDir)) {
207
+ throw new ScaffoldError(`Template directory not found: ${templateDir}`);
208
+ }
209
+ const files = listTemplateFiles(templateDir);
210
+ const written = [];
211
+ for (const file of files) {
212
+ const target = join2(directory, file.relative);
213
+ mkdirSync(dirname(target), { recursive: true });
214
+ const body = render(file.content, vars);
215
+ writeFileSync(target, body);
216
+ written.push(file.relative);
217
+ }
218
+ return { directory, written, name: parsed.raw, exportName };
219
+ }
220
+ export {
221
+ toPascalCase,
222
+ scaffold,
223
+ resolveDirectory,
224
+ render,
225
+ parseName,
226
+ listTemplateFiles,
227
+ isValidName,
228
+ derivePluginExportName,
229
+ deriveExportName,
230
+ defaultTemplateDir,
231
+ ScaffoldError
232
+ };
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "@itzptk/ocps",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "opencode plugin starter — scaffold a new opencode plugin project via `npx ocps init`",
5
5
  "type": "module",
6
6
  "bin": {
7
- "ocps": "./src/cli.ts"
7
+ "ocps": "./dist/cli.js"
8
8
  },
9
9
  "publishConfig": {
10
10
  "access": "public"
11
11
  },
12
- "exports": "./src/index.ts",
12
+ "exports": "./dist/index.js",
13
13
  "files": [
14
- "src",
14
+ "dist",
15
15
  "templates",
16
16
  "README.md"
17
17
  ],
18
18
  "scripts": {
19
+ "build": "bun build ./src/cli.ts ./src/index.ts --outdir ./dist --target node",
20
+ "prepublishOnly": "bun run build",
19
21
  "test": "bun test",
20
22
  "typecheck": "tsc -p tsconfig.json --noEmit"
21
23
  },
package/src/cli.ts DELETED
@@ -1,190 +0,0 @@
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 DELETED
@@ -1,10 +0,0 @@
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 DELETED
@@ -1,71 +0,0 @@
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 DELETED
@@ -1,94 +0,0 @@
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
- }
@@ -1,55 +0,0 @@
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
- }