@sduck/sduck-cli 0.1.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/dist/cli.d.ts +1 -0
- package/dist/cli.js +1149 -0
- package/dist/cli.js.map +1 -0
- package/package.json +59 -0
- package/sduck-assets/agent-rules/antigravity.md +4 -0
- package/sduck-assets/agent-rules/claude-code.md +4 -0
- package/sduck-assets/agent-rules/codex.md +4 -0
- package/sduck-assets/agent-rules/core.md +11 -0
- package/sduck-assets/agent-rules/cursor.mdc +11 -0
- package/sduck-assets/agent-rules/gemini-cli.md +4 -0
- package/sduck-assets/agent-rules/opencode.md +4 -0
- package/sduck-assets/eval/plan.yml +31 -0
- package/sduck-assets/eval/spec.yml +31 -0
- package/sduck-assets/eval/task.yml +31 -0
- package/sduck-assets/types/build.md +194 -0
- package/sduck-assets/types/chore.md +164 -0
- package/sduck-assets/types/feature.md +187 -0
- package/sduck-assets/types/fix.md +174 -0
- package/sduck-assets/types/refactor.md +174 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import { checkbox } from "@inquirer/prompts";
|
|
8
|
+
|
|
9
|
+
// src/core/agent-rules.ts
|
|
10
|
+
import { readFile } from "fs/promises";
|
|
11
|
+
import { dirname, join } from "path";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
|
|
14
|
+
// src/core/fs.ts
|
|
15
|
+
import { constants } from "fs";
|
|
16
|
+
import { access, copyFile, mkdir, stat } from "fs/promises";
|
|
17
|
+
async function getFsEntryKind(targetPath) {
|
|
18
|
+
try {
|
|
19
|
+
const stats = await stat(targetPath);
|
|
20
|
+
if (stats.isDirectory()) {
|
|
21
|
+
return "directory";
|
|
22
|
+
}
|
|
23
|
+
if (stats.isFile()) {
|
|
24
|
+
return "file";
|
|
25
|
+
}
|
|
26
|
+
return "file";
|
|
27
|
+
} catch {
|
|
28
|
+
return "missing";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function ensureDirectory(targetPath) {
|
|
32
|
+
await mkdir(targetPath, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
async function ensureReadableFile(targetPath) {
|
|
35
|
+
await access(targetPath, constants.R_OK);
|
|
36
|
+
}
|
|
37
|
+
async function copyFileIntoPlace(sourcePath, targetPath) {
|
|
38
|
+
await copyFile(sourcePath, targetPath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/core/agent-rules.ts
|
|
42
|
+
var SDD_RULES_BEGIN = "<!-- sduck:begin -->";
|
|
43
|
+
var SDD_RULES_END = "<!-- sduck:end -->";
|
|
44
|
+
var SUPPORTED_AGENTS = [
|
|
45
|
+
{ id: "claude-code", label: "Claude Code" },
|
|
46
|
+
{ id: "codex", label: "Codex" },
|
|
47
|
+
{ id: "opencode", label: "OpenCode" },
|
|
48
|
+
{ id: "gemini-cli", label: "Gemini CLI" },
|
|
49
|
+
{ id: "cursor", label: "Cursor" },
|
|
50
|
+
{ id: "antigravity", label: "Antigravity" }
|
|
51
|
+
];
|
|
52
|
+
var AGENT_RULE_TARGETS = [
|
|
53
|
+
{ agentId: "claude-code", outputPath: "CLAUDE.md", kind: "root-file" },
|
|
54
|
+
{ agentId: "codex", outputPath: "AGENTS.md", kind: "root-file" },
|
|
55
|
+
{ agentId: "opencode", outputPath: "AGENTS.md", kind: "root-file" },
|
|
56
|
+
{ agentId: "gemini-cli", outputPath: "GEMINI.md", kind: "root-file" },
|
|
57
|
+
{
|
|
58
|
+
agentId: "cursor",
|
|
59
|
+
outputPath: join(".cursor", "rules", "sduck-core.mdc"),
|
|
60
|
+
kind: "managed-file"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
agentId: "antigravity",
|
|
64
|
+
outputPath: join(".agents", "rules", "sduck-core.md"),
|
|
65
|
+
kind: "managed-file"
|
|
66
|
+
}
|
|
67
|
+
];
|
|
68
|
+
var AGENT_TEMPLATE_FILES = {
|
|
69
|
+
"claude-code": "claude-code.md",
|
|
70
|
+
codex: "codex.md",
|
|
71
|
+
opencode: "opencode.md",
|
|
72
|
+
"gemini-cli": "gemini-cli.md",
|
|
73
|
+
cursor: "cursor.mdc",
|
|
74
|
+
antigravity: "antigravity.md"
|
|
75
|
+
};
|
|
76
|
+
function unique(values) {
|
|
77
|
+
return [...new Set(values)];
|
|
78
|
+
}
|
|
79
|
+
function parseAgentsOption(rawAgents) {
|
|
80
|
+
if (rawAgents === void 0 || rawAgents.trim() === "") {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const requestedAgents = unique(
|
|
84
|
+
rawAgents.split(",").map((value) => value.trim()).filter((value) => value !== "")
|
|
85
|
+
);
|
|
86
|
+
const validAgents = new Set(SUPPORTED_AGENTS.map((agent) => agent.id));
|
|
87
|
+
const invalidAgent = requestedAgents.find((agent) => !validAgents.has(agent));
|
|
88
|
+
if (invalidAgent !== void 0) {
|
|
89
|
+
throw new Error(`Unsupported agent: ${invalidAgent}`);
|
|
90
|
+
}
|
|
91
|
+
return requestedAgents;
|
|
92
|
+
}
|
|
93
|
+
function listAgentRuleTargets(selectedAgents) {
|
|
94
|
+
const selectedAgentSet = new Set(selectedAgents);
|
|
95
|
+
return AGENT_RULE_TARGETS.filter((target) => selectedAgentSet.has(target.agentId)).filter(
|
|
96
|
+
(target, index, allTargets) => index === allTargets.findIndex((candidate) => candidate.outputPath === target.outputPath)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
function hasManagedBlock(content) {
|
|
100
|
+
return content.includes(SDD_RULES_BEGIN) && content.includes(SDD_RULES_END);
|
|
101
|
+
}
|
|
102
|
+
function prependManagedBlock(existingContent, blockContent) {
|
|
103
|
+
const normalizedExistingContent = existingContent.trimStart();
|
|
104
|
+
if (normalizedExistingContent === "") {
|
|
105
|
+
return `${blockContent}
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
return `${blockContent}
|
|
109
|
+
|
|
110
|
+
${normalizedExistingContent}`;
|
|
111
|
+
}
|
|
112
|
+
function replaceManagedBlock(existingContent, blockContent) {
|
|
113
|
+
const blockPattern = new RegExp(`${SDD_RULES_BEGIN}[\\s\\S]*?${SDD_RULES_END}`);
|
|
114
|
+
if (!blockPattern.test(existingContent)) {
|
|
115
|
+
return prependManagedBlock(existingContent, blockContent);
|
|
116
|
+
}
|
|
117
|
+
return existingContent.replace(blockPattern, blockContent);
|
|
118
|
+
}
|
|
119
|
+
function renderManagedBlock(lines) {
|
|
120
|
+
return [SDD_RULES_BEGIN, ...lines, SDD_RULES_END].join("\n");
|
|
121
|
+
}
|
|
122
|
+
async function getAgentRulesAssetRoot() {
|
|
123
|
+
const currentDirectoryPath = dirname(fileURLToPath(import.meta.url));
|
|
124
|
+
const candidatePaths = [
|
|
125
|
+
join(currentDirectoryPath, "..", "..", "sduck-assets", "agent-rules"),
|
|
126
|
+
join(currentDirectoryPath, "..", "sduck-assets", "agent-rules")
|
|
127
|
+
];
|
|
128
|
+
for (const candidatePath of candidatePaths) {
|
|
129
|
+
if (await getFsEntryKind(candidatePath) === "directory") {
|
|
130
|
+
return candidatePath;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw new Error("Unable to locate bundled sduck agent rule assets.");
|
|
134
|
+
}
|
|
135
|
+
async function readAssetFile(assetRoot, fileName) {
|
|
136
|
+
return await readFile(join(assetRoot, fileName), "utf8");
|
|
137
|
+
}
|
|
138
|
+
function buildRootFileLines(agentIds, agentSpecificContent) {
|
|
139
|
+
const labels = SUPPORTED_AGENTS.filter((agent) => agentIds.includes(agent.id)).map(
|
|
140
|
+
(agent) => agent.label
|
|
141
|
+
);
|
|
142
|
+
return [
|
|
143
|
+
"# sduck managed rules",
|
|
144
|
+
"",
|
|
145
|
+
`Selected agents: ${labels.join(", ")}`,
|
|
146
|
+
"",
|
|
147
|
+
...agentSpecificContent
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
async function renderAgentRuleContent(target, selectedAgents) {
|
|
151
|
+
const assetRoot = await getAgentRulesAssetRoot();
|
|
152
|
+
const coreContent = await readAssetFile(assetRoot, "core.md");
|
|
153
|
+
if (target.kind === "managed-file") {
|
|
154
|
+
const templateFileName = AGENT_TEMPLATE_FILES[target.agentId];
|
|
155
|
+
const specificContent = await readAssetFile(assetRoot, templateFileName);
|
|
156
|
+
return `${specificContent.trim()}
|
|
157
|
+
|
|
158
|
+
${coreContent.trim()}
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
const relatedAgents = AGENT_RULE_TARGETS.filter(
|
|
162
|
+
(candidate) => candidate.outputPath === target.outputPath && selectedAgents.includes(candidate.agentId)
|
|
163
|
+
).map((candidate) => candidate.agentId);
|
|
164
|
+
const specificSections = [];
|
|
165
|
+
for (const agentId of relatedAgents) {
|
|
166
|
+
const templateFileName = AGENT_TEMPLATE_FILES[agentId];
|
|
167
|
+
specificSections.push((await readAssetFile(assetRoot, templateFileName)).trim());
|
|
168
|
+
}
|
|
169
|
+
const lines = buildRootFileLines(relatedAgents, [...specificSections, coreContent.trim()]);
|
|
170
|
+
return `${renderManagedBlock(lines)}
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
function planAgentRuleActions(mode, targets, existingEntries, existingContents) {
|
|
174
|
+
return targets.map((target) => {
|
|
175
|
+
const currentKind = existingEntries.get(target.outputPath) ?? "missing";
|
|
176
|
+
if (currentKind === "missing") {
|
|
177
|
+
return { ...target, mergeMode: "create", currentKind };
|
|
178
|
+
}
|
|
179
|
+
if (currentKind !== "file") {
|
|
180
|
+
return { ...target, mergeMode: "overwrite", currentKind };
|
|
181
|
+
}
|
|
182
|
+
if (target.kind === "managed-file") {
|
|
183
|
+
return { ...target, mergeMode: mode === "force" ? "overwrite" : "keep", currentKind };
|
|
184
|
+
}
|
|
185
|
+
const content = existingContents.get(target.outputPath) ?? "";
|
|
186
|
+
if (mode === "safe") {
|
|
187
|
+
return { ...target, mergeMode: hasManagedBlock(content) ? "keep" : "prepend", currentKind };
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
...target,
|
|
191
|
+
mergeMode: hasManagedBlock(content) ? "replace-block" : "prepend",
|
|
192
|
+
currentKind
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/core/init.ts
|
|
198
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
|
|
199
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
200
|
+
|
|
201
|
+
// src/core/assets.ts
|
|
202
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
203
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
204
|
+
var SUPPORTED_TASK_TYPES = [
|
|
205
|
+
"build",
|
|
206
|
+
"feature",
|
|
207
|
+
"fix",
|
|
208
|
+
"refactor",
|
|
209
|
+
"chore"
|
|
210
|
+
];
|
|
211
|
+
var EVAL_ASSET_RELATIVE_PATHS = {
|
|
212
|
+
plan: join2("eval", "plan.yml"),
|
|
213
|
+
spec: join2("eval", "spec.yml")
|
|
214
|
+
};
|
|
215
|
+
var SPEC_TEMPLATE_RELATIVE_PATHS = {
|
|
216
|
+
build: join2("types", "build.md"),
|
|
217
|
+
feature: join2("types", "feature.md"),
|
|
218
|
+
fix: join2("types", "fix.md"),
|
|
219
|
+
refactor: join2("types", "refactor.md"),
|
|
220
|
+
chore: join2("types", "chore.md")
|
|
221
|
+
};
|
|
222
|
+
var INIT_ASSET_RELATIVE_PATHS = [
|
|
223
|
+
EVAL_ASSET_RELATIVE_PATHS.spec,
|
|
224
|
+
EVAL_ASSET_RELATIVE_PATHS.plan,
|
|
225
|
+
...Object.values(SPEC_TEMPLATE_RELATIVE_PATHS)
|
|
226
|
+
];
|
|
227
|
+
async function getBundledAssetsRoot() {
|
|
228
|
+
const currentDirectoryPath = dirname2(fileURLToPath2(import.meta.url));
|
|
229
|
+
const candidatePaths = [
|
|
230
|
+
join2(currentDirectoryPath, "..", "..", "sduck-assets"),
|
|
231
|
+
join2(currentDirectoryPath, "..", "sduck-assets")
|
|
232
|
+
];
|
|
233
|
+
for (const candidatePath of candidatePaths) {
|
|
234
|
+
if (await getFsEntryKind(candidatePath) === "directory") {
|
|
235
|
+
return candidatePath;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
throw new Error("Unable to locate bundled sduck-assets directory.");
|
|
239
|
+
}
|
|
240
|
+
function isSupportedTaskType(value) {
|
|
241
|
+
return SUPPORTED_TASK_TYPES.includes(value);
|
|
242
|
+
}
|
|
243
|
+
function resolveSpecTemplateRelativePath(type) {
|
|
244
|
+
return SPEC_TEMPLATE_RELATIVE_PATHS[type];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/core/init.ts
|
|
248
|
+
var ASSET_TEMPLATE_DEFINITIONS = [
|
|
249
|
+
{
|
|
250
|
+
key: "eval-spec",
|
|
251
|
+
relativePath: join3("sduck-assets", EVAL_ASSET_RELATIVE_PATHS.spec)
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
key: "eval-plan",
|
|
255
|
+
relativePath: join3("sduck-assets", EVAL_ASSET_RELATIVE_PATHS.plan)
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
key: "type-build",
|
|
259
|
+
relativePath: join3("sduck-assets", "types", "build.md")
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
key: "type-feature",
|
|
263
|
+
relativePath: join3("sduck-assets", "types", "feature.md")
|
|
264
|
+
},
|
|
265
|
+
{ key: "type-fix", relativePath: join3("sduck-assets", "types", "fix.md") },
|
|
266
|
+
{
|
|
267
|
+
key: "type-refactor",
|
|
268
|
+
relativePath: join3("sduck-assets", "types", "refactor.md")
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
key: "type-chore",
|
|
272
|
+
relativePath: join3("sduck-assets", "types", "chore.md")
|
|
273
|
+
}
|
|
274
|
+
];
|
|
275
|
+
var ASSET_TEMPLATE_MAP = Object.fromEntries(
|
|
276
|
+
ASSET_TEMPLATE_DEFINITIONS.map((definition) => [definition.key, definition])
|
|
277
|
+
);
|
|
278
|
+
function planInitActions(mode, existingEntries) {
|
|
279
|
+
return ASSET_TEMPLATE_DEFINITIONS.map((definition) => {
|
|
280
|
+
const currentKind = existingEntries.get(definition.relativePath) ?? "missing";
|
|
281
|
+
if (currentKind === "missing") {
|
|
282
|
+
return {
|
|
283
|
+
key: definition.key,
|
|
284
|
+
targetPath: definition.relativePath,
|
|
285
|
+
currentKind,
|
|
286
|
+
action: "create",
|
|
287
|
+
collision: "none"
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (currentKind === "file") {
|
|
291
|
+
return {
|
|
292
|
+
key: definition.key,
|
|
293
|
+
targetPath: definition.relativePath,
|
|
294
|
+
currentKind,
|
|
295
|
+
action: mode === "force" ? "overwrite" : "keep",
|
|
296
|
+
collision: "none"
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
key: definition.key,
|
|
301
|
+
targetPath: definition.relativePath,
|
|
302
|
+
currentKind,
|
|
303
|
+
action: "error",
|
|
304
|
+
collision: "directory-file-mismatch"
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
function summarizeInitActions(actions) {
|
|
309
|
+
const summary = {
|
|
310
|
+
created: [],
|
|
311
|
+
prepended: [],
|
|
312
|
+
kept: [],
|
|
313
|
+
overwritten: [],
|
|
314
|
+
warnings: [],
|
|
315
|
+
errors: [],
|
|
316
|
+
rows: []
|
|
317
|
+
};
|
|
318
|
+
for (const action of actions) {
|
|
319
|
+
if (action.action === "create") {
|
|
320
|
+
summary.created.push(action.targetPath);
|
|
321
|
+
summary.rows.push({ path: action.targetPath, status: "created" });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (action.action === "keep") {
|
|
325
|
+
summary.kept.push(action.targetPath);
|
|
326
|
+
summary.rows.push({ path: action.targetPath, status: "kept" });
|
|
327
|
+
summary.warnings.push(`Kept existing asset: ${action.targetPath}`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (action.action === "overwrite") {
|
|
331
|
+
summary.overwritten.push(action.targetPath);
|
|
332
|
+
summary.rows.push({ path: action.targetPath, status: "overwritten" });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
summary.errors.push(`Path conflict for ${action.targetPath}: ${action.collision}`);
|
|
336
|
+
}
|
|
337
|
+
if (summary.kept.length > 0) {
|
|
338
|
+
summary.warnings.push("Run `sduck init --force` if you want to regenerate bundled assets.");
|
|
339
|
+
}
|
|
340
|
+
return summary;
|
|
341
|
+
}
|
|
342
|
+
function getInitMode(options) {
|
|
343
|
+
return options.force ? "force" : "safe";
|
|
344
|
+
}
|
|
345
|
+
function resolveInitOptions(options) {
|
|
346
|
+
return {
|
|
347
|
+
mode: getInitMode(options),
|
|
348
|
+
agents: [...new Set(options.agents)]
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
async function collectExistingEntries(projectRoot) {
|
|
352
|
+
const existingEntries = /* @__PURE__ */ new Map();
|
|
353
|
+
for (const definition of ASSET_TEMPLATE_DEFINITIONS) {
|
|
354
|
+
existingEntries.set(
|
|
355
|
+
definition.relativePath,
|
|
356
|
+
await getFsEntryKind(join3(projectRoot, definition.relativePath))
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return existingEntries;
|
|
360
|
+
}
|
|
361
|
+
async function collectExistingFileContents(projectRoot, targets) {
|
|
362
|
+
const contents = /* @__PURE__ */ new Map();
|
|
363
|
+
for (const target of targets) {
|
|
364
|
+
const targetPath = join3(projectRoot, target.outputPath);
|
|
365
|
+
if (await getFsEntryKind(targetPath) === "file") {
|
|
366
|
+
contents.set(target.outputPath, await readFile2(targetPath, "utf8"));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return contents;
|
|
370
|
+
}
|
|
371
|
+
async function ensureRootDirectory(targetPath, errorCode) {
|
|
372
|
+
const kind = await getFsEntryKind(targetPath);
|
|
373
|
+
if (kind === "missing") {
|
|
374
|
+
await ensureDirectory(targetPath);
|
|
375
|
+
return "created";
|
|
376
|
+
}
|
|
377
|
+
if (kind === "directory") {
|
|
378
|
+
return "kept";
|
|
379
|
+
}
|
|
380
|
+
throw new Error(`${errorCode}: expected a directory at ${targetPath}.`);
|
|
381
|
+
}
|
|
382
|
+
async function initProject(options, projectRoot) {
|
|
383
|
+
const resolvedOptions = resolveInitOptions(options);
|
|
384
|
+
const { mode } = resolvedOptions;
|
|
385
|
+
const assetSourceRoot = await getBundledAssetsRoot();
|
|
386
|
+
const assetsRoot = join3(projectRoot, "sduck-assets");
|
|
387
|
+
const workspaceRoot = join3(projectRoot, "sduck-workspace");
|
|
388
|
+
const summary = {
|
|
389
|
+
created: [],
|
|
390
|
+
prepended: [],
|
|
391
|
+
kept: [],
|
|
392
|
+
overwritten: [],
|
|
393
|
+
warnings: [],
|
|
394
|
+
errors: [],
|
|
395
|
+
rows: []
|
|
396
|
+
};
|
|
397
|
+
const assetsRootStatus = await ensureRootDirectory(assetsRoot, "asset-root-conflict");
|
|
398
|
+
summary[assetsRootStatus].push("sduck-assets/");
|
|
399
|
+
summary.rows.push({ path: "sduck-assets/", status: assetsRootStatus });
|
|
400
|
+
const workspaceRootStatus = await ensureRootDirectory(workspaceRoot, "workspace-root-conflict");
|
|
401
|
+
summary[workspaceRootStatus].push("sduck-workspace/");
|
|
402
|
+
summary.rows.push({ path: "sduck-workspace/", status: workspaceRootStatus });
|
|
403
|
+
const actions = planInitActions(mode, await collectExistingEntries(projectRoot));
|
|
404
|
+
const actionSummary = summarizeInitActions(actions);
|
|
405
|
+
summary.created.push(...actionSummary.created);
|
|
406
|
+
summary.kept.push(...actionSummary.kept);
|
|
407
|
+
summary.overwritten.push(...actionSummary.overwritten);
|
|
408
|
+
summary.warnings.push(...actionSummary.warnings);
|
|
409
|
+
summary.errors.push(...actionSummary.errors);
|
|
410
|
+
summary.rows.push(...actionSummary.rows);
|
|
411
|
+
const conflict = actions.find((action) => action.action === "error");
|
|
412
|
+
if (conflict !== void 0) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`type-conflict: expected a file but found a directory at ${conflict.targetPath}. Resolve it manually or move the conflicting path before retrying.`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
for (const action of actions) {
|
|
418
|
+
if (action.action === "keep") {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const definition = ASSET_TEMPLATE_MAP[action.key];
|
|
422
|
+
const sourcePath = join3(
|
|
423
|
+
assetSourceRoot,
|
|
424
|
+
definition.relativePath.replace(/^sduck-assets[\\/]/, "")
|
|
425
|
+
);
|
|
426
|
+
const targetPath = join3(projectRoot, definition.relativePath);
|
|
427
|
+
await ensureReadableFile(sourcePath);
|
|
428
|
+
await mkdir2(dirname3(targetPath), { recursive: true });
|
|
429
|
+
await copyFileIntoPlace(sourcePath, targetPath);
|
|
430
|
+
}
|
|
431
|
+
const agentTargets = listAgentRuleTargets(resolvedOptions.agents);
|
|
432
|
+
const agentEntryKinds = /* @__PURE__ */ new Map();
|
|
433
|
+
for (const target of agentTargets) {
|
|
434
|
+
agentEntryKinds.set(
|
|
435
|
+
target.outputPath,
|
|
436
|
+
await getFsEntryKind(join3(projectRoot, target.outputPath))
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
const existingContents = await collectExistingFileContents(projectRoot, agentTargets);
|
|
440
|
+
const agentActions = planAgentRuleActions(mode, agentTargets, agentEntryKinds, existingContents);
|
|
441
|
+
await applyAgentRuleActions(
|
|
442
|
+
projectRoot,
|
|
443
|
+
agentActions,
|
|
444
|
+
existingContents,
|
|
445
|
+
summary,
|
|
446
|
+
resolvedOptions.agents
|
|
447
|
+
);
|
|
448
|
+
return {
|
|
449
|
+
mode,
|
|
450
|
+
agents: resolvedOptions.agents,
|
|
451
|
+
summary,
|
|
452
|
+
didChange: summary.created.length > 0 || summary.prepended.length > 0 || summary.overwritten.length > 0
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
async function applyAgentRuleActions(projectRoot, actions, existingContents, summary, selectedAgents) {
|
|
456
|
+
for (const action of actions) {
|
|
457
|
+
const targetPath = join3(projectRoot, action.outputPath);
|
|
458
|
+
const content = await renderAgentRuleContent(action, selectedAgents);
|
|
459
|
+
if (action.mergeMode === "create") {
|
|
460
|
+
await mkdir2(dirname3(targetPath), { recursive: true });
|
|
461
|
+
await writeFile(targetPath, content, "utf8");
|
|
462
|
+
summary.created.push(action.outputPath);
|
|
463
|
+
summary.rows.push({ path: action.outputPath, status: "created" });
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (action.mergeMode === "keep") {
|
|
467
|
+
summary.kept.push(action.outputPath);
|
|
468
|
+
summary.rows.push({ path: action.outputPath, status: "kept" });
|
|
469
|
+
summary.warnings.push(`Kept existing rule file: ${action.outputPath}`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
await mkdir2(dirname3(targetPath), { recursive: true });
|
|
473
|
+
if (action.mergeMode === "prepend") {
|
|
474
|
+
const currentContent = existingContents.get(action.outputPath) ?? "";
|
|
475
|
+
await writeFile(targetPath, prependManagedBlock(currentContent, content.trimEnd()), "utf8");
|
|
476
|
+
summary.prepended.push(action.outputPath);
|
|
477
|
+
summary.rows.push({ path: action.outputPath, status: "prepended" });
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (action.mergeMode === "replace-block") {
|
|
481
|
+
const currentContent = existingContents.get(action.outputPath) ?? "";
|
|
482
|
+
await writeFile(targetPath, replaceManagedBlock(currentContent, content.trimEnd()), "utf8");
|
|
483
|
+
summary.overwritten.push(action.outputPath);
|
|
484
|
+
summary.rows.push({ path: action.outputPath, status: "overwritten" });
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
await writeFile(targetPath, content, "utf8");
|
|
488
|
+
summary.overwritten.push(action.outputPath);
|
|
489
|
+
summary.rows.push({ path: action.outputPath, status: "overwritten" });
|
|
490
|
+
}
|
|
491
|
+
if (summary.kept.some((path) => path.endsWith(".md") || path.endsWith(".mdc"))) {
|
|
492
|
+
summary.warnings.push(
|
|
493
|
+
"Run `sduck init --force` to refresh managed rule content for selected agents."
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/commands/init.ts
|
|
499
|
+
function padCell(value, width) {
|
|
500
|
+
return value.padEnd(width, " ");
|
|
501
|
+
}
|
|
502
|
+
function buildSummaryTable(rows) {
|
|
503
|
+
const statusWidth = Math.max("Status".length, ...rows.map((row) => row.status.length));
|
|
504
|
+
const pathWidth = Math.max("Path".length, ...rows.map((row) => row.path.length));
|
|
505
|
+
const border = `+-${"-".repeat(statusWidth)}-+-${"-".repeat(pathWidth)}-+`;
|
|
506
|
+
const header = `| ${padCell("Status", statusWidth)} | ${padCell("Path", pathWidth)} |`;
|
|
507
|
+
const body = rows.map(
|
|
508
|
+
(row) => `| ${padCell(row.status, statusWidth)} | ${padCell(row.path, pathWidth)} |`
|
|
509
|
+
);
|
|
510
|
+
return [border, header, border, ...body, border].join("\n");
|
|
511
|
+
}
|
|
512
|
+
function formatResult(result) {
|
|
513
|
+
const lines = [
|
|
514
|
+
result.didChange ? "sduck init completed." : "sduck init completed with no file changes."
|
|
515
|
+
];
|
|
516
|
+
if (result.agents.length > 0) {
|
|
517
|
+
lines.push(`Selected agents: ${result.agents.join(", ")}`);
|
|
518
|
+
}
|
|
519
|
+
lines.push("", buildSummaryTable(result.summary.rows));
|
|
520
|
+
if (result.summary.warnings.length > 0) {
|
|
521
|
+
lines.push("", "Warnings:");
|
|
522
|
+
lines.push(...result.summary.warnings.map((warning) => `- ${warning}`));
|
|
523
|
+
}
|
|
524
|
+
return lines.join("\n");
|
|
525
|
+
}
|
|
526
|
+
async function resolveSelectedAgents(options) {
|
|
527
|
+
const parsedAgents = parseAgentsOption(options.agents);
|
|
528
|
+
if (parsedAgents.length > 0 || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
529
|
+
return parsedAgents;
|
|
530
|
+
}
|
|
531
|
+
return await checkbox({
|
|
532
|
+
message: "Select AI agents to generate repository rule files for",
|
|
533
|
+
choices: SUPPORTED_AGENTS.map((agent) => ({
|
|
534
|
+
name: agent.label,
|
|
535
|
+
value: agent.id
|
|
536
|
+
}))
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
async function runInitCommand(options, projectRoot) {
|
|
540
|
+
try {
|
|
541
|
+
const resolvedOptions = {
|
|
542
|
+
force: options.force,
|
|
543
|
+
agents: await resolveSelectedAgents(options)
|
|
544
|
+
};
|
|
545
|
+
const result = await initProject(resolvedOptions, projectRoot);
|
|
546
|
+
return {
|
|
547
|
+
exitCode: 0,
|
|
548
|
+
stderr: "",
|
|
549
|
+
stdout: formatResult(result)
|
|
550
|
+
};
|
|
551
|
+
} catch (error) {
|
|
552
|
+
const message = error instanceof Error ? error.message : "Unknown init failure.";
|
|
553
|
+
return {
|
|
554
|
+
exitCode: 1,
|
|
555
|
+
stderr: message,
|
|
556
|
+
stdout: ""
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/commands/plan-approve.ts
|
|
562
|
+
import { checkbox as checkbox2 } from "@inquirer/prompts";
|
|
563
|
+
|
|
564
|
+
// src/core/plan-approve.ts
|
|
565
|
+
import { readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
|
|
566
|
+
import { join as join5 } from "path";
|
|
567
|
+
|
|
568
|
+
// src/core/workspace.ts
|
|
569
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
570
|
+
import { join as join4 } from "path";
|
|
571
|
+
var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["IN_PROGRESS", "PENDING_SPEC_APPROVAL", "PENDING_PLAN_APPROVAL"]);
|
|
572
|
+
function parseMetaText(content) {
|
|
573
|
+
const createdAtMatch = /^created_at:\s+(.+)$/m.exec(content);
|
|
574
|
+
const idMatch = /^id:\s+(.+)$/m.exec(content);
|
|
575
|
+
const slugMatch = /^slug:\s+(.+)$/m.exec(content);
|
|
576
|
+
const statusMatch = /^status:\s+(.+)$/m.exec(content);
|
|
577
|
+
const parsedMeta = {};
|
|
578
|
+
if (createdAtMatch?.[1] !== void 0) {
|
|
579
|
+
parsedMeta.createdAt = createdAtMatch[1].trim();
|
|
580
|
+
}
|
|
581
|
+
if (idMatch?.[1] !== void 0) {
|
|
582
|
+
parsedMeta.id = idMatch[1].trim();
|
|
583
|
+
}
|
|
584
|
+
if (slugMatch?.[1] !== void 0) {
|
|
585
|
+
parsedMeta.slug = slugMatch[1].trim();
|
|
586
|
+
}
|
|
587
|
+
if (statusMatch?.[1] !== void 0) {
|
|
588
|
+
parsedMeta.status = statusMatch[1].trim();
|
|
589
|
+
}
|
|
590
|
+
return parsedMeta;
|
|
591
|
+
}
|
|
592
|
+
function sortTasksByRecency(tasks) {
|
|
593
|
+
return [...tasks].sort((left, right) => {
|
|
594
|
+
const leftValue = left.createdAt ?? "";
|
|
595
|
+
const rightValue = right.createdAt ?? "";
|
|
596
|
+
return rightValue.localeCompare(leftValue);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
async function listWorkspaceTasks(projectRoot) {
|
|
600
|
+
const workspaceRoot = join4(projectRoot, "sduck-workspace");
|
|
601
|
+
if (await getFsEntryKind(workspaceRoot) !== "directory") {
|
|
602
|
+
return [];
|
|
603
|
+
}
|
|
604
|
+
const { readdir } = await import("fs/promises");
|
|
605
|
+
const entries = await readdir(workspaceRoot, { withFileTypes: true });
|
|
606
|
+
const tasks = [];
|
|
607
|
+
for (const entry of entries) {
|
|
608
|
+
if (!entry.isDirectory()) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const relativePath = join4("sduck-workspace", entry.name);
|
|
612
|
+
const metaPath = join4(projectRoot, relativePath, "meta.yml");
|
|
613
|
+
if (await getFsEntryKind(metaPath) !== "file") {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const parsedMeta = parseMetaText(await readFile3(metaPath, "utf8"));
|
|
617
|
+
if (parsedMeta.id !== void 0 && parsedMeta.status !== void 0) {
|
|
618
|
+
const task = {
|
|
619
|
+
id: parsedMeta.id,
|
|
620
|
+
path: relativePath,
|
|
621
|
+
status: parsedMeta.status
|
|
622
|
+
};
|
|
623
|
+
if (parsedMeta.createdAt !== void 0) {
|
|
624
|
+
task.createdAt = parsedMeta.createdAt;
|
|
625
|
+
}
|
|
626
|
+
if (parsedMeta.slug !== void 0) {
|
|
627
|
+
task.slug = parsedMeta.slug;
|
|
628
|
+
}
|
|
629
|
+
tasks.push(task);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return sortTasksByRecency(tasks);
|
|
633
|
+
}
|
|
634
|
+
async function findActiveTask(projectRoot) {
|
|
635
|
+
const tasks = await listWorkspaceTasks(projectRoot);
|
|
636
|
+
for (const task of tasks) {
|
|
637
|
+
if (ACTIVE_STATUSES.has(task.status)) {
|
|
638
|
+
return {
|
|
639
|
+
id: task.id,
|
|
640
|
+
path: task.path,
|
|
641
|
+
status: task.status
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/utils/utc-date.ts
|
|
649
|
+
function pad2(value) {
|
|
650
|
+
return String(value).padStart(2, "0");
|
|
651
|
+
}
|
|
652
|
+
function formatUtcDate(date) {
|
|
653
|
+
const year = String(date.getUTCFullYear());
|
|
654
|
+
const month = pad2(date.getUTCMonth() + 1);
|
|
655
|
+
const day = pad2(date.getUTCDate());
|
|
656
|
+
return `${year}-${month}-${day}`;
|
|
657
|
+
}
|
|
658
|
+
function formatUtcTimestamp(date) {
|
|
659
|
+
const year = String(date.getUTCFullYear());
|
|
660
|
+
const month = pad2(date.getUTCMonth() + 1);
|
|
661
|
+
const day = pad2(date.getUTCDate());
|
|
662
|
+
const hour = pad2(date.getUTCHours());
|
|
663
|
+
const minute = pad2(date.getUTCMinutes());
|
|
664
|
+
const second = pad2(date.getUTCSeconds());
|
|
665
|
+
return `${year}-${month}-${day}T${hour}:${minute}:${second}Z`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// src/core/plan-approve.ts
|
|
669
|
+
function filterPlanApprovalCandidates(tasks) {
|
|
670
|
+
return tasks.filter((task) => task.status === "SPEC_APPROVED");
|
|
671
|
+
}
|
|
672
|
+
function resolvePlanApprovalCandidates(tasks, target) {
|
|
673
|
+
const candidates = filterPlanApprovalCandidates(tasks);
|
|
674
|
+
if (target === void 0 || target.trim() === "") {
|
|
675
|
+
return candidates;
|
|
676
|
+
}
|
|
677
|
+
const trimmedTarget = target.trim();
|
|
678
|
+
return candidates.filter(
|
|
679
|
+
(task) => task.id === trimmedTarget || task.slug === trimmedTarget || task.id.endsWith(trimmedTarget)
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
function countPlanSteps(planContent) {
|
|
683
|
+
const matches = planContent.match(/^## Step \d+\. .+$/gm);
|
|
684
|
+
return matches?.length ?? 0;
|
|
685
|
+
}
|
|
686
|
+
function updatePlanApprovalBlock(metaContent, approvedAt, totalSteps) {
|
|
687
|
+
const withStatus = metaContent.replace(/^status:\s+.+$/m, "status: IN_PROGRESS");
|
|
688
|
+
const withPlan = withStatus.replace(
|
|
689
|
+
/plan:\n {2}approved:\s+false\n {2}approved_at:\s+null/m,
|
|
690
|
+
`plan:
|
|
691
|
+
approved: true
|
|
692
|
+
approved_at: ${approvedAt}`
|
|
693
|
+
);
|
|
694
|
+
return withPlan.replace(
|
|
695
|
+
/steps:\n {2}total:\s+null\n {2}completed:\s+\[\]/m,
|
|
696
|
+
`steps:
|
|
697
|
+
total: ${String(totalSteps)}
|
|
698
|
+
completed: []`
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
async function approvePlans(projectRoot, tasks, approvedAt) {
|
|
702
|
+
const succeeded = [];
|
|
703
|
+
const failed = [];
|
|
704
|
+
for (const task of tasks) {
|
|
705
|
+
if (task.status !== "SPEC_APPROVED") {
|
|
706
|
+
failed.push({
|
|
707
|
+
note: `task is not awaiting plan approval (${task.status})`,
|
|
708
|
+
taskId: task.id
|
|
709
|
+
});
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
const metaPath = join5(projectRoot, task.path, "meta.yml");
|
|
713
|
+
const planPath = join5(projectRoot, task.path, "plan.md");
|
|
714
|
+
if (await getFsEntryKind(metaPath) !== "file") {
|
|
715
|
+
failed.push({ note: "missing meta.yml", taskId: task.id });
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (await getFsEntryKind(planPath) !== "file") {
|
|
719
|
+
failed.push({ note: "missing plan.md", taskId: task.id });
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
const planContent = await readFile4(planPath, "utf8");
|
|
723
|
+
const totalSteps = countPlanSteps(planContent);
|
|
724
|
+
if (totalSteps === 0) {
|
|
725
|
+
failed.push({ note: "missing valid Step headers", taskId: task.id });
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
const updatedMeta = updatePlanApprovalBlock(
|
|
729
|
+
await readFile4(metaPath, "utf8"),
|
|
730
|
+
approvedAt,
|
|
731
|
+
totalSteps
|
|
732
|
+
);
|
|
733
|
+
await writeFile2(metaPath, updatedMeta, "utf8");
|
|
734
|
+
succeeded.push({ note: "moved to IN_PROGRESS", steps: totalSteps, taskId: task.id });
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
approvedAt,
|
|
738
|
+
failed,
|
|
739
|
+
nextStatus: "IN_PROGRESS",
|
|
740
|
+
succeeded
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
async function loadPlanApprovalCandidates(projectRoot, input) {
|
|
744
|
+
const tasks = await listWorkspaceTasks(projectRoot);
|
|
745
|
+
return resolvePlanApprovalCandidates(tasks, input.target);
|
|
746
|
+
}
|
|
747
|
+
function createPlanApprovedAt(date = /* @__PURE__ */ new Date()) {
|
|
748
|
+
return formatUtcTimestamp(date);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// src/commands/plan-approve.ts
|
|
752
|
+
function padCell2(value, width) {
|
|
753
|
+
return value.padEnd(width, " ");
|
|
754
|
+
}
|
|
755
|
+
function buildResultTable(result) {
|
|
756
|
+
const rows = [
|
|
757
|
+
...result.succeeded.map((row) => ({
|
|
758
|
+
note: row.note,
|
|
759
|
+
result: "success",
|
|
760
|
+
steps: String(row.steps),
|
|
761
|
+
task: row.taskId
|
|
762
|
+
})),
|
|
763
|
+
...result.failed.map((row) => ({
|
|
764
|
+
note: row.note,
|
|
765
|
+
result: "failed",
|
|
766
|
+
steps: "-",
|
|
767
|
+
task: row.taskId
|
|
768
|
+
}))
|
|
769
|
+
];
|
|
770
|
+
const resultWidth = Math.max("Result".length, ...rows.map((row) => row.result.length));
|
|
771
|
+
const taskWidth = Math.max("Task".length, ...rows.map((row) => row.task.length));
|
|
772
|
+
const stepsWidth = Math.max("Steps".length, ...rows.map((row) => row.steps.length));
|
|
773
|
+
const noteWidth = Math.max("Note".length, ...rows.map((row) => row.note.length));
|
|
774
|
+
const border = `+-${"-".repeat(resultWidth)}-+-${"-".repeat(taskWidth)}-+-${"-".repeat(stepsWidth)}-+-${"-".repeat(noteWidth)}-+`;
|
|
775
|
+
const header = `| ${padCell2("Result", resultWidth)} | ${padCell2("Task", taskWidth)} | ${padCell2("Steps", stepsWidth)} | ${padCell2("Note", noteWidth)} |`;
|
|
776
|
+
const body = rows.map(
|
|
777
|
+
(row) => `| ${padCell2(row.result, resultWidth)} | ${padCell2(row.task, taskWidth)} | ${padCell2(row.steps, stepsWidth)} | ${padCell2(row.note, noteWidth)} |`
|
|
778
|
+
);
|
|
779
|
+
return [border, header, border, ...body, border].join("\n");
|
|
780
|
+
}
|
|
781
|
+
function formatTaskLabel(task) {
|
|
782
|
+
return `${task.id} (${task.status})`;
|
|
783
|
+
}
|
|
784
|
+
function formatSuccess(result) {
|
|
785
|
+
const lines = [buildResultTable(result)];
|
|
786
|
+
if (result.succeeded.length > 0) {
|
|
787
|
+
lines.push("", "\uC0C1\uD0DC: IN_PROGRESS \u2192 \uC791\uC5C5\uC744 \uC2DC\uC791\uD569\uB2C8\uB2E4.");
|
|
788
|
+
}
|
|
789
|
+
return lines.join("\n");
|
|
790
|
+
}
|
|
791
|
+
async function selectTargets(tasks) {
|
|
792
|
+
if (tasks.length <= 1 || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
793
|
+
return [...tasks];
|
|
794
|
+
}
|
|
795
|
+
const selectedIds = await checkbox2({
|
|
796
|
+
message: "Select tasks to approve plan for",
|
|
797
|
+
choices: tasks.map((task) => ({ checked: true, name: formatTaskLabel(task), value: task.id }))
|
|
798
|
+
});
|
|
799
|
+
return tasks.filter((task) => selectedIds.includes(task.id));
|
|
800
|
+
}
|
|
801
|
+
async function runPlanApproveCommand(input, projectRoot) {
|
|
802
|
+
try {
|
|
803
|
+
const candidates = await loadPlanApprovalCandidates(projectRoot, input);
|
|
804
|
+
if (candidates.length === 0) {
|
|
805
|
+
throw new Error("No matching tasks awaiting plan approval.");
|
|
806
|
+
}
|
|
807
|
+
if (input.target !== void 0 && candidates.length > 1 && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
808
|
+
throw new Error(
|
|
809
|
+
"Multiple matching tasks found; rerun interactively to choose approval targets."
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
const selectedTasks = await selectTargets(candidates);
|
|
813
|
+
if (selectedTasks.length === 0) {
|
|
814
|
+
throw new Error("No tasks selected for plan approval.");
|
|
815
|
+
}
|
|
816
|
+
const result = await approvePlans(projectRoot, selectedTasks, createPlanApprovedAt());
|
|
817
|
+
if (result.succeeded.length === 0) {
|
|
818
|
+
return {
|
|
819
|
+
exitCode: 1,
|
|
820
|
+
stderr: buildResultTable(result),
|
|
821
|
+
stdout: ""
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
exitCode: result.failed.length > 0 ? 1 : 0,
|
|
826
|
+
stderr: result.failed.length > 0 ? "" : "",
|
|
827
|
+
stdout: formatSuccess(result)
|
|
828
|
+
};
|
|
829
|
+
} catch (error) {
|
|
830
|
+
return {
|
|
831
|
+
exitCode: 1,
|
|
832
|
+
stderr: error instanceof Error ? error.message : "Unknown plan approval failure.",
|
|
833
|
+
stdout: ""
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// src/commands/spec-approve.ts
|
|
839
|
+
import { checkbox as checkbox3 } from "@inquirer/prompts";
|
|
840
|
+
|
|
841
|
+
// src/core/spec-approve.ts
|
|
842
|
+
import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
|
|
843
|
+
import { join as join6 } from "path";
|
|
844
|
+
function filterApprovalCandidates(tasks) {
|
|
845
|
+
return tasks.filter((task) => task.status === "PENDING_SPEC_APPROVAL");
|
|
846
|
+
}
|
|
847
|
+
function resolveTargetCandidates(tasks, target) {
|
|
848
|
+
const candidates = filterApprovalCandidates(tasks);
|
|
849
|
+
if (target === void 0 || target.trim() === "") {
|
|
850
|
+
return candidates;
|
|
851
|
+
}
|
|
852
|
+
const trimmedTarget = target.trim();
|
|
853
|
+
return candidates.filter(
|
|
854
|
+
(task) => task.id === trimmedTarget || task.slug === trimmedTarget || task.id.endsWith(trimmedTarget)
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
function validateSpecApprovalTargets(tasks) {
|
|
858
|
+
if (tasks.length === 0) {
|
|
859
|
+
throw new Error("No approvable spec tasks found.");
|
|
860
|
+
}
|
|
861
|
+
const invalidTask = tasks.find((task) => task.status !== "PENDING_SPEC_APPROVAL");
|
|
862
|
+
if (invalidTask !== void 0) {
|
|
863
|
+
throw new Error(
|
|
864
|
+
`Task ${invalidTask.id} is not awaiting spec approval (${invalidTask.status}).`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
function updateSpecApprovalBlock(metaContent, approvedAt) {
|
|
869
|
+
const withStatus = metaContent.replace(/^status:\s+.+$/m, "status: SPEC_APPROVED");
|
|
870
|
+
return withStatus.replace(
|
|
871
|
+
/spec:\n {2}approved:\s+false\n {2}approved_at:\s+null/m,
|
|
872
|
+
`spec:
|
|
873
|
+
approved: true
|
|
874
|
+
approved_at: ${approvedAt}`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
async function approveSpecs(projectRoot, tasks, approvedAt) {
|
|
878
|
+
validateSpecApprovalTargets(tasks);
|
|
879
|
+
for (const task of tasks) {
|
|
880
|
+
const metaPath = join6(projectRoot, task.path, "meta.yml");
|
|
881
|
+
if (await getFsEntryKind(metaPath) !== "file") {
|
|
882
|
+
throw new Error(`Missing meta.yml for task ${task.id}.`);
|
|
883
|
+
}
|
|
884
|
+
const updatedContent = updateSpecApprovalBlock(await readFile5(metaPath, "utf8"), approvedAt);
|
|
885
|
+
await writeFile3(metaPath, updatedContent, "utf8");
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
approvedAt,
|
|
889
|
+
approvedTaskIds: tasks.map((task) => task.id),
|
|
890
|
+
nextStatus: "SPEC_APPROVED"
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
async function loadSpecApprovalCandidates(projectRoot, input) {
|
|
894
|
+
const tasks = await listWorkspaceTasks(projectRoot);
|
|
895
|
+
return resolveTargetCandidates(tasks, input.target);
|
|
896
|
+
}
|
|
897
|
+
function createSpecApprovedAt(date = /* @__PURE__ */ new Date()) {
|
|
898
|
+
return formatUtcTimestamp(date);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// src/commands/spec-approve.ts
|
|
902
|
+
function formatTaskLabel2(task) {
|
|
903
|
+
return `${task.id} (${task.status})`;
|
|
904
|
+
}
|
|
905
|
+
function formatSuccess2(result, tasks) {
|
|
906
|
+
const lines = ["\uC2A4\uD399 \uC2B9\uC778\uB428"];
|
|
907
|
+
for (const task of tasks) {
|
|
908
|
+
lines.push(`- ${task.path} -> ${result.nextStatus}`);
|
|
909
|
+
}
|
|
910
|
+
lines.push("\uC0C1\uD0DC: SPEC_APPROVED \u2192 \uD50C\uB79C \uC791\uC131\uC744 \uC2DC\uC791\uD569\uB2C8\uB2E4.");
|
|
911
|
+
return lines.join("\n");
|
|
912
|
+
}
|
|
913
|
+
async function selectTargets2(tasks) {
|
|
914
|
+
if (tasks.length <= 1 || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
915
|
+
return [...tasks];
|
|
916
|
+
}
|
|
917
|
+
const selectedIds = await checkbox3({
|
|
918
|
+
message: "Select tasks to approve",
|
|
919
|
+
choices: tasks.map((task) => ({
|
|
920
|
+
checked: true,
|
|
921
|
+
name: formatTaskLabel2(task),
|
|
922
|
+
value: task.id
|
|
923
|
+
}))
|
|
924
|
+
});
|
|
925
|
+
return tasks.filter((task) => selectedIds.includes(task.id));
|
|
926
|
+
}
|
|
927
|
+
async function runSpecApproveCommand(input, projectRoot) {
|
|
928
|
+
try {
|
|
929
|
+
const candidates = await loadSpecApprovalCandidates(projectRoot, input);
|
|
930
|
+
if (candidates.length === 0) {
|
|
931
|
+
throw new Error("No matching tasks awaiting spec approval.");
|
|
932
|
+
}
|
|
933
|
+
if (input.target !== void 0 && candidates.length > 1 && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
934
|
+
throw new Error(
|
|
935
|
+
"Multiple matching tasks found; rerun interactively to choose approval targets."
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
const selectedTasks = await selectTargets2(candidates);
|
|
939
|
+
if (selectedTasks.length === 0) {
|
|
940
|
+
throw new Error("No tasks selected for spec approval.");
|
|
941
|
+
}
|
|
942
|
+
const result = await approveSpecs(projectRoot, selectedTasks, createSpecApprovedAt());
|
|
943
|
+
return {
|
|
944
|
+
exitCode: 0,
|
|
945
|
+
stderr: "",
|
|
946
|
+
stdout: formatSuccess2(result, selectedTasks)
|
|
947
|
+
};
|
|
948
|
+
} catch (error) {
|
|
949
|
+
return {
|
|
950
|
+
exitCode: 1,
|
|
951
|
+
stderr: error instanceof Error ? error.message : "Unknown spec approval failure.",
|
|
952
|
+
stdout: ""
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// src/core/start.ts
|
|
958
|
+
import { mkdir as mkdir3, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
|
|
959
|
+
import { join as join7 } from "path";
|
|
960
|
+
function normalizeSlug(input) {
|
|
961
|
+
return input.trim().toLowerCase().replace(/[_\s]+/g, "-").replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
962
|
+
}
|
|
963
|
+
function validateSlug(slug) {
|
|
964
|
+
if (slug === "") {
|
|
965
|
+
throw new Error("Invalid slug: slug cannot be empty after normalization.");
|
|
966
|
+
}
|
|
967
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
|
968
|
+
throw new Error("Invalid slug: use lowercase kebab-case only.");
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
function createWorkspaceId(date, type, slug) {
|
|
972
|
+
const year = String(date.getUTCFullYear());
|
|
973
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
974
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
975
|
+
const hour = String(date.getUTCHours()).padStart(2, "0");
|
|
976
|
+
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
|
977
|
+
return `${year}${month}${day}-${hour}${minute}-${type}-${slug}`;
|
|
978
|
+
}
|
|
979
|
+
function renderInitialMeta(input) {
|
|
980
|
+
return [
|
|
981
|
+
`id: ${input.id}`,
|
|
982
|
+
`type: ${input.type}`,
|
|
983
|
+
`slug: ${input.slug}`,
|
|
984
|
+
`created_at: ${input.createdAt}`,
|
|
985
|
+
"",
|
|
986
|
+
"status: PENDING_SPEC_APPROVAL",
|
|
987
|
+
"",
|
|
988
|
+
"spec:",
|
|
989
|
+
" approved: false",
|
|
990
|
+
" approved_at: null",
|
|
991
|
+
"",
|
|
992
|
+
"plan:",
|
|
993
|
+
" approved: false",
|
|
994
|
+
" approved_at: null",
|
|
995
|
+
"",
|
|
996
|
+
"steps:",
|
|
997
|
+
" total: null",
|
|
998
|
+
" completed: []",
|
|
999
|
+
"",
|
|
1000
|
+
"completed_at: null",
|
|
1001
|
+
""
|
|
1002
|
+
].join("\n");
|
|
1003
|
+
}
|
|
1004
|
+
async function resolveSpecTemplatePath(type) {
|
|
1005
|
+
const assetsRoot = await getBundledAssetsRoot();
|
|
1006
|
+
return join7(assetsRoot, resolveSpecTemplateRelativePath(type));
|
|
1007
|
+
}
|
|
1008
|
+
function applyTemplateDefaults(template, type, slug, currentDate) {
|
|
1009
|
+
const displayName = slug.replace(/-/g, " ");
|
|
1010
|
+
return template.replace(/\{기능명\}/g, displayName).replace(/\{버그 요약 한 줄\}/g, displayName).replace(/YYYY-MM-DD/g, formatUtcDate(currentDate)).replace(/> \*\*작성자:\*\*\s*$/m, "> **\uC791\uC131\uC790:** taehee").replace(/> \*\*연관 티켓:\*\*\s*$/m, "> **\uC5F0\uAD00 \uD2F0\uCF13:** -").replace(/^# \[(feature|fix|refactor|chore|build)\] .*/m, `# [${type}] ${displayName}`);
|
|
1011
|
+
}
|
|
1012
|
+
async function startTask(rawType, rawSlug, projectRoot, currentDate = /* @__PURE__ */ new Date()) {
|
|
1013
|
+
if (!isSupportedTaskType(rawType)) {
|
|
1014
|
+
throw new Error(`Unsupported type: ${rawType}`);
|
|
1015
|
+
}
|
|
1016
|
+
const slug = normalizeSlug(rawSlug);
|
|
1017
|
+
validateSlug(slug);
|
|
1018
|
+
const activeTask = await findActiveTask(projectRoot);
|
|
1019
|
+
if (activeTask !== null) {
|
|
1020
|
+
throw new Error(
|
|
1021
|
+
`Active task exists: ${activeTask.id} (${activeTask.status}) at ${activeTask.path}. Finish or approve it before starting a new task.`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
const workspaceId = createWorkspaceId(currentDate, rawType, slug);
|
|
1025
|
+
const workspacePath = join7("sduck-workspace", workspaceId);
|
|
1026
|
+
const absoluteWorkspacePath = join7(projectRoot, workspacePath);
|
|
1027
|
+
if (await getFsEntryKind(absoluteWorkspacePath) !== "missing") {
|
|
1028
|
+
throw new Error(`Workspace already exists: ${workspacePath}`);
|
|
1029
|
+
}
|
|
1030
|
+
const workspaceRoot = join7(projectRoot, "sduck-workspace");
|
|
1031
|
+
await mkdir3(workspaceRoot, { recursive: true });
|
|
1032
|
+
await mkdir3(absoluteWorkspacePath, { recursive: false });
|
|
1033
|
+
const templatePath = await resolveSpecTemplatePath(rawType);
|
|
1034
|
+
if (await getFsEntryKind(templatePath) !== "file") {
|
|
1035
|
+
throw new Error(`Missing spec template for type '${rawType}' at ${templatePath}`);
|
|
1036
|
+
}
|
|
1037
|
+
const specTemplate = await readFile6(templatePath, "utf8");
|
|
1038
|
+
const specContent = applyTemplateDefaults(specTemplate, rawType, slug, currentDate);
|
|
1039
|
+
const metaContent = renderInitialMeta({
|
|
1040
|
+
createdAt: formatUtcTimestamp(currentDate),
|
|
1041
|
+
id: workspaceId,
|
|
1042
|
+
slug,
|
|
1043
|
+
type: rawType
|
|
1044
|
+
});
|
|
1045
|
+
await writeFile4(join7(absoluteWorkspacePath, "meta.yml"), metaContent, "utf8");
|
|
1046
|
+
await writeFile4(join7(absoluteWorkspacePath, "spec.md"), specContent, "utf8");
|
|
1047
|
+
await writeFile4(join7(absoluteWorkspacePath, "plan.md"), "", "utf8");
|
|
1048
|
+
return {
|
|
1049
|
+
workspaceId,
|
|
1050
|
+
workspacePath,
|
|
1051
|
+
status: "PENDING_SPEC_APPROVAL"
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/commands/start.ts
|
|
1056
|
+
async function runStartCommand(type, slug, projectRoot) {
|
|
1057
|
+
try {
|
|
1058
|
+
const result = await startTask(type, slug, projectRoot);
|
|
1059
|
+
return {
|
|
1060
|
+
exitCode: 0,
|
|
1061
|
+
stderr: "",
|
|
1062
|
+
stdout: [
|
|
1063
|
+
"\uC791\uC5C5 \uB514\uB809\uD1A0\uB9AC \uC0DD\uC131\uB428",
|
|
1064
|
+
`\uACBD\uB85C: ${result.workspacePath}/`,
|
|
1065
|
+
`\uC0C1\uD0DC: ${result.status}`
|
|
1066
|
+
].join("\n")
|
|
1067
|
+
};
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
return {
|
|
1070
|
+
exitCode: 1,
|
|
1071
|
+
stderr: error instanceof Error ? error.message : "Unknown start failure.",
|
|
1072
|
+
stdout: ""
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/core/command-metadata.ts
|
|
1078
|
+
var CLI_NAME = "sduck";
|
|
1079
|
+
var CLI_DESCRIPTION = "Spec-Driven Development workflow bootstrap CLI";
|
|
1080
|
+
var PLACEHOLDER_MESSAGE = "Core workflow commands are planned but not implemented in this bootstrap yet.";
|
|
1081
|
+
function normalizeCommandName(input) {
|
|
1082
|
+
return input.trim().toLowerCase().replace(/\s+/g, "-");
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// src/cli.ts
|
|
1086
|
+
var program = new Command();
|
|
1087
|
+
program.name(CLI_NAME).description(CLI_DESCRIPTION).version("0.1.0");
|
|
1088
|
+
program.command("init").description("Initialize the current repository for the SDD workflow").option("--force", "Regenerate the bundled assets in sduck-assets").option(
|
|
1089
|
+
"--agents <agents>",
|
|
1090
|
+
"Comma-separated agents (claude-code,codex,opencode,gemini-cli,cursor,antigravity)"
|
|
1091
|
+
).action(async (options) => {
|
|
1092
|
+
const initOptions = options.agents === void 0 ? { force: options.force ?? false } : { agents: options.agents, force: options.force ?? false };
|
|
1093
|
+
const result = await runInitCommand(initOptions, process.cwd());
|
|
1094
|
+
if (result.stdout !== "") {
|
|
1095
|
+
console.log(result.stdout);
|
|
1096
|
+
}
|
|
1097
|
+
if (result.stderr !== "") {
|
|
1098
|
+
console.error(result.stderr);
|
|
1099
|
+
}
|
|
1100
|
+
if (result.exitCode !== 0) {
|
|
1101
|
+
process.exitCode = result.exitCode;
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
program.command("preview <name>").description("Print the normalized command name for bootstrap verification").action((name) => {
|
|
1105
|
+
console.log(normalizeCommandName(name));
|
|
1106
|
+
});
|
|
1107
|
+
program.command("start <type> <slug>").description("Create a new task workspace from a type template").action(async (type, slug) => {
|
|
1108
|
+
const result = await runStartCommand(type, slug, process.cwd());
|
|
1109
|
+
if (result.stdout !== "") {
|
|
1110
|
+
console.log(result.stdout);
|
|
1111
|
+
}
|
|
1112
|
+
if (result.stderr !== "") {
|
|
1113
|
+
console.error(result.stderr);
|
|
1114
|
+
}
|
|
1115
|
+
if (result.exitCode !== 0) {
|
|
1116
|
+
process.exitCode = result.exitCode;
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
program.command("spec").description("Manage spec workflow state").command("approve [target]").description("Approve a task spec and move it to plan writing").action(async (target) => {
|
|
1120
|
+
const input = target === void 0 ? {} : { target };
|
|
1121
|
+
const result = await runSpecApproveCommand(input, process.cwd());
|
|
1122
|
+
if (result.stdout !== "") {
|
|
1123
|
+
console.log(result.stdout);
|
|
1124
|
+
}
|
|
1125
|
+
if (result.stderr !== "") {
|
|
1126
|
+
console.error(result.stderr);
|
|
1127
|
+
}
|
|
1128
|
+
if (result.exitCode !== 0) {
|
|
1129
|
+
process.exitCode = result.exitCode;
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
program.command("plan").description("Manage plan workflow state").command("approve [target]").description("Approve a task plan and move it to implementation").action(async (target) => {
|
|
1133
|
+
const input = target === void 0 ? {} : { target };
|
|
1134
|
+
const result = await runPlanApproveCommand(input, process.cwd());
|
|
1135
|
+
if (result.stdout !== "") {
|
|
1136
|
+
console.log(result.stdout);
|
|
1137
|
+
}
|
|
1138
|
+
if (result.stderr !== "") {
|
|
1139
|
+
console.error(result.stderr);
|
|
1140
|
+
}
|
|
1141
|
+
if (result.exitCode !== 0) {
|
|
1142
|
+
process.exitCode = result.exitCode;
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
program.command("roadmap").description("Show the current bootstrap status").action(() => {
|
|
1146
|
+
console.log(PLACEHOLDER_MESSAGE);
|
|
1147
|
+
});
|
|
1148
|
+
await program.parseAsync(process.argv);
|
|
1149
|
+
//# sourceMappingURL=cli.js.map
|