tuncss-plan-kit 0.1.0 → 0.2.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/bin/cli.js CHANGED
@@ -1,426 +1,454 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from "fs";
4
- import path from "path";
5
- import os from "os";
6
- import { spawnSync } from "child_process";
7
- import { fileURLToPath } from "url";
8
-
9
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
- const PKG_ROOT = path.resolve(__dirname, "..");
11
- const PKG_NAME = "tuncss-plan-kit";
12
-
13
- const SKILLS = ["brainstorm", "plan-universal", "handoff-plan"];
14
- const COMMANDS = ["brainstorm", "plan-universal", "handoff-plan"];
15
- const MARKER_START = "<!-- tuncss-plan-kit:start -->";
16
- const MARKER_END = "<!-- tuncss-plan-kit:end -->";
17
-
18
- const PLATFORMS = {
19
- claude: {
20
- label: "claude",
21
- project: {
22
- skillsDir: ".claude/skills",
23
- commandsDir: null,
24
- instructionsFile: "CLAUDE.md",
25
- },
26
- global: (home) => ({
27
- skillsDir: path.join(home, ".claude", "skills"),
28
- commandsDir: null,
29
- instructionsFile: path.join(home, ".claude", "CLAUDE.md"),
30
- }),
31
- commandsSrc: null,
32
- skipCommands: true,
33
- },
34
- codex: {
35
- label: "codex",
36
- project: {
37
- skillsDir: ".agents/skills",
38
- commandsDir: ".codex/prompts",
39
- instructionsFile: "AGENTS.md",
40
- },
41
- global: (home) => ({
42
- skillsDir: path.join(home, ".agents", "skills"),
43
- commandsDir: path.join(home, ".codex", "prompts"),
44
- instructionsFile: path.join(home, ".codex", "AGENTS.md"),
45
- }),
46
- commandsSrc: "codex",
47
- },
48
- opencode: {
49
- label: "opencode",
50
- project: {
51
- skillsDir: ".opencode/skills",
52
- commandsDir: ".opencode/commands",
53
- instructionsFile: "AGENTS.md",
54
- },
55
- // Global install for OpenCode uses the npm-plugin route, not file-drop.
56
- // See installOpenCodeGlobal().
57
- global: null,
58
- commandsSrc: "opencode",
59
- },
60
- };
61
-
62
- const SUPPORTED = Object.keys(PLATFORMS);
63
-
64
- function parseArgs(argv) {
65
- const args = { command: null, target: null, global: false, force: false };
66
- for (const a of argv) {
67
- if (!args.command && !a.startsWith("--")) {
68
- args.command = a;
69
- } else if (a.startsWith("--target=")) {
70
- args.target = a
71
- .slice("--target=".length)
72
- .split(",")
73
- .map((s) => s.trim())
74
- .filter(Boolean);
75
- } else if (a === "--global") {
76
- args.global = true;
77
- } else if (a === "--force") {
78
- args.force = true;
79
- } else if (a === "--help" || a === "-h") {
80
- args.command = "help";
81
- }
82
- }
83
- return args;
84
- }
85
-
86
- function printHelp() {
87
- console.log(`tuncss-plan-kit
88
-
89
- Usage:
90
- npx tuncss-plan-kit init [--target=<list>] [--global] [--force]
91
-
92
- Commands:
93
- init Install the three skills (brainstorm, plan-universal, handoff-plan)
94
- into the target platform(s).
95
-
96
- Options:
97
- --target Comma-separated platforms to install for. Supported:
98
- claude, codex, opencode, all
99
- If omitted, auto-detects from the current directory.
100
- --global Install to user-wide locations.
101
- For OpenCode this uses the npm-plugin route (installs the package
102
- into ~/.config/opencode/node_modules/ and registers it in
103
- opencode.json's plugin array).
104
- --force Overwrite existing skill/command files without warning.
105
- (Instruction-file marker blocks are always idempotent.)
106
-
107
- Examples:
108
- npx tuncss-plan-kit init
109
- npx tuncss-plan-kit init --target=claude
110
- npx tuncss-plan-kit init --target=claude,codex
111
- npx tuncss-plan-kit init --target=all --global
112
- `);
113
- }
114
-
115
- function exists(p) {
116
- try {
117
- fs.accessSync(p);
118
- return true;
119
- } catch {
120
- return false;
121
- }
122
- }
123
-
124
- function detectTargets(cwd) {
125
- const found = new Set();
126
- if (exists(path.join(cwd, ".claude")) || exists(path.join(cwd, "CLAUDE.md"))) {
127
- found.add("claude");
128
- }
129
- if (exists(path.join(cwd, ".codex"))) found.add("codex");
130
- if (exists(path.join(cwd, ".opencode"))) found.add("opencode");
131
- if (exists(path.join(cwd, "AGENTS.md"))) {
132
- if (!found.has("codex") && !found.has("opencode")) {
133
- found.add("codex");
134
- found.add("opencode");
135
- }
136
- }
137
- return [...found];
138
- }
139
-
140
- function ensureDir(dir) {
141
- fs.mkdirSync(dir, { recursive: true });
142
- }
143
-
144
- function copyFile(src, dest, force) {
145
- if (exists(dest) && !force) {
146
- const a = fs.readFileSync(src, "utf8");
147
- const b = fs.readFileSync(dest, "utf8");
148
- if (a === b) return { status: "unchanged", dest };
149
- fs.writeFileSync(dest, a);
150
- return { status: "updated", dest };
151
- }
152
- ensureDir(path.dirname(dest));
153
- fs.writeFileSync(dest, fs.readFileSync(src, "utf8"));
154
- return { status: "written", dest };
155
- }
156
-
157
- function escapeRegex(s) {
158
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
- }
160
-
161
- function upsertMarkerBlock(filePath, blockBody) {
162
- const block = `${MARKER_START}\n${blockBody.trim()}\n${MARKER_END}`;
163
- let existing = "";
164
- if (exists(filePath)) existing = fs.readFileSync(filePath, "utf8");
165
- if (existing.includes(MARKER_START) && existing.includes(MARKER_END)) {
166
- const re = new RegExp(
167
- `${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}`,
168
- "m"
169
- );
170
- const next = existing.replace(re, block);
171
- if (next === existing) return { status: "unchanged" };
172
- ensureDir(path.dirname(filePath));
173
- fs.writeFileSync(filePath, next);
174
- return { status: "block-updated" };
175
- }
176
- const sep =
177
- existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
178
- ensureDir(path.dirname(filePath));
179
- fs.writeFileSync(filePath, existing + sep + block + "\n");
180
- return { status: existing.length === 0 ? "file-created" : "block-appended" };
181
- }
182
-
183
- function loadInstructionsBlock() {
184
- const tplPath = path.join(PKG_ROOT, "templates", "instructions-block.md");
185
- const tpl = fs.readFileSync(tplPath, "utf8");
186
- return tpl.replace(MARKER_START, "").replace(MARKER_END, "").trim();
187
- }
188
-
189
- function relPath(p, base) {
190
- const r = path.relative(base, p);
191
- return r.split(path.sep).join("/");
192
- }
193
-
194
- function statusTag(status) {
195
- if (status === "unchanged") return "·";
196
- if (status === "updated" || status === "block-updated") return "↻";
197
- return "✓";
198
- }
199
-
200
- function installPlatform({ platform, isGlobal, force, baseRoot, sharedState }) {
201
- const cfg = PLATFORMS[platform];
202
- const paths = isGlobal
203
- ? cfg.global(os.homedir())
204
- : {
205
- skillsDir: path.join(baseRoot, cfg.project.skillsDir),
206
- commandsDir: cfg.project.commandsDir
207
- ? path.join(baseRoot, cfg.project.commandsDir)
208
- : null,
209
- instructionsFile: path.join(baseRoot, cfg.project.instructionsFile),
210
- };
211
- const displayBase = isGlobal ? os.homedir() : baseRoot;
212
-
213
- const lines = [];
214
-
215
- for (const skill of SKILLS) {
216
- const src = path.join(PKG_ROOT, "skills", skill, "SKILL.md");
217
- const dest = path.join(paths.skillsDir, skill, "SKILL.md");
218
- const r = copyFile(src, dest, force);
219
- lines.push(` ${statusTag(r.status)} ${relPath(r.dest, displayBase)}`);
220
- }
221
-
222
- if (!cfg.skipCommands) {
223
- for (const cmd of COMMANDS) {
224
- const src = path.join(PKG_ROOT, "commands", cfg.commandsSrc, `${cmd}.md`);
225
- const dest = path.join(paths.commandsDir, `${cmd}.md`);
226
- const r = copyFile(src, dest, force);
227
- lines.push(` ${statusTag(r.status)} ${relPath(r.dest, displayBase)}`);
228
- }
229
- } else {
230
- lines.push(
231
- ` · (skill names auto-expose as slash commands; no wrapper files needed)`
232
- );
233
- }
234
-
235
- if (sharedState.writtenInstructionFiles.has(paths.instructionsFile)) {
236
- lines.push(
237
- ` · ${relPath(paths.instructionsFile, displayBase)} (already updated this run)`
238
- );
239
- } else {
240
- const r = upsertMarkerBlock(
241
- paths.instructionsFile,
242
- sharedState.instructionsBlock
243
- );
244
- sharedState.writtenInstructionFiles.add(paths.instructionsFile);
245
- lines.push(
246
- ` ${statusTag(r.status)} ${relPath(paths.instructionsFile, displayBase)} (${r.status})`
247
- );
248
- }
249
-
250
- return lines;
251
- }
252
-
253
- function readJsonOrDefault(filePath, fallback) {
254
- if (!exists(filePath)) return fallback;
255
- try {
256
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
257
- } catch {
258
- return fallback;
259
- }
260
- }
261
-
262
- function writeJson(filePath, obj) {
263
- ensureDir(path.dirname(filePath));
264
- fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + "\n");
265
- }
266
-
267
- function isRunningFromInstalledPackage() {
268
- // If our package root contains /node_modules/, we were installed as a dep
269
- // (e.g. via `npm install -g` or `npx`). Otherwise we're being run from
270
- // source (dev mode).
271
- return PKG_ROOT.split(path.sep).includes("node_modules");
272
- }
273
-
274
- function dependencySpec() {
275
- // When running from a published install, use a version range so npm
276
- // installs from the registry. When running from source (dev), point to
277
- // our absolute path with file: so changes are picked up without publish.
278
- if (isRunningFromInstalledPackage()) {
279
- return `^${readJsonOrDefault(path.join(PKG_ROOT, "package.json"), { version: "0.1.0" }).version || "0.1.0"}`;
280
- }
281
- return "file:" + PKG_ROOT.split(path.sep).join("/");
282
- }
283
-
284
- function installOpenCodeGlobal() {
285
- const home = os.homedir();
286
- const ocDir = path.join(home, ".config", "opencode");
287
- ensureDir(ocDir);
288
- const lines = [];
289
-
290
- // 1. package.json — add or update tuncss-plan-kit dependency
291
- const pkgPath = path.join(ocDir, "package.json");
292
- const pkg = readJsonOrDefault(pkgPath, {});
293
- pkg.dependencies = pkg.dependencies || {};
294
- const spec = dependencySpec();
295
- const prevSpec = pkg.dependencies[PKG_NAME];
296
- pkg.dependencies[PKG_NAME] = spec;
297
- writeJson(pkgPath, pkg);
298
- lines.push(
299
- ` ${prevSpec === spec ? "·" : prevSpec ? "↻" : "✓"} ${relPath(pkgPath, home)} (dep: ${PKG_NAME}@${spec})`
300
- );
301
-
302
- // 2. npm install in ocDir
303
- const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
304
- const npmRes = spawnSync(npmCmd, ["install", "--silent", "--no-audit", "--no-fund"], {
305
- cwd: ocDir,
306
- stdio: "inherit",
307
- shell: process.platform === "win32",
308
- });
309
- if (npmRes.status !== 0) {
310
- throw new Error(
311
- `npm install failed in ${ocDir} (exit code ${npmRes.status}). Fix the error above and re-run.`
312
- );
313
- }
314
- lines.push(` ✓ npm install ran in ${relPath(ocDir, home)}`);
315
-
316
- // 3. command wrappers — file-drop into ~/.config/opencode/commands/
317
- // (the plugin handles skills via config injection, but command discovery
318
- // appears to require file-drop)
319
- const cmdDir = path.join(ocDir, "commands");
320
- for (const cmd of COMMANDS) {
321
- const src = path.join(PKG_ROOT, "commands", "opencode", `${cmd}.md`);
322
- const dest = path.join(cmdDir, `${cmd}.md`);
323
- const r = copyFile(src, dest, false);
324
- lines.push(` ${statusTag(r.status)} ${relPath(r.dest, home)}`);
325
- }
326
-
327
- // 4. opencode.json — add plugin reference (idempotent)
328
- const ocJsonPath = path.join(ocDir, "opencode.json");
329
- const ocJson = readJsonOrDefault(ocJsonPath, {
330
- $schema: "https://opencode.ai/config.json",
331
- });
332
- ocJson.plugin = ocJson.plugin || [];
333
- // Normalize to strings only (we don't use the [string, object] tuple form)
334
- const pluginPath = `~/.config/opencode/node_modules/${PKG_NAME}`;
335
- const alreadyHas = ocJson.plugin.some(
336
- (p) => p === pluginPath || (Array.isArray(p) && p[0] === pluginPath)
337
- );
338
- if (!alreadyHas) {
339
- ocJson.plugin.push(pluginPath);
340
- }
341
- writeJson(ocJsonPath, ocJson);
342
- lines.push(
343
- ` ${alreadyHas ? "·" : "✓"} ${relPath(ocJsonPath, home)} (plugin: ${pluginPath})`
344
- );
345
-
346
- return lines;
347
- }
348
-
349
- function init(args) {
350
- const cwd = process.cwd();
351
-
352
- let targets = args.target;
353
- if (targets && targets.includes("all")) {
354
- targets = SUPPORTED.slice();
355
- }
356
- if (!targets) {
357
- targets = detectTargets(cwd);
358
- if (targets.length === 0) {
359
- console.error(
360
- "No supported platform detected in this directory.\n" +
361
- "Specify one explicitly, e.g.: npx tuncss-plan-kit init --target=claude\n" +
362
- "Or install for all three: npx tuncss-plan-kit init --target=all"
363
- );
364
- process.exit(1);
365
- }
366
- }
367
-
368
- const unsupported = targets.filter((t) => !SUPPORTED.includes(t));
369
- if (unsupported.length > 0) {
370
- console.error(
371
- `Unsupported target(s): ${unsupported.join(", ")}.\n` +
372
- `Supported: ${SUPPORTED.join(", ")}, all.`
373
- );
374
- process.exit(1);
375
- }
376
-
377
- targets = SUPPORTED.filter((p) => targets.includes(p));
378
-
379
- console.log(`tuncss-plan-kit init`);
380
- console.log(` scope: ${args.global ? "global (user-wide)" : "project (./)"}`);
381
- console.log(` target: ${targets.join(", ")}`);
382
- console.log("");
383
-
384
- const sharedState = {
385
- instructionsBlock: loadInstructionsBlock(),
386
- writtenInstructionFiles: new Set(),
387
- };
388
-
389
- for (const platform of targets) {
390
- console.log(`[${platform}]`);
391
- if (platform === "opencode" && args.global) {
392
- const lines = installOpenCodeGlobal();
393
- for (const l of lines) console.log(l);
394
- } else {
395
- const lines = installPlatform({
396
- platform,
397
- isGlobal: args.global,
398
- force: args.force,
399
- baseRoot: cwd,
400
- sharedState,
401
- });
402
- for (const l of lines) console.log(l);
403
- }
404
- console.log("");
405
- }
406
-
407
- console.log(
408
- "Done. Restart your coding agent(s) to pick up new skills and slash commands."
409
- );
410
- }
411
-
412
- function main() {
413
- const args = parseArgs(process.argv.slice(2));
414
- if (!args.command || args.command === "help") {
415
- printHelp();
416
- process.exit(args.command ? 0 : 1);
417
- }
418
- if (args.command === "init") {
419
- return init(args);
420
- }
421
- console.error(`Unknown command: ${args.command}`);
422
- printHelp();
423
- process.exit(1);
424
- }
425
-
426
- main();
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import os from "os";
6
+ import { spawnSync } from "child_process";
7
+ import { fileURLToPath } from "url";
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const PKG_ROOT = path.resolve(__dirname, "..");
11
+ const PKG_NAME = "tuncss-plan-kit";
12
+
13
+ const SKILLS = ["brainstorm", "plan-universal", "handoff-plan", "changelog"];
14
+ const COMMANDS = ["brainstorm", "plan-universal", "handoff-plan", "changelog"];
15
+ const MARKER_START = "<!-- tuncss-plan-kit:start -->";
16
+ const MARKER_END = "<!-- tuncss-plan-kit:end -->";
17
+
18
+ const PLATFORMS = {
19
+ claude: {
20
+ label: "claude",
21
+ project: {
22
+ skillsDir: ".claude/skills",
23
+ commandsDir: null,
24
+ instructionsFile: "CLAUDE.md",
25
+ },
26
+ global: (home) => ({
27
+ skillsDir: path.join(home, ".claude", "skills"),
28
+ commandsDir: null,
29
+ instructionsFile: path.join(home, ".claude", "CLAUDE.md"),
30
+ }),
31
+ commandsSrc: null,
32
+ skipCommands: true,
33
+ },
34
+ codex: {
35
+ label: "codex",
36
+ project: {
37
+ skillsDir: ".agents/skills",
38
+ commandsDir: ".codex/prompts",
39
+ instructionsFile: "AGENTS.md",
40
+ },
41
+ global: (home) => ({
42
+ skillsDir: path.join(home, ".agents", "skills"),
43
+ commandsDir: path.join(home, ".codex", "prompts"),
44
+ instructionsFile: path.join(home, ".codex", "AGENTS.md"),
45
+ }),
46
+ commandsSrc: "codex",
47
+ },
48
+ opencode: {
49
+ label: "opencode",
50
+ project: {
51
+ skillsDir: ".opencode/skills",
52
+ commandsDir: ".opencode/commands",
53
+ instructionsFile: "AGENTS.md",
54
+ },
55
+ // Global install for OpenCode uses the npm-plugin route, not file-drop.
56
+ // See installOpenCodeGlobal().
57
+ global: null,
58
+ commandsSrc: "opencode",
59
+ },
60
+ };
61
+
62
+ const SUPPORTED = Object.keys(PLATFORMS);
63
+
64
+ function parseArgs(argv) {
65
+ const args = { command: null, target: null, global: false, force: false };
66
+ for (const a of argv) {
67
+ if (!args.command && !a.startsWith("--")) {
68
+ args.command = a;
69
+ } else if (a.startsWith("--target=")) {
70
+ args.target = a
71
+ .slice("--target=".length)
72
+ .split(",")
73
+ .map((s) => s.trim())
74
+ .filter(Boolean);
75
+ } else if (a === "--global") {
76
+ args.global = true;
77
+ } else if (a === "--force") {
78
+ args.force = true;
79
+ } else if (a === "--help" || a === "-h") {
80
+ args.command = "help";
81
+ }
82
+ }
83
+ return args;
84
+ }
85
+
86
+ function printHelp() {
87
+ console.log(`tuncss-plan-kit
88
+
89
+ Usage:
90
+ npx tuncss-plan-kit init [--target=<list>] [--global] [--force]
91
+
92
+ Commands:
93
+ init Install the three skills (brainstorm, plan-universal, handoff-plan)
94
+ into the target platform(s).
95
+
96
+ Options:
97
+ --target Comma-separated platforms to install for. Supported:
98
+ claude, codex, opencode, all
99
+ If omitted, auto-detects from the current directory.
100
+ --global Install to user-wide locations.
101
+ For OpenCode this uses the npm-plugin route (installs the package
102
+ into ~/.config/opencode/node_modules/ and registers it in
103
+ opencode.json's plugin array).
104
+ --force Overwrite existing skill/command files without warning.
105
+ (Instruction-file marker blocks are always idempotent.)
106
+
107
+ Examples:
108
+ npx tuncss-plan-kit init
109
+ npx tuncss-plan-kit init --target=claude
110
+ npx tuncss-plan-kit init --target=claude,codex
111
+ npx tuncss-plan-kit init --target=all --global
112
+ `);
113
+ }
114
+
115
+ function exists(p) {
116
+ try {
117
+ fs.accessSync(p);
118
+ return true;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ function detectTargets(cwd) {
125
+ const found = new Set();
126
+ if (exists(path.join(cwd, ".claude")) || exists(path.join(cwd, "CLAUDE.md"))) {
127
+ found.add("claude");
128
+ }
129
+ if (exists(path.join(cwd, ".codex"))) found.add("codex");
130
+ if (exists(path.join(cwd, ".opencode"))) found.add("opencode");
131
+ if (exists(path.join(cwd, "AGENTS.md"))) {
132
+ if (!found.has("codex") && !found.has("opencode")) {
133
+ found.add("codex");
134
+ found.add("opencode");
135
+ }
136
+ }
137
+ return [...found];
138
+ }
139
+
140
+ function ensureDir(dir) {
141
+ fs.mkdirSync(dir, { recursive: true });
142
+ }
143
+
144
+ function copyFile(src, dest, force) {
145
+ if (exists(dest) && !force) {
146
+ const a = fs.readFileSync(src, "utf8");
147
+ const b = fs.readFileSync(dest, "utf8");
148
+ if (a === b) return { status: "unchanged", dest };
149
+ fs.writeFileSync(dest, a);
150
+ return { status: "updated", dest };
151
+ }
152
+ ensureDir(path.dirname(dest));
153
+ fs.writeFileSync(dest, fs.readFileSync(src, "utf8"));
154
+ return { status: "written", dest };
155
+ }
156
+
157
+ function escapeRegex(s) {
158
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
+ }
160
+
161
+ function upsertMarkerBlock(filePath, blockBody) {
162
+ const block = `${MARKER_START}\n${blockBody.trim()}\n${MARKER_END}`;
163
+ let existing = "";
164
+ if (exists(filePath)) existing = fs.readFileSync(filePath, "utf8");
165
+ if (existing.includes(MARKER_START) && existing.includes(MARKER_END)) {
166
+ const re = new RegExp(
167
+ `${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}`,
168
+ "m"
169
+ );
170
+ const next = existing.replace(re, block);
171
+ if (next === existing) return { status: "unchanged" };
172
+ ensureDir(path.dirname(filePath));
173
+ fs.writeFileSync(filePath, next);
174
+ return { status: "block-updated" };
175
+ }
176
+ const sep =
177
+ existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
178
+ ensureDir(path.dirname(filePath));
179
+ fs.writeFileSync(filePath, existing + sep + block + "\n");
180
+ return { status: existing.length === 0 ? "file-created" : "block-appended" };
181
+ }
182
+
183
+ const GITATTRIBUTES_LINE = "docs/CHANGELOG.md merge=union";
184
+
185
+ // Three people appending changelog entries on separate branches all touch the
186
+ // same lines of docs/CHANGELOG.md. The union merge driver keeps both sides
187
+ // instead of raising a conflict on every merge.
188
+ function ensureGitattributes(baseRoot) {
189
+ const filePath = path.join(baseRoot, ".gitattributes");
190
+ let existing = "";
191
+ if (exists(filePath)) existing = fs.readFileSync(filePath, "utf8");
192
+ if (existing.split(/\r?\n/).some((l) => l.trim() === GITATTRIBUTES_LINE)) {
193
+ return { status: "unchanged", dest: filePath };
194
+ }
195
+ const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
196
+ ensureDir(path.dirname(filePath));
197
+ fs.writeFileSync(filePath, existing + sep + GITATTRIBUTES_LINE + "\n");
198
+ return { status: existing.length === 0 ? "written" : "updated", dest: filePath };
199
+ }
200
+
201
+ function loadInstructionsBlock() {
202
+ const tplPath = path.join(PKG_ROOT, "templates", "instructions-block.md");
203
+ const tpl = fs.readFileSync(tplPath, "utf8");
204
+ return tpl.replace(MARKER_START, "").replace(MARKER_END, "").trim();
205
+ }
206
+
207
+ function relPath(p, base) {
208
+ const r = path.relative(base, p);
209
+ return r.split(path.sep).join("/");
210
+ }
211
+
212
+ function statusTag(status) {
213
+ if (status === "unchanged") return "·";
214
+ if (status === "updated" || status === "block-updated") return "↻";
215
+ return "✓";
216
+ }
217
+
218
+ function installPlatform({ platform, isGlobal, force, baseRoot, sharedState }) {
219
+ const cfg = PLATFORMS[platform];
220
+ const paths = isGlobal
221
+ ? cfg.global(os.homedir())
222
+ : {
223
+ skillsDir: path.join(baseRoot, cfg.project.skillsDir),
224
+ commandsDir: cfg.project.commandsDir
225
+ ? path.join(baseRoot, cfg.project.commandsDir)
226
+ : null,
227
+ instructionsFile: path.join(baseRoot, cfg.project.instructionsFile),
228
+ };
229
+ const displayBase = isGlobal ? os.homedir() : baseRoot;
230
+
231
+ const lines = [];
232
+
233
+ for (const skill of SKILLS) {
234
+ const src = path.join(PKG_ROOT, "skills", skill, "SKILL.md");
235
+ const dest = path.join(paths.skillsDir, skill, "SKILL.md");
236
+ const r = copyFile(src, dest, force);
237
+ lines.push(` ${statusTag(r.status)} ${relPath(r.dest, displayBase)}`);
238
+ }
239
+
240
+ if (!cfg.skipCommands) {
241
+ for (const cmd of COMMANDS) {
242
+ const src = path.join(PKG_ROOT, "commands", cfg.commandsSrc, `${cmd}.md`);
243
+ const dest = path.join(paths.commandsDir, `${cmd}.md`);
244
+ const r = copyFile(src, dest, force);
245
+ lines.push(` ${statusTag(r.status)} ${relPath(r.dest, displayBase)}`);
246
+ }
247
+ } else {
248
+ lines.push(
249
+ ` · (skill names auto-expose as slash commands; no wrapper files needed)`
250
+ );
251
+ }
252
+
253
+ if (sharedState.writtenInstructionFiles.has(paths.instructionsFile)) {
254
+ lines.push(
255
+ ` · ${relPath(paths.instructionsFile, displayBase)} (already updated this run)`
256
+ );
257
+ } else {
258
+ const r = upsertMarkerBlock(
259
+ paths.instructionsFile,
260
+ sharedState.instructionsBlock
261
+ );
262
+ sharedState.writtenInstructionFiles.add(paths.instructionsFile);
263
+ lines.push(
264
+ ` ${statusTag(r.status)} ${relPath(paths.instructionsFile, displayBase)} (${r.status})`
265
+ );
266
+ }
267
+
268
+ return lines;
269
+ }
270
+
271
+ function readJsonOrDefault(filePath, fallback) {
272
+ if (!exists(filePath)) return fallback;
273
+ try {
274
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
275
+ } catch {
276
+ return fallback;
277
+ }
278
+ }
279
+
280
+ function writeJson(filePath, obj) {
281
+ ensureDir(path.dirname(filePath));
282
+ fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + "\n");
283
+ }
284
+
285
+ function isRunningFromInstalledPackage() {
286
+ // If our package root contains /node_modules/, we were installed as a dep
287
+ // (e.g. via `npm install -g` or `npx`). Otherwise we're being run from
288
+ // source (dev mode).
289
+ return PKG_ROOT.split(path.sep).includes("node_modules");
290
+ }
291
+
292
+ function dependencySpec() {
293
+ // When running from a published install, use a version range so npm
294
+ // installs from the registry. When running from source (dev), point to
295
+ // our absolute path with file: so changes are picked up without publish.
296
+ if (isRunningFromInstalledPackage()) {
297
+ return `^${readJsonOrDefault(path.join(PKG_ROOT, "package.json"), { version: "0.1.0" }).version || "0.1.0"}`;
298
+ }
299
+ return "file:" + PKG_ROOT.split(path.sep).join("/");
300
+ }
301
+
302
+ function installOpenCodeGlobal() {
303
+ const home = os.homedir();
304
+ const ocDir = path.join(home, ".config", "opencode");
305
+ ensureDir(ocDir);
306
+ const lines = [];
307
+
308
+ // 1. package.json — add or update tuncss-plan-kit dependency
309
+ const pkgPath = path.join(ocDir, "package.json");
310
+ const pkg = readJsonOrDefault(pkgPath, {});
311
+ pkg.dependencies = pkg.dependencies || {};
312
+ const spec = dependencySpec();
313
+ const prevSpec = pkg.dependencies[PKG_NAME];
314
+ pkg.dependencies[PKG_NAME] = spec;
315
+ writeJson(pkgPath, pkg);
316
+ lines.push(
317
+ ` ${prevSpec === spec ? "·" : prevSpec ? "↻" : "✓"} ${relPath(pkgPath, home)} (dep: ${PKG_NAME}@${spec})`
318
+ );
319
+
320
+ // 2. npm install in ocDir
321
+ // Pass the full command as a single string with shell: true to avoid the
322
+ // Node DEP0190 warning about unescaped args concatenation. Args are all
323
+ // hardcoded here so there's no injection surface.
324
+ const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
325
+ const npmRes = spawnSync(`${npmBin} install --silent --no-audit --no-fund`, {
326
+ cwd: ocDir,
327
+ stdio: "inherit",
328
+ shell: true,
329
+ });
330
+ if (npmRes.status !== 0) {
331
+ throw new Error(
332
+ `npm install failed in ${ocDir} (exit code ${npmRes.status}). Fix the error above and re-run.`
333
+ );
334
+ }
335
+ lines.push(` ✓ npm install ran in ${relPath(ocDir, home)}`);
336
+
337
+ // 3. command wrappers — file-drop into ~/.config/opencode/commands/
338
+ // (the plugin handles skills via config injection, but command discovery
339
+ // appears to require file-drop)
340
+ const cmdDir = path.join(ocDir, "commands");
341
+ for (const cmd of COMMANDS) {
342
+ const src = path.join(PKG_ROOT, "commands", "opencode", `${cmd}.md`);
343
+ const dest = path.join(cmdDir, `${cmd}.md`);
344
+ const r = copyFile(src, dest, false);
345
+ lines.push(` ${statusTag(r.status)} ${relPath(r.dest, home)}`);
346
+ }
347
+
348
+ // 4. opencode.json — add plugin reference (idempotent)
349
+ const ocJsonPath = path.join(ocDir, "opencode.json");
350
+ const ocJson = readJsonOrDefault(ocJsonPath, {
351
+ $schema: "https://opencode.ai/config.json",
352
+ });
353
+ ocJson.plugin = ocJson.plugin || [];
354
+ // Normalize to strings only (we don't use the [string, object] tuple form)
355
+ const pluginPath = `~/.config/opencode/node_modules/${PKG_NAME}`;
356
+ const alreadyHas = ocJson.plugin.some(
357
+ (p) => p === pluginPath || (Array.isArray(p) && p[0] === pluginPath)
358
+ );
359
+ if (!alreadyHas) {
360
+ ocJson.plugin.push(pluginPath);
361
+ }
362
+ writeJson(ocJsonPath, ocJson);
363
+ lines.push(
364
+ ` ${alreadyHas ? "·" : "✓"} ${relPath(ocJsonPath, home)} (plugin: ${pluginPath})`
365
+ );
366
+
367
+ return lines;
368
+ }
369
+
370
+ function init(args) {
371
+ const cwd = process.cwd();
372
+
373
+ let targets = args.target;
374
+ if (targets && targets.includes("all")) {
375
+ targets = SUPPORTED.slice();
376
+ }
377
+ if (!targets) {
378
+ targets = detectTargets(cwd);
379
+ if (targets.length === 0) {
380
+ console.error(
381
+ "No supported platform detected in this directory.\n" +
382
+ "Specify one explicitly, e.g.: npx tuncss-plan-kit init --target=claude\n" +
383
+ "Or install for all three: npx tuncss-plan-kit init --target=all"
384
+ );
385
+ process.exit(1);
386
+ }
387
+ }
388
+
389
+ const unsupported = targets.filter((t) => !SUPPORTED.includes(t));
390
+ if (unsupported.length > 0) {
391
+ console.error(
392
+ `Unsupported target(s): ${unsupported.join(", ")}.\n` +
393
+ `Supported: ${SUPPORTED.join(", ")}, all.`
394
+ );
395
+ process.exit(1);
396
+ }
397
+
398
+ targets = SUPPORTED.filter((p) => targets.includes(p));
399
+
400
+ console.log(`tuncss-plan-kit init`);
401
+ console.log(` scope: ${args.global ? "global (user-wide)" : "project (./)"}`);
402
+ console.log(` target: ${targets.join(", ")}`);
403
+ console.log("");
404
+
405
+ const sharedState = {
406
+ instructionsBlock: loadInstructionsBlock(),
407
+ writtenInstructionFiles: new Set(),
408
+ };
409
+
410
+ for (const platform of targets) {
411
+ console.log(`[${platform}]`);
412
+ if (platform === "opencode" && args.global) {
413
+ const lines = installOpenCodeGlobal();
414
+ for (const l of lines) console.log(l);
415
+ } else {
416
+ const lines = installPlatform({
417
+ platform,
418
+ isGlobal: args.global,
419
+ force: args.force,
420
+ baseRoot: cwd,
421
+ sharedState,
422
+ });
423
+ for (const l of lines) console.log(l);
424
+ }
425
+ console.log("");
426
+ }
427
+
428
+ if (!args.global) {
429
+ const r = ensureGitattributes(cwd);
430
+ console.log(`[git]`);
431
+ console.log(` ${statusTag(r.status)} .gitattributes (${GITATTRIBUTES_LINE})`);
432
+ console.log("");
433
+ }
434
+
435
+ console.log(
436
+ "Done. Restart your coding agent(s) to pick up new skills and slash commands."
437
+ );
438
+ }
439
+
440
+ function main() {
441
+ const args = parseArgs(process.argv.slice(2));
442
+ if (!args.command || args.command === "help") {
443
+ printHelp();
444
+ process.exit(args.command ? 0 : 1);
445
+ }
446
+ if (args.command === "init") {
447
+ return init(args);
448
+ }
449
+ console.error(`Unknown command: ${args.command}`);
450
+ printHelp();
451
+ process.exit(1);
452
+ }
453
+
454
+ main();