@opellen/scaff 0.1.15 → 0.1.16

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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +2 -0
  3. package/README.md +2 -0
  4. package/dist/agents-partial.d.ts +2 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +235 -0
  7. package/dist/extend.d.ts +12 -0
  8. package/dist/extend.js +242 -0
  9. package/dist/hook-parser.d.ts +15 -0
  10. package/dist/hook-parser.js +95 -0
  11. package/dist/i18n/index.d.ts +5 -0
  12. package/dist/i18n/index.js +20 -0
  13. package/dist/i18n/messages.d.ts +28 -0
  14. package/dist/i18n/messages.js +49 -0
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +4 -0
  17. package/dist/installer.d.ts +10 -0
  18. package/dist/installer.js +74 -0
  19. package/dist/marker.d.ts +11 -0
  20. package/dist/marker.js +25 -0
  21. package/dist/platforms/amazon-q.d.ts +2 -0
  22. package/dist/platforms/amazon-q.js +12 -0
  23. package/dist/platforms/antigravity.d.ts +2 -0
  24. package/dist/platforms/antigravity.js +12 -0
  25. package/dist/platforms/auggie.d.ts +2 -0
  26. package/dist/platforms/auggie.js +15 -0
  27. package/dist/platforms/claude.d.ts +2 -0
  28. package/dist/platforms/claude.js +7 -0
  29. package/dist/platforms/cline.d.ts +2 -0
  30. package/dist/platforms/cline.js +11 -0
  31. package/dist/platforms/codebuddy.d.ts +2 -0
  32. package/dist/platforms/codebuddy.js +16 -0
  33. package/dist/platforms/codex.d.ts +2 -0
  34. package/dist/platforms/codex.js +7 -0
  35. package/dist/platforms/continue.d.ts +2 -0
  36. package/dist/platforms/continue.js +16 -0
  37. package/dist/platforms/costrict.d.ts +2 -0
  38. package/dist/platforms/costrict.js +15 -0
  39. package/dist/platforms/crush.d.ts +2 -0
  40. package/dist/platforms/crush.js +19 -0
  41. package/dist/platforms/cursor.d.ts +2 -0
  42. package/dist/platforms/cursor.js +18 -0
  43. package/dist/platforms/factory.d.ts +2 -0
  44. package/dist/platforms/factory.js +15 -0
  45. package/dist/platforms/gemini.d.ts +2 -0
  46. package/dist/platforms/gemini.js +14 -0
  47. package/dist/platforms/github-copilot.d.ts +2 -0
  48. package/dist/platforms/github-copilot.js +12 -0
  49. package/dist/platforms/iflow.d.ts +2 -0
  50. package/dist/platforms/iflow.js +18 -0
  51. package/dist/platforms/index.d.ts +10 -0
  52. package/dist/platforms/index.js +68 -0
  53. package/dist/platforms/kilocode.d.ts +2 -0
  54. package/dist/platforms/kilocode.js +8 -0
  55. package/dist/platforms/kiro.d.ts +2 -0
  56. package/dist/platforms/kiro.js +12 -0
  57. package/dist/platforms/opencode.d.ts +2 -0
  58. package/dist/platforms/opencode.js +12 -0
  59. package/dist/platforms/pi.d.ts +2 -0
  60. package/dist/platforms/pi.js +12 -0
  61. package/dist/platforms/qoder.d.ts +2 -0
  62. package/dist/platforms/qoder.js +19 -0
  63. package/dist/platforms/qwen.d.ts +2 -0
  64. package/dist/platforms/qwen.js +14 -0
  65. package/dist/platforms/roocode.d.ts +2 -0
  66. package/dist/platforms/roocode.js +11 -0
  67. package/dist/platforms/toml.d.ts +9 -0
  68. package/dist/platforms/toml.js +18 -0
  69. package/dist/platforms/trae.d.ts +2 -0
  70. package/dist/platforms/trae.js +11 -0
  71. package/dist/platforms/types.d.ts +19 -0
  72. package/dist/platforms/types.js +1 -0
  73. package/dist/platforms/windsurf.d.ts +2 -0
  74. package/dist/platforms/windsurf.js +19 -0
  75. package/dist/platforms/yaml.d.ts +10 -0
  76. package/dist/platforms/yaml.js +30 -0
  77. package/dist/platforms.d.ts +20 -0
  78. package/dist/platforms.js +139 -0
  79. package/dist/prompts/platform-select.d.ts +1 -0
  80. package/dist/prompts/platform-select.js +174 -0
  81. package/dist/skills/generate.d.ts +13 -0
  82. package/dist/skills/generate.js +16 -0
  83. package/dist/templates.d.ts +18 -0
  84. package/dist/templates.js +96 -0
  85. package/dist/ui/ascii-logo.d.ts +6 -0
  86. package/dist/ui/ascii-logo.js +38 -0
  87. package/dist/ui/colors.d.ts +2 -0
  88. package/dist/ui/colors.js +3 -0
  89. package/dist/ui/welcome.d.ts +1 -0
  90. package/dist/ui/welcome.js +77 -0
  91. package/package.json +1 -1
  92. package/templates/commands/context.md +1 -0
  93. package/templates/commands/goal.md +3 -1
  94. package/templates/commands/scout.md +3 -1
  95. package/templates/skills/scaff-flow/SKILL.md +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 San
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.ko.md CHANGED
@@ -75,6 +75,7 @@ docs/
75
75
  ├── DESIGN.md ← 구현 설계 (필요 시)
76
76
  ├── PLAN.md ← 구현 계획 (복잡한 태스크에서 자동 생성)
77
77
  ├── ROADMAP.md ← 마일스톤 단위 전체 계획
78
+ ├── BACKLOG.md ← 범위 밖 발견 사항 보관
78
79
  ├── OVERVIEW.md ← 프로젝트 전체 조망 (필요 시)
79
80
  └── suspended/ ← 중단된 목표
80
81
  ```
@@ -87,6 +88,7 @@ docs/
87
88
  | `DESIGN.md` | 어떻게 구현하지? | 목표 달성까지 |
88
89
  | `PLAN.md` | 이 태스크를 어떻게 구현하지? | 자동 생성 → 자동 아카이브 |
89
90
  | `ROADMAP.md` | 큰 그림에서 어디에 있지? | 프로젝트 전체 |
91
+ | `BACKLOG.md` | 발견했지만 미뤄둔 건 뭐지? | 프로젝트 전체 |
90
92
  | `OVERVIEW.md` | 이 프로젝트 전체를 한눈에 보면? | 프로젝트 전체 |
91
93
 
92
94
  `GOAL.md` 하나로 시작해서 `ROADMAP.md`로 확장해도 되고, `ROADMAP.md`에서 큰 그림을 잡고 `GOAL.md`로 내려와도 됩니다. 어느 방향으로 가든 Scaff는 '지금 할 일'을 마크다운 위에 유지합니다.
package/README.md CHANGED
@@ -74,6 +74,7 @@ docs/
74
74
  ├── DESIGN.md ← Implementation design (as needed)
75
75
  ├── PLAN.md ← Implementation plan (auto-generated for complex tasks)
76
76
  ├── ROADMAP.md ← Milestone-based overall plan
77
+ ├── BACKLOG.md ← Deferred out-of-scope discoveries
77
78
  ├── OVERVIEW.md ← Full project overview (as needed)
78
79
  └── suspended/ ← Suspended goals
79
80
  ```
@@ -86,6 +87,7 @@ docs/
86
87
  | `DESIGN.md` | How do I implement this? | Until goal is achieved |
87
88
  | `PLAN.md` | How do I implement this task? | Auto-generated → auto-archived |
88
89
  | `ROADMAP.md` | Where am I in the big picture? | Project lifetime |
90
+ | `BACKLOG.md` | What did I discover but defer? | Project lifetime |
89
91
  | `OVERVIEW.md` | What does this whole project look like? | Project lifetime |
90
92
 
91
93
  Start with just `GOAL.md` and expand to `ROADMAP.md`, or lay out the big picture in `ROADMAP.md` and drill down to `GOAL.md`. Either way, Scaff keeps "what to do now" visible on markdown.
@@ -0,0 +1,2 @@
1
+ import type { ScaffConfig } from "./templates.js";
2
+ export declare function generateAgentsPartial(config: ScaffConfig): string;
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { install, dryRun } from "./installer.js";
5
+ import { PLATFORM_IDS, DEFAULT_PLATFORM, isValidPlatform, detectInstalledPlatforms } from "./platforms/index.js";
6
+ import { showWelcome } from "./ui/welcome.js";
7
+ import { selectPlatforms } from "./prompts/platform-select.js";
8
+ import { t } from "./i18n/index.js";
9
+ import chalk from "chalk";
10
+ import { brand } from "./ui/colors.js";
11
+ import { extend } from "./extend.js";
12
+ function printUsage() {
13
+ console.log(`
14
+ scaff — Lightweight context scaffolding for AI coding
15
+
16
+ Usage:
17
+ scaff init [options]
18
+ scaff extend <path> [--force] [--root <dir>]
19
+
20
+ Options:
21
+ --docs <dir> Documentation directory (default: docs)
22
+ --codebase <dir> Codebase directory (default: .)
23
+ --tools <list> Target platforms: comma-separated, or "all" (default: claude)
24
+ Available: ${PLATFORM_IDS.join(", ")}
25
+ --no-subagent Disable subagent delegation (enabled by default)
26
+ --root <dir> Target directory (default: cwd)
27
+ --force Overwrite existing files without prompting
28
+ -y, --yes Auto-confirm with detected platforms (skip interactive selection)
29
+ --dry-run Preview without writing files
30
+ -h, --help Show this help
31
+
32
+ Commands:
33
+ init Install scaff into the current project
34
+ extend <path> Install a local extension (reads <path>/scaff-extend.yml)
35
+ `);
36
+ }
37
+ function parseArgs(argv) {
38
+ let command = null;
39
+ const positional = [];
40
+ let docsDir = "docs";
41
+ let codebaseDir = ".";
42
+ let tools = null;
43
+ let subagent = true;
44
+ let root = process.cwd();
45
+ let force = false;
46
+ let yes = false;
47
+ let isDryRun = false;
48
+ let help = false;
49
+ for (let i = 0; i < argv.length; i++) {
50
+ const arg = argv[i];
51
+ if (arg === "-h" || arg === "--help") {
52
+ help = true;
53
+ }
54
+ else if (arg === "--docs") {
55
+ const next = argv[++i];
56
+ if (!next) {
57
+ console.error("Error: --docs requires a directory path");
58
+ process.exit(1);
59
+ }
60
+ docsDir = next;
61
+ }
62
+ else if (arg === "--codebase") {
63
+ const next = argv[++i];
64
+ if (!next) {
65
+ console.error("Error: --codebase requires a directory path");
66
+ process.exit(1);
67
+ }
68
+ codebaseDir = next;
69
+ }
70
+ else if (arg === "--tools") {
71
+ const next = argv[++i];
72
+ if (!next) {
73
+ console.error("Error: --tools requires a platform list");
74
+ process.exit(1);
75
+ }
76
+ if (next === "all") {
77
+ tools = [...PLATFORM_IDS];
78
+ }
79
+ else {
80
+ tools = next.split(",").map((id) => id.trim());
81
+ for (const id of tools) {
82
+ if (!isValidPlatform(id)) {
83
+ console.error(`Error: unknown platform "${id}". Available: ${PLATFORM_IDS.join(", ")}`);
84
+ process.exit(1);
85
+ }
86
+ }
87
+ }
88
+ }
89
+ else if (arg === "--no-subagent") {
90
+ subagent = false;
91
+ }
92
+ else if (arg === "--force") {
93
+ force = true;
94
+ }
95
+ else if (arg === "-y" || arg === "--yes") {
96
+ yes = true;
97
+ }
98
+ else if (arg === "--dry-run") {
99
+ isDryRun = true;
100
+ }
101
+ else if (arg === "--root") {
102
+ const next = argv[++i];
103
+ if (!next) {
104
+ console.error("Error: --root requires a directory path");
105
+ process.exit(1);
106
+ }
107
+ root = resolve(next);
108
+ }
109
+ else if (!arg.startsWith("-")) {
110
+ if (command === null) {
111
+ command = arg;
112
+ }
113
+ else {
114
+ positional.push(arg);
115
+ }
116
+ }
117
+ else {
118
+ console.error(`Unknown option: ${arg}`);
119
+ printUsage();
120
+ process.exit(1);
121
+ }
122
+ }
123
+ return { command, positional, docsDir, codebaseDir, tools, subagent, root, force, yes, dryRun: isDryRun, help };
124
+ }
125
+ async function runExtend(args) {
126
+ const extPath = args.positional[0];
127
+ if (!extPath) {
128
+ console.error("Error: scaff extend requires a path argument");
129
+ printUsage();
130
+ process.exit(1);
131
+ }
132
+ const resolvedExt = resolve(extPath);
133
+ if (!existsSync(resolvedExt)) {
134
+ console.error(`Error: extension path does not exist: ${resolvedExt}`);
135
+ process.exit(1);
136
+ }
137
+ if (!existsSync(args.root)) {
138
+ console.error(`Error: target directory does not exist: ${args.root}`);
139
+ process.exit(1);
140
+ }
141
+ try {
142
+ const result = await extend(resolvedExt, {
143
+ force: args.force,
144
+ root: args.root,
145
+ });
146
+ if (result.commandsAdded.length > 0) {
147
+ console.log(`${chalk.green("\u2713")} Commands added: ${result.commandsAdded.join(", ")}`);
148
+ }
149
+ if (result.commandsSkipped.length > 0) {
150
+ console.log(`${chalk.yellow("~")} Commands skipped: ${result.commandsSkipped.join(", ")}`);
151
+ }
152
+ if (result.hooksApplied.length > 0) {
153
+ console.log(`${chalk.green("\u2713")} Hooks applied: ${result.hooksApplied.join(", ")}`);
154
+ }
155
+ if (result.dependenciesChecked.length > 0) {
156
+ console.log(`${chalk.green("\u2713")} Dependencies: all found`);
157
+ }
158
+ process.exit(0);
159
+ }
160
+ catch (err) {
161
+ console.error(`Error: ${err.message}`);
162
+ process.exit(1);
163
+ }
164
+ }
165
+ async function main() {
166
+ const args = parseArgs(process.argv.slice(2));
167
+ if (args.help || !args.command) {
168
+ printUsage();
169
+ process.exit(args.help ? 0 : 1);
170
+ }
171
+ if (args.command === "extend") {
172
+ await runExtend(args);
173
+ return;
174
+ }
175
+ if (args.command !== "init") {
176
+ console.error(`Unknown command: ${args.command}`);
177
+ printUsage();
178
+ process.exit(1);
179
+ }
180
+ if (!existsSync(args.root)) {
181
+ console.error(`Error: target directory does not exist: ${args.root}`);
182
+ process.exit(1);
183
+ }
184
+ let tools;
185
+ if (args.tools) {
186
+ tools = args.tools;
187
+ }
188
+ else if (args.yes) {
189
+ const detected = detectInstalledPlatforms(args.root);
190
+ tools = detected.length > 0 ? detected : [DEFAULT_PLATFORM];
191
+ console.log(chalk.dim(`Auto-selected: ${tools.join(", ")}`));
192
+ }
193
+ else if (process.stdin.isTTY && !args.dryRun) {
194
+ await showWelcome();
195
+ tools = await selectPlatforms(args.root);
196
+ }
197
+ else {
198
+ tools = [DEFAULT_PLATFORM];
199
+ }
200
+ const config = {
201
+ tools,
202
+ subagent: args.subagent,
203
+ root: args.root,
204
+ docsDir: args.docsDir,
205
+ codebaseDir: args.codebaseDir,
206
+ force: args.force,
207
+ };
208
+ if (args.dryRun) {
209
+ const result = dryRun(config);
210
+ console.log(t("cli.dryRun") + "\n");
211
+ for (const file of result.files) {
212
+ console.log(` ${chalk.green("+")} ${file}`);
213
+ }
214
+ return;
215
+ }
216
+ const result = await install(config);
217
+ console.log(t("cli.complete") + "\n");
218
+ if (result.files.length > 0) {
219
+ console.log(t("cli.installed"));
220
+ for (const file of result.files) {
221
+ console.log(` ${chalk.green("+")} ${file}`);
222
+ }
223
+ }
224
+ if (result.skipped.length > 0) {
225
+ console.log("\n" + t("cli.skipped"));
226
+ for (const file of result.skipped) {
227
+ console.log(` ${chalk.yellow("~")} ${file}`);
228
+ }
229
+ }
230
+ console.log("\n" + brand(t("cli.nextSteps")));
231
+ console.log(" " + chalk.dim("1.") + " " + t("cli.step1"));
232
+ console.log(" " + chalk.dim("2.") + " " + t("cli.step2").replace("{cmd}", brand("/scaff:scout")));
233
+ process.exit(0);
234
+ }
235
+ main();
@@ -0,0 +1,12 @@
1
+ export interface ExtendOptions {
2
+ force: boolean;
3
+ root: string;
4
+ }
5
+ export type PromptFn = (question: string) => Promise<string>;
6
+ export interface ExtendResult {
7
+ commandsAdded: string[];
8
+ commandsSkipped: string[];
9
+ hooksApplied: string[];
10
+ dependenciesChecked: string[];
11
+ }
12
+ export declare function extend(extensionPath: string, opts: ExtendOptions, promptFn?: PromptFn): Promise<ExtendResult>;
package/dist/extend.js ADDED
@@ -0,0 +1,242 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, } from "node:fs";
2
+ import { join, dirname, isAbsolute, resolve } from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import { createInterface } from "node:readline";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { injectHooks } from "./hook-parser.js";
7
+ function createDefaultPrompt() {
8
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9
+ let closed = false;
10
+ rl.on("close", () => {
11
+ closed = true;
12
+ });
13
+ const prompt = (question) => {
14
+ if (closed)
15
+ return Promise.resolve("");
16
+ return new Promise((resolveP) => {
17
+ rl.question(question, (answer) => {
18
+ resolveP(answer.trim().toLowerCase());
19
+ });
20
+ rl.once("close", () => resolveP(""));
21
+ });
22
+ };
23
+ return { prompt, close: () => rl.close() };
24
+ }
25
+ function readManifest(extensionPath) {
26
+ const manifestPath = join(extensionPath, "scaff-extend.yml");
27
+ if (!existsSync(manifestPath)) {
28
+ throw new Error(`Missing scaff-extend.yml at ${manifestPath}`);
29
+ }
30
+ const raw = readFileSync(manifestPath, "utf-8");
31
+ let parsed;
32
+ try {
33
+ parsed = parseYaml(raw);
34
+ }
35
+ catch (err) {
36
+ throw new Error(`Failed to parse scaff-extend.yml: ${err.message}`);
37
+ }
38
+ if (!parsed || typeof parsed !== "object") {
39
+ throw new Error("scaff-extend.yml must be a YAML mapping");
40
+ }
41
+ return validateManifest(parsed);
42
+ }
43
+ function validateManifest(raw) {
44
+ if (typeof raw.name !== "string" || !raw.name) {
45
+ throw new Error("scaff-extend.yml: 'name' is required");
46
+ }
47
+ if (typeof raw.extends !== "string" || !raw.extends) {
48
+ throw new Error("scaff-extend.yml: 'extends' is required");
49
+ }
50
+ if (raw.extends !== "scaff") {
51
+ throw new Error(`scaff-extend.yml: 'extends' must be "scaff" (got "${raw.extends}"). Extension chains are not supported yet.`);
52
+ }
53
+ const commands = [];
54
+ if (raw.commands !== undefined) {
55
+ if (!Array.isArray(raw.commands)) {
56
+ throw new Error("scaff-extend.yml: 'commands' must be a list");
57
+ }
58
+ for (const c of raw.commands) {
59
+ if (typeof c !== "string" || !c) {
60
+ throw new Error("scaff-extend.yml: each command must be a non-empty string");
61
+ }
62
+ commands.push(c);
63
+ }
64
+ }
65
+ const hooks = [];
66
+ if (raw.hooks !== undefined) {
67
+ if (!Array.isArray(raw.hooks)) {
68
+ throw new Error("scaff-extend.yml: 'hooks' must be a list");
69
+ }
70
+ for (const h of raw.hooks) {
71
+ if (!h || typeof h !== "object") {
72
+ throw new Error("scaff-extend.yml: each hook must be a mapping");
73
+ }
74
+ const hook = h;
75
+ if (typeof hook.id !== "string" || !hook.id) {
76
+ throw new Error("scaff-extend.yml: hook 'id' is required");
77
+ }
78
+ if (typeof hook.target !== "string" || !hook.target) {
79
+ throw new Error(`scaff-extend.yml: hook '${hook.id}' missing 'target'`);
80
+ }
81
+ const hasContent = hook.content !== undefined;
82
+ const hasFile = hook.file !== undefined;
83
+ if (hasContent && hasFile) {
84
+ throw new Error(`scaff-extend.yml: hook '${hook.id}' cannot have both 'content' and 'file'`);
85
+ }
86
+ if (!hasContent && !hasFile) {
87
+ throw new Error(`scaff-extend.yml: hook '${hook.id}' must have either 'content' or 'file'`);
88
+ }
89
+ if (hasContent && typeof hook.content !== "string") {
90
+ throw new Error(`scaff-extend.yml: hook '${hook.id}' 'content' must be a string`);
91
+ }
92
+ if (hasFile && typeof hook.file !== "string") {
93
+ throw new Error(`scaff-extend.yml: hook '${hook.id}' 'file' must be a string`);
94
+ }
95
+ hooks.push({
96
+ id: hook.id,
97
+ target: hook.target,
98
+ content: hasContent ? hook.content : undefined,
99
+ file: hasFile ? hook.file : undefined,
100
+ });
101
+ }
102
+ }
103
+ const dependencies = [];
104
+ if (raw.dependencies !== undefined) {
105
+ if (!Array.isArray(raw.dependencies)) {
106
+ throw new Error("scaff-extend.yml: 'dependencies' must be a list");
107
+ }
108
+ for (const d of raw.dependencies) {
109
+ if (!d || typeof d !== "object") {
110
+ throw new Error("scaff-extend.yml: each dependency must be a mapping");
111
+ }
112
+ const dep = d;
113
+ if (typeof dep.name !== "string" || !dep.name) {
114
+ throw new Error("scaff-extend.yml: dependency 'name' is required");
115
+ }
116
+ if (typeof dep.check !== "string" || !dep.check) {
117
+ throw new Error(`scaff-extend.yml: dependency '${dep.name}' missing 'check'`);
118
+ }
119
+ dependencies.push({ name: dep.name, check: dep.check });
120
+ }
121
+ }
122
+ return {
123
+ name: raw.name,
124
+ version: typeof raw.version === "string" ? raw.version : undefined,
125
+ extends: raw.extends,
126
+ requires: typeof raw.requires === "string" ? raw.requires : undefined,
127
+ commands,
128
+ hooks,
129
+ dependencies,
130
+ };
131
+ }
132
+ function checkDependencies(deps) {
133
+ for (const dep of deps) {
134
+ try {
135
+ execSync(dep.check, { stdio: "ignore" });
136
+ }
137
+ catch {
138
+ throw new Error(`Dependency '${dep.name}' not found (check: ${dep.check})`);
139
+ }
140
+ }
141
+ }
142
+ function commandTargetPath(root, name) {
143
+ return join(root, ".claude", "commands", "scaff", `${name}.md`);
144
+ }
145
+ function resolveWithinExtension(extensionPath, relPath) {
146
+ if (isAbsolute(relPath))
147
+ return relPath;
148
+ return resolve(extensionPath, relPath);
149
+ }
150
+ async function copyCommands(extensionPath, commands, root, force, ask) {
151
+ const copied = [];
152
+ const skipped = [];
153
+ let forceAll = force;
154
+ for (const name of commands) {
155
+ const src = join(extensionPath, "commands", `${name}.md`);
156
+ if (!existsSync(src)) {
157
+ throw new Error(`Missing command source file: ${src}`);
158
+ }
159
+ const dst = commandTargetPath(root, name);
160
+ if (existsSync(dst) && !forceAll) {
161
+ const answer = await ask(`File exists: ${dst}. Overwrite? (y/n/a) `);
162
+ if (answer === "a" || answer === "all") {
163
+ forceAll = true;
164
+ }
165
+ else if (answer !== "y" && answer !== "yes") {
166
+ skipped.push(name);
167
+ continue;
168
+ }
169
+ }
170
+ const dir = dirname(dst);
171
+ if (!existsSync(dir)) {
172
+ mkdirSync(dir, { recursive: true });
173
+ }
174
+ const content = readFileSync(src, "utf-8");
175
+ writeFileSync(dst, content, "utf-8");
176
+ copied.push(name);
177
+ }
178
+ return { copied, skipped };
179
+ }
180
+ function applyHooks(extensionPath, hooks, root) {
181
+ const applied = [];
182
+ const byTarget = new Map();
183
+ for (const hook of hooks) {
184
+ const list = byTarget.get(hook.target) ?? [];
185
+ list.push(hook);
186
+ byTarget.set(hook.target, list);
187
+ }
188
+ for (const [target, specs] of byTarget) {
189
+ const targetPath = isAbsolute(target) ? target : join(root, target);
190
+ if (!existsSync(targetPath)) {
191
+ throw new Error(`Hook target file not found: ${targetPath}`);
192
+ }
193
+ const original = readFileSync(targetPath, "utf-8");
194
+ const entries = specs.map((spec) => {
195
+ let content;
196
+ if (spec.content !== undefined) {
197
+ content = spec.content;
198
+ }
199
+ else {
200
+ const filePath = resolveWithinExtension(extensionPath, spec.file);
201
+ if (!existsSync(filePath)) {
202
+ throw new Error(`Hook '${spec.id}' file not found: ${filePath}`);
203
+ }
204
+ content = readFileSync(filePath, "utf-8");
205
+ }
206
+ // Strip trailing newline so injected content fits cleanly between markers.
207
+ if (content.endsWith("\n")) {
208
+ content = content.slice(0, -1);
209
+ }
210
+ return { id: spec.id, content };
211
+ });
212
+ const next = injectHooks(original, entries);
213
+ writeFileSync(targetPath, next, "utf-8");
214
+ for (const spec of specs)
215
+ applied.push(spec.id);
216
+ }
217
+ return applied;
218
+ }
219
+ export async function extend(extensionPath, opts, promptFn) {
220
+ if (!existsSync(extensionPath)) {
221
+ throw new Error(`Extension path does not exist: ${extensionPath}`);
222
+ }
223
+ const manifest = readManifest(extensionPath);
224
+ const deps = manifest.dependencies ?? [];
225
+ checkDependencies(deps);
226
+ const defaultPrompt = promptFn ? null : createDefaultPrompt();
227
+ const ask = promptFn ?? defaultPrompt.prompt;
228
+ let copyResult;
229
+ try {
230
+ copyResult = await copyCommands(extensionPath, manifest.commands ?? [], opts.root, opts.force, ask);
231
+ }
232
+ finally {
233
+ defaultPrompt?.close();
234
+ }
235
+ const hooksApplied = applyHooks(extensionPath, manifest.hooks ?? [], opts.root);
236
+ return {
237
+ commandsAdded: copyResult.copied,
238
+ commandsSkipped: copyResult.skipped,
239
+ hooksApplied,
240
+ dependenciesChecked: deps.map((d) => d.name),
241
+ };
242
+ }
@@ -0,0 +1,15 @@
1
+ export interface HookEntry {
2
+ id: string;
3
+ content: string;
4
+ }
5
+ /**
6
+ * Validate that a file has properly paired hook markers.
7
+ * Returns list of hook ids found.
8
+ */
9
+ export declare function findHookPoints(fileContent: string): string[];
10
+ /**
11
+ * Inject hook content into a file's text.
12
+ * Returns the modified text.
13
+ * Throws on errors (missing markers, mismatched pairs, non-empty hooks).
14
+ */
15
+ export declare function injectHooks(fileContent: string, hooks: HookEntry[]): string;
@@ -0,0 +1,95 @@
1
+ const HOOK_BEGIN = /^\s*<!--\s*@hook\s+(\S+)\s*-->/;
2
+ const HOOK_END = /^\s*<!--\s*@end-hook\s+(\S+)\s*-->/;
3
+ /**
4
+ * Validate that a file has properly paired hook markers.
5
+ * Returns list of hook ids found.
6
+ */
7
+ export function findHookPoints(fileContent) {
8
+ const ids = [];
9
+ const lines = fileContent.split("\n");
10
+ let openId = null;
11
+ for (const line of lines) {
12
+ const beginMatch = line.match(HOOK_BEGIN);
13
+ const endMatch = line.match(HOOK_END);
14
+ if (beginMatch) {
15
+ if (openId !== null) {
16
+ throw new Error(`Unclosed hook '${openId}'`);
17
+ }
18
+ openId = beginMatch[1];
19
+ }
20
+ else if (endMatch) {
21
+ const endId = endMatch[1];
22
+ if (openId === null) {
23
+ throw new Error(`Unexpected hook end '${endId}'`);
24
+ }
25
+ if (endId !== openId) {
26
+ throw new Error(`Mismatched hook end: expected '${openId}', got '${endId}'`);
27
+ }
28
+ ids.push(openId);
29
+ openId = null;
30
+ }
31
+ }
32
+ if (openId !== null) {
33
+ throw new Error(`Unclosed hook '${openId}'`);
34
+ }
35
+ return ids;
36
+ }
37
+ /**
38
+ * Inject hook content into a file's text.
39
+ * Returns the modified text.
40
+ * Throws on errors (missing markers, mismatched pairs, non-empty hooks).
41
+ */
42
+ export function injectHooks(fileContent, hooks) {
43
+ if (hooks.length === 0) {
44
+ return fileContent;
45
+ }
46
+ const lines = fileContent.split("\n");
47
+ const result = [];
48
+ const injected = new Set();
49
+ const hookMap = new Map(hooks.map((h) => [h.id, h.content]));
50
+ let openId = null;
51
+ let openLine = -1;
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const line = lines[i];
54
+ const beginMatch = line.match(HOOK_BEGIN);
55
+ const endMatch = line.match(HOOK_END);
56
+ if (beginMatch) {
57
+ if (openId !== null) {
58
+ throw new Error(`Unclosed hook '${openId}'`);
59
+ }
60
+ openId = beginMatch[1];
61
+ openLine = i;
62
+ result.push(line);
63
+ }
64
+ else if (endMatch) {
65
+ const endId = endMatch[1];
66
+ if (openId === null) {
67
+ throw new Error(`Unexpected hook end '${endId}'`);
68
+ }
69
+ if (endId !== openId) {
70
+ throw new Error(`Mismatched hook end: expected '${openId}', got '${endId}'`);
71
+ }
72
+ if (i !== openLine + 1) {
73
+ throw new Error(`Hook '${openId}' already has content`);
74
+ }
75
+ if (hookMap.has(openId)) {
76
+ result.push(hookMap.get(openId));
77
+ injected.add(openId);
78
+ }
79
+ result.push(line);
80
+ openId = null;
81
+ }
82
+ else {
83
+ result.push(line);
84
+ }
85
+ }
86
+ if (openId !== null) {
87
+ throw new Error(`Unclosed hook '${openId}'`);
88
+ }
89
+ for (const hook of hooks) {
90
+ if (!injected.has(hook.id)) {
91
+ throw new Error(`Hook point '${hook.id}' not found in file`);
92
+ }
93
+ }
94
+ return result.join("\n");
95
+ }
@@ -0,0 +1,5 @@
1
+ import { type Locale, type MessageKey } from "./messages.js";
2
+ export declare function getLocale(): Locale;
3
+ export declare function setLocale(locale: Locale): void;
4
+ export declare function t(key: MessageKey): string;
5
+ export type { Locale, MessageKey };