@negikirin/repo-pattern 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/CLAUDE.md +61 -0
- package/.claude/settings.example.json +40 -0
- package/.claude/settings.local.example.json +24 -0
- package/.github/workflows/nodejs-package.yml +60 -0
- package/.repo-pattern.json +25 -0
- package/.repo-pattern.lock.json +23 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/THIRD_PARTY_NOTICES.md +51 -0
- package/docs/repo-pattern/setup-guide.md +725 -0
- package/docs/repo-pattern/workflow.md +429 -0
- package/mcp/profiles/backend.json +7 -0
- package/mcp/profiles/full.json +11 -0
- package/mcp/profiles/minimal.json +6 -0
- package/mcp/profiles/research.json +8 -0
- package/mcp/profiles/web.json +8 -0
- package/mcp/servers/chrome-devtools.json +10 -0
- package/mcp/servers/context7.json +13 -0
- package/mcp/servers/filesystem.json +11 -0
- package/mcp/servers/gitnexus.json +11 -0
- package/mcp/servers/playwright.json +12 -0
- package/mcp/servers/sequential-thinking.json +10 -0
- package/mcp/servers/tavily.json +13 -0
- package/package.json +71 -0
- package/scripts/lib/audit.mjs +127 -0
- package/scripts/lib/cleanup.mjs +35 -0
- package/scripts/lib/doctor.mjs +86 -0
- package/scripts/lib/ecc-rules.mjs +98 -0
- package/scripts/lib/ecc.mjs +61 -0
- package/scripts/lib/fs-utils.mjs +133 -0
- package/scripts/lib/mcp.mjs +203 -0
- package/scripts/lib/project-detect.mjs +107 -0
- package/scripts/lib/prompt.mjs +226 -0
- package/scripts/lib/provision.mjs +184 -0
- package/scripts/lib/rules.mjs +142 -0
- package/scripts/lib/setup.mjs +299 -0
- package/scripts/lib/skills.mjs +249 -0
- package/scripts/repo-pattern.mjs +156 -0
- package/scripts/self-check.mjs +175 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { appendGitignoreLine, backupPaths, copyRecursive, ensureDir, exists, readJson, removePath, writeJson } from "./fs-utils.mjs";
|
|
6
|
+
import { isInteractive, printSummary, withSpinner } from "./prompt.mjs";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export const OPTIONAL_SKILLS = [
|
|
11
|
+
{
|
|
12
|
+
value: "taste",
|
|
13
|
+
label: "taste",
|
|
14
|
+
description: "UI taste/design skills (MIT)",
|
|
15
|
+
source: "https://github.com/Leonxlnx/taste-skill.git",
|
|
16
|
+
revision: "b17742737e796305d829b3ad39eda3add0d79060",
|
|
17
|
+
license: "MIT",
|
|
18
|
+
installedDirs: ["brandkit", "brutalist-skill", "gpt-tasteskill", "imagegen-frontend-mobile", "imagegen-frontend-web", "image-to-code-skill", "minimalist-skill", "output-skill", "redesign-skill", "soft-skill", "stitch-skill", "taste-skill", "taste-skill-v1"],
|
|
19
|
+
sourceDir: "skills"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
value: "document-specialist",
|
|
23
|
+
label: "document-specialist",
|
|
24
|
+
description: "documentation specialist skill (license not declared; user opt-in only)",
|
|
25
|
+
source: "https://github.com/SpillwaveSolutions/document-specialist-skill.git",
|
|
26
|
+
revision: "4d50d302b9f40e8eafec72d78a86676cdd9511ac",
|
|
27
|
+
license: "NOASSERTION",
|
|
28
|
+
installedDirs: ["document-specialist-skill"],
|
|
29
|
+
destName: "document-specialist-skill"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
value: "ui-ux-pro-max",
|
|
33
|
+
label: "ui-ux-pro-max",
|
|
34
|
+
description: "UI/UX design intelligence skill (MIT; requires Python 3.x)",
|
|
35
|
+
source: "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
|
36
|
+
revision: "4baa399d00da806f83ed93652172f66943205153",
|
|
37
|
+
license: "MIT",
|
|
38
|
+
installedDirs: ["ui-ux-pro-max"],
|
|
39
|
+
sourceSubdir: ".claude/skills/ui-ux-pro-max",
|
|
40
|
+
destName: "ui-ux-pro-max"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
value: "impeccable",
|
|
44
|
+
label: "impeccable",
|
|
45
|
+
description: "visual design QA skill-only install (Apache-2.0; scripts require Node >=24)",
|
|
46
|
+
source: "https://github.com/pbakaus/impeccable.git",
|
|
47
|
+
revision: "88f52ac4e6a5ce99d39a0f5d89e7ac3a168910f5",
|
|
48
|
+
license: "Apache-2.0",
|
|
49
|
+
installedDirs: ["impeccable"],
|
|
50
|
+
sourceSubdir: ".claude/skills/impeccable",
|
|
51
|
+
destName: "impeccable",
|
|
52
|
+
partialClone: true
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
value: "huashu-design",
|
|
56
|
+
label: "huashu-design",
|
|
57
|
+
description: "multimedia design skill (MIT; may require Node, Playwright, Python, ffmpeg)",
|
|
58
|
+
source: "https://github.com/alchaincyf/huashu-design.git",
|
|
59
|
+
revision: "0e7ec8aca0058184c1a9e06e57697e84f68a3f0f",
|
|
60
|
+
license: "MIT",
|
|
61
|
+
installedDirs: ["huashu-design"],
|
|
62
|
+
includePaths: ["SKILL.md", "assets", "references", "scripts", "demos", "package.json", "package-lock.json", "README.md", "README.en.md", "LICENSE"],
|
|
63
|
+
destName: "huashu-design",
|
|
64
|
+
partialClone: true
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function knownSkill(value) {
|
|
69
|
+
return OPTIONAL_SKILLS.find((skill) => skill.value === value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function normalizeOptionalSkills(skills = []) {
|
|
73
|
+
return [...new Set((skills || []).filter(Boolean))];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function invalidOptionalSkills(skills = []) {
|
|
77
|
+
return normalizeOptionalSkills(skills).filter((skill) => !knownSkill(skill));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function expectedOptionalSkillDirs(optionalSkills = []) {
|
|
81
|
+
return [...new Set(optionalSkills.flatMap((entry) => {
|
|
82
|
+
const skill = knownSkill(entry?.name);
|
|
83
|
+
if (!skill || skill.source !== entry?.source || skill.revision !== entry?.revision) return [];
|
|
84
|
+
return skill.installedDirs;
|
|
85
|
+
}))].sort();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runGit(args, cwd, { quiet = false } = {}) {
|
|
89
|
+
execFileSync("git", args, {
|
|
90
|
+
cwd,
|
|
91
|
+
stdio: quiet ? "ignore" : "inherit"
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function gitOutput(args, cwd) {
|
|
96
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runGitAsync(args, cwd) {
|
|
100
|
+
await execFileAsync("git", args, { cwd });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function verifyRevision(cacheDir, skill) {
|
|
104
|
+
const actual = gitOutput(["rev-parse", "HEAD"], cacheDir);
|
|
105
|
+
if (actual !== skill.revision) throw new Error(`${skill.value} cache revision mismatch: expected ${skill.revision}, got ${actual}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function rejectSymlinks(root) {
|
|
109
|
+
const stat = await fs.lstat(root);
|
|
110
|
+
if (stat.isSymbolicLink()) throw new Error(`Optional skill source contains symlink: ${root}`);
|
|
111
|
+
if (!stat.isDirectory()) return;
|
|
112
|
+
|
|
113
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
await rejectSymlinks(path.join(root, entry.name));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function syncSkillCache(target, skill, { dryRun = false } = {}) {
|
|
120
|
+
const cacheRoot = path.join(target, ".repo-pattern", "cache", "skills");
|
|
121
|
+
const cacheDir = path.join(cacheRoot, skill.value);
|
|
122
|
+
const cloneArgs = ["clone", ...(skill.partialClone ? ["--filter=blob:none"] : []), skill.source, cacheDir];
|
|
123
|
+
|
|
124
|
+
if (exists(path.join(cacheDir, ".git"))) {
|
|
125
|
+
if (!dryRun) {
|
|
126
|
+
runGit(["fetch", "--quiet", "origin", skill.revision], cacheDir);
|
|
127
|
+
runGit(["checkout", "--quiet", skill.revision], cacheDir);
|
|
128
|
+
verifyRevision(cacheDir, skill);
|
|
129
|
+
}
|
|
130
|
+
return cacheDir;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (dryRun) {
|
|
134
|
+
console.log(`[dry-run] git ${cloneArgs.join(" ")}`);
|
|
135
|
+
console.log(`[dry-run] git -C ${cacheDir} checkout ${skill.revision}`);
|
|
136
|
+
return cacheDir;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await ensureDir(cacheRoot);
|
|
140
|
+
if (isInteractive()) {
|
|
141
|
+
await withSpinner(`Syncing ${skill.value}`, async () => {
|
|
142
|
+
await runGitAsync(["clone", "--quiet", ...cloneArgs.slice(1)], target);
|
|
143
|
+
await runGitAsync(["checkout", "--quiet", skill.revision], cacheDir);
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
console.log(`Syncing ${skill.value} from ${skill.source}`);
|
|
147
|
+
runGit(cloneArgs, target);
|
|
148
|
+
runGit(["checkout", "--quiet", skill.revision], cacheDir);
|
|
149
|
+
}
|
|
150
|
+
verifyRevision(cacheDir, skill);
|
|
151
|
+
return cacheDir;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function copySkill(skill, cacheDir, destRoot, { dryRun = false } = {}) {
|
|
155
|
+
if (skill.includePaths) {
|
|
156
|
+
const dest = path.join(destRoot, skill.destName);
|
|
157
|
+
for (const relPath of skill.includePaths) {
|
|
158
|
+
const sourcePath = path.join(cacheDir, relPath);
|
|
159
|
+
if (!dryRun && !exists(sourcePath)) throw new Error(`${skill.value} source path not found: ${relPath}`);
|
|
160
|
+
if (!dryRun) await rejectSymlinks(sourcePath);
|
|
161
|
+
await copyRecursive(sourcePath, path.join(dest, relPath), { dryRun });
|
|
162
|
+
}
|
|
163
|
+
return [skill.destName];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (skill.sourceSubdir) {
|
|
167
|
+
const sourceDir = path.join(cacheDir, skill.sourceSubdir);
|
|
168
|
+
if (!dryRun && !exists(sourceDir)) throw new Error(`${skill.value} source dir not found: ${skill.sourceSubdir}`);
|
|
169
|
+
if (!dryRun) await rejectSymlinks(sourceDir);
|
|
170
|
+
const dest = path.join(destRoot, skill.destName);
|
|
171
|
+
await copyRecursive(sourceDir, dest, { dryRun });
|
|
172
|
+
return [skill.destName];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (skill.sourceDir) {
|
|
176
|
+
const sourceDir = path.join(cacheDir, skill.sourceDir);
|
|
177
|
+
if (!dryRun && !exists(sourceDir)) throw new Error(`${skill.value} source dir not found: ${skill.sourceDir}`);
|
|
178
|
+
if (dryRun) {
|
|
179
|
+
await copyRecursive(sourceDir, destRoot, { dryRun });
|
|
180
|
+
return [skill.sourceDir];
|
|
181
|
+
}
|
|
182
|
+
await rejectSymlinks(sourceDir);
|
|
183
|
+
const installedDirs = [];
|
|
184
|
+
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
185
|
+
for (const entry of entries.filter((item) => item.isDirectory())) {
|
|
186
|
+
await copyRecursive(path.join(sourceDir, entry.name), path.join(destRoot, entry.name), { dryRun });
|
|
187
|
+
installedDirs.push(entry.name);
|
|
188
|
+
}
|
|
189
|
+
return installedDirs;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await rejectSymlinks(cacheDir);
|
|
193
|
+
const dest = path.join(destRoot, skill.destName);
|
|
194
|
+
await copyRecursive(cacheDir, dest, { dryRun });
|
|
195
|
+
await removePath(path.join(dest, ".git"), { dryRun });
|
|
196
|
+
return [skill.destName];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function applyOptionalSkills({ target, skills = [], dryRun = false }) {
|
|
200
|
+
const selected = normalizeOptionalSkills(skills);
|
|
201
|
+
const invalid = invalidOptionalSkills(selected);
|
|
202
|
+
if (invalid.length > 0) throw new Error(`Unknown optional skill(s): ${invalid.join(", ")}`);
|
|
203
|
+
if (selected.length === 0) return { selectedSkills: [], appliedSkills: [] };
|
|
204
|
+
|
|
205
|
+
const chosen = selected.map(knownSkill);
|
|
206
|
+
const destRoot = path.join(target, ".claude", "skills");
|
|
207
|
+
await backupPaths(target, [".claude/skills"], { dryRun });
|
|
208
|
+
await removePath(destRoot, { dryRun });
|
|
209
|
+
await ensureDir(destRoot, { dryRun });
|
|
210
|
+
await appendGitignoreLine(target, ".repo-pattern/", { dryRun });
|
|
211
|
+
|
|
212
|
+
const appliedSkills = [];
|
|
213
|
+
for (const skill of chosen) {
|
|
214
|
+
const cacheDir = await syncSkillCache(target, skill, { dryRun });
|
|
215
|
+
const installedDirs = await copySkill(skill, cacheDir, destRoot, { dryRun });
|
|
216
|
+
appliedSkills.push({ name: skill.value, source: skill.source, revision: skill.revision, license: skill.license, installedDirs });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const repoPatternDir = path.join(target, ".repo-pattern");
|
|
220
|
+
if (dryRun) {
|
|
221
|
+
console.log(`[dry-run] rm -rf ${path.join(repoPatternDir, "cache")}`);
|
|
222
|
+
console.log(`[dry-run] rmdir ${repoPatternDir} if empty`);
|
|
223
|
+
} else {
|
|
224
|
+
await removePath(path.join(repoPatternDir, "cache"));
|
|
225
|
+
try {
|
|
226
|
+
await fs.rmdir(repoPatternDir);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const repoConfigPath = path.join(target, ".repo-pattern.json");
|
|
233
|
+
const repoConfig = await readJson(repoConfigPath, {});
|
|
234
|
+
repoConfig.runtime = { ...(repoConfig.runtime || {}), localSkills: true };
|
|
235
|
+
repoConfig.optionalSkills = appliedSkills.map(({ name, source, revision, license, installedDirs }) => ({ name, source, revision, license, installedDirs }));
|
|
236
|
+
await writeJson(repoConfigPath, repoConfig, { dryRun });
|
|
237
|
+
|
|
238
|
+
const lockPath = path.join(target, ".repo-pattern.lock.json");
|
|
239
|
+
const lock = await readJson(lockPath, {});
|
|
240
|
+
lock.optionalSkills = { appliedSkills, appliedAt: new Date().toISOString() };
|
|
241
|
+
await writeJson(lockPath, lock, { dryRun });
|
|
242
|
+
|
|
243
|
+
printSummary("Applied optional skills", [
|
|
244
|
+
["Path", path.relative(target, destRoot)],
|
|
245
|
+
["Skills", selected.join(", ")]
|
|
246
|
+
]);
|
|
247
|
+
|
|
248
|
+
return { selectedSkills: selected, appliedSkills };
|
|
249
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { auditProject, printAudit } from "./lib/audit.mjs";
|
|
5
|
+
import { cleanupProject } from "./lib/cleanup.mjs";
|
|
6
|
+
import { generateMcp } from "./lib/mcp.mjs";
|
|
7
|
+
import { setupEcc } from "./lib/ecc.mjs";
|
|
8
|
+
import { doctorProject } from "./lib/doctor.mjs";
|
|
9
|
+
import { applyEccRules } from "./lib/rules.mjs";
|
|
10
|
+
import { setupProject } from "./lib/setup.mjs";
|
|
11
|
+
import { invalidOptionalSkills } from "./lib/skills.mjs";
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = path.dirname(__filename);
|
|
15
|
+
const sourceRoot = path.resolve(__dirname, "..");
|
|
16
|
+
|
|
17
|
+
function requiredOptionValue(rest, index, arg) {
|
|
18
|
+
const value = rest[index + 1];
|
|
19
|
+
if (!value || value.startsWith("--")) {
|
|
20
|
+
console.error(`Missing value for ${arg}`);
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
let [command, ...rest] = argv;
|
|
28
|
+
if (command === "-h" || command === "--help") {
|
|
29
|
+
command = "help";
|
|
30
|
+
rest = [];
|
|
31
|
+
}
|
|
32
|
+
const options = {
|
|
33
|
+
command: command || "help",
|
|
34
|
+
target: ".",
|
|
35
|
+
profile: "web",
|
|
36
|
+
dryRun: false,
|
|
37
|
+
force: false,
|
|
38
|
+
migrate: false,
|
|
39
|
+
applyRules: false,
|
|
40
|
+
optionalSkills: [],
|
|
41
|
+
yes: false
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < rest.length; i++) {
|
|
45
|
+
const arg = rest[i];
|
|
46
|
+
if (arg === "--target") options.target = requiredOptionValue(rest, i++, arg);
|
|
47
|
+
else if (arg === "--profile") options.profile = requiredOptionValue(rest, i++, arg);
|
|
48
|
+
else if (arg === "--dry-run") options.dryRun = true;
|
|
49
|
+
else if (arg === "--force") options.force = true;
|
|
50
|
+
else if (arg === "--migrate") options.migrate = true;
|
|
51
|
+
else if (arg === "--with-rules") options.applyRules = true;
|
|
52
|
+
else if (arg === "--with-skill") options.optionalSkills.push(requiredOptionValue(rest, i++, arg));
|
|
53
|
+
else if (arg === "--with-skills") options.optionalSkills.push(...requiredOptionValue(rest, i++, arg).split(",").map((value) => value.trim()).filter(Boolean));
|
|
54
|
+
else if (arg === "--yes") options.yes = true;
|
|
55
|
+
else if (arg === "-h" || arg === "--help") options.command = "help";
|
|
56
|
+
else {
|
|
57
|
+
console.error(`Unknown argument: ${arg}`);
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (options.force && options.migrate) {
|
|
63
|
+
console.error("Use either --force or --migrate, not both.");
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const invalidSkills = invalidOptionalSkills(options.optionalSkills);
|
|
68
|
+
if (invalidSkills.length > 0) {
|
|
69
|
+
console.error(`Unknown optional skill(s): ${invalidSkills.join(", ")}. Available: taste, document-specialist, ui-ux-pro-max, impeccable, huashu-design`);
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
options.target = path.resolve(process.cwd(), options.target);
|
|
74
|
+
return options;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function help() {
|
|
78
|
+
console.log(`repo-pattern — minimal ECC-first Claude Code setup
|
|
79
|
+
|
|
80
|
+
Usage:
|
|
81
|
+
repo-pattern help
|
|
82
|
+
repo-pattern setup
|
|
83
|
+
repo-pattern setup --profile web --yes
|
|
84
|
+
repo-pattern setup --profile web --migrate --yes
|
|
85
|
+
repo-pattern setup --with-skill taste --yes
|
|
86
|
+
repo-pattern setup --with-skill ui-ux-pro-max --yes # requires Python 3.x
|
|
87
|
+
repo-pattern setup --with-skills impeccable,huashu-design --yes
|
|
88
|
+
|
|
89
|
+
Advanced:
|
|
90
|
+
repo-pattern mcp --profile web
|
|
91
|
+
repo-pattern ecc
|
|
92
|
+
repo-pattern rules
|
|
93
|
+
repo-pattern audit
|
|
94
|
+
repo-pattern doctor
|
|
95
|
+
repo-pattern cleanup
|
|
96
|
+
|
|
97
|
+
Options:
|
|
98
|
+
--target <path> Target project path. Default: .
|
|
99
|
+
--profile <name> MCP profile for scriptable commands. Default: web
|
|
100
|
+
|
|
101
|
+
Setup UI:
|
|
102
|
+
setup uses ↑/↓ to move, Space to toggle MCP/rules choices, Enter to confirm, Esc/Ctrl+C to cancel
|
|
103
|
+
--dry-run Print actions without writing
|
|
104
|
+
--migrate Take over legacy/local Claude runtime surfaces
|
|
105
|
+
--force Reapply setup over repo-pattern-managed state
|
|
106
|
+
--with-rules Opt in to repo-pattern-managed .claude/rules/ecc
|
|
107
|
+
--with-skill <name> Opt in to external .claude/skills (taste, document-specialist, ui-ux-pro-max, impeccable, huashu-design)
|
|
108
|
+
--with-skills <a,b> Comma-separated optional skills
|
|
109
|
+
--yes Run setup non-interactively
|
|
110
|
+
`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function main() {
|
|
114
|
+
const options = parseArgs(process.argv.slice(2));
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
switch (options.command) {
|
|
118
|
+
case "help":
|
|
119
|
+
help();
|
|
120
|
+
break;
|
|
121
|
+
case "audit": {
|
|
122
|
+
const audit = await auditProject(options.target);
|
|
123
|
+
printAudit(audit);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case "setup":
|
|
127
|
+
await setupProject({ sourceRoot, ...options });
|
|
128
|
+
break;
|
|
129
|
+
case "cleanup":
|
|
130
|
+
await cleanupProject({ sourceRoot, ...options });
|
|
131
|
+
break;
|
|
132
|
+
case "mcp":
|
|
133
|
+
await generateMcp({ sourceRoot, target: options.target, profile: options.profile, yes: options.yes, dryRun: options.dryRun });
|
|
134
|
+
break;
|
|
135
|
+
case "ecc":
|
|
136
|
+
await setupEcc({ sourceRoot, target: options.target, dryRun: options.dryRun });
|
|
137
|
+
break;
|
|
138
|
+
case "rules":
|
|
139
|
+
await applyEccRules({ target: options.target, dryRun: options.dryRun });
|
|
140
|
+
break;
|
|
141
|
+
case "doctor":
|
|
142
|
+
await doctorProject(options.target);
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
console.error(`Unknown command: ${options.command}`);
|
|
146
|
+
help();
|
|
147
|
+
process.exit(2);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error(`\nERROR: ${error.message}`);
|
|
151
|
+
if (process.env.DEBUG) console.error(error.stack);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
main();
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { printAudit } from "./lib/audit.mjs";
|
|
6
|
+
import { applyMcpValues, mcpSecretPrompt, validateRelativeMcpPath } from "./lib/mcp.mjs";
|
|
7
|
+
import { applyAttributionSetting } from "./lib/provision.mjs";
|
|
8
|
+
import { printSummary, renderLogo, style } from "./lib/prompt.mjs";
|
|
9
|
+
import { expectedOptionalSkillDirs, invalidOptionalSkills, normalizeOptionalSkills } from "./lib/skills.mjs";
|
|
10
|
+
|
|
11
|
+
const cliDir = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const cliPath = path.join(cliDir, "repo-pattern.mjs");
|
|
13
|
+
const repoRoot = path.dirname(cliDir);
|
|
14
|
+
|
|
15
|
+
const mcpServers = {
|
|
16
|
+
context7: {
|
|
17
|
+
command: "npx",
|
|
18
|
+
args: ["-y", "@upstash/context7-mcp"],
|
|
19
|
+
env: { CONTEXT7_API_KEY: "${CONTEXT7_API_KEY}" }
|
|
20
|
+
},
|
|
21
|
+
filesystem: {
|
|
22
|
+
command: "npx",
|
|
23
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
assert.equal(validateRelativeMcpPath("src"), true);
|
|
28
|
+
assert.equal(validateRelativeMcpPath("."), true);
|
|
29
|
+
assert.equal(validateRelativeMcpPath("/home/user/project"), "Use a relative path, not an absolute machine path.");
|
|
30
|
+
assert.equal(validateRelativeMcpPath("C:\\Users\\me\\project"), "Use a relative path, not an absolute machine path.");
|
|
31
|
+
assert.equal(validateRelativeMcpPath("~/project"), "Use a relative path, not an absolute machine path.");
|
|
32
|
+
assert.equal(validateRelativeMcpPath("../secrets"), "Path must not contain '..'.");
|
|
33
|
+
assert.equal(
|
|
34
|
+
mcpSecretPrompt({ name: "CONTEXT7_API_KEY", label: "context7: CONTEXT7_API_KEY" }),
|
|
35
|
+
"MCP secret — context7: CONTEXT7_API_KEY (get key: https://context7.com/dashboard)"
|
|
36
|
+
);
|
|
37
|
+
assert.equal(
|
|
38
|
+
mcpSecretPrompt({ name: "TAVILY_API_KEY", label: "tavily: TAVILY_API_KEY" }),
|
|
39
|
+
"MCP secret — tavily: TAVILY_API_KEY (get key: https://app.tavily.com/home)"
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(applyMcpValues(mcpServers, {
|
|
43
|
+
CONTEXT7_API_KEY: "redacted-key"
|
|
44
|
+
}), {
|
|
45
|
+
context7: {
|
|
46
|
+
command: "npx",
|
|
47
|
+
args: ["-y", "@upstash/context7-mcp"],
|
|
48
|
+
env: { CONTEXT7_API_KEY: "redacted-key" }
|
|
49
|
+
},
|
|
50
|
+
filesystem: {
|
|
51
|
+
command: "npx",
|
|
52
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
assert.equal(applyMcpValues(mcpServers).filesystem.args[2], ".");
|
|
57
|
+
assert.deepEqual(applyAttributionSetting({ hooks: {} }, { mode: "off" }), { hooks: {}, attribution: { commit: "" } });
|
|
58
|
+
assert.deepEqual(applyAttributionSetting({ attribution: { commit: "" } }, { mode: "on" }), {});
|
|
59
|
+
assert.deepEqual(applyAttributionSetting({ attribution: { pr: "PR" } }, { mode: "custom", commit: "Custom" }), { attribution: { pr: "PR", commit: "Custom" } });
|
|
60
|
+
assert.deepEqual(normalizeOptionalSkills(["taste", "taste", "document-specialist", "ui-ux-pro-max", "impeccable", "huashu-design"]), ["taste", "document-specialist", "ui-ux-pro-max", "impeccable", "huashu-design"]);
|
|
61
|
+
assert.deepEqual(invalidOptionalSkills(["taste", "ui-ux-pro-max", "impeccable", "huashu-design", "nope"]), ["nope"]);
|
|
62
|
+
assert.deepEqual(expectedOptionalSkillDirs([
|
|
63
|
+
{
|
|
64
|
+
name: "ui-ux-pro-max",
|
|
65
|
+
source: "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
|
66
|
+
revision: "4baa399d00da806f83ed93652172f66943205153"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "impeccable",
|
|
70
|
+
source: "https://github.com/pbakaus/impeccable.git",
|
|
71
|
+
revision: "88f52ac4e6a5ce99d39a0f5d89e7ac3a168910f5"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "huashu-design",
|
|
75
|
+
source: "https://github.com/alchaincyf/huashu-design.git",
|
|
76
|
+
revision: "0e7ec8aca0058184c1a9e06e57697e84f68a3f0f"
|
|
77
|
+
}
|
|
78
|
+
]), ["huashu-design", "impeccable", "ui-ux-pro-max"]);
|
|
79
|
+
|
|
80
|
+
const auditLogs = [];
|
|
81
|
+
const originalAuditLog = console.log;
|
|
82
|
+
const originalAuditStdinIsTty = process.stdin.isTTY;
|
|
83
|
+
const originalAuditStdoutIsTty = process.stdout.isTTY;
|
|
84
|
+
console.log = (message = "") => auditLogs.push(String(message));
|
|
85
|
+
process.stdin.isTTY = false;
|
|
86
|
+
process.stdout.isTTY = false;
|
|
87
|
+
try {
|
|
88
|
+
printAudit({ target: "/tmp/project", state: "EMPTY" });
|
|
89
|
+
printAudit({ target: "/tmp/project", state: "PARTIAL", hasSettingsHooks: true });
|
|
90
|
+
} finally {
|
|
91
|
+
console.log = originalAuditLog;
|
|
92
|
+
process.stdin.isTTY = originalAuditStdinIsTty;
|
|
93
|
+
process.stdout.isTTY = originalAuditStdoutIsTty;
|
|
94
|
+
}
|
|
95
|
+
assert(auditLogs.includes("Target /tmp/project"));
|
|
96
|
+
assert(auditLogs.includes("State EMPTY"));
|
|
97
|
+
assert(!auditLogs.some((line) => line.includes(".claude present")));
|
|
98
|
+
assert(auditLogs.some((line) => line.includes("⚠ .mcp.json missing")));
|
|
99
|
+
assert(auditLogs.some((line) => line.includes("⚠ .claude/settings.json hooks not empty")));
|
|
100
|
+
|
|
101
|
+
const logs = [];
|
|
102
|
+
const originalLog = console.log;
|
|
103
|
+
const originalStdinIsTty = process.stdin.isTTY;
|
|
104
|
+
const originalStdoutIsTty = process.stdout.isTTY;
|
|
105
|
+
console.log = (message = "") => logs.push(String(message));
|
|
106
|
+
process.stdin.isTTY = false;
|
|
107
|
+
process.stdout.isTTY = false;
|
|
108
|
+
try {
|
|
109
|
+
printSummary("MCP generated", [
|
|
110
|
+
["Enabled servers", "context7, filesystem, playwright, chrome-devtools, gitnexus, tavily, sequential-thinking"]
|
|
111
|
+
]);
|
|
112
|
+
} finally {
|
|
113
|
+
console.log = originalLog;
|
|
114
|
+
process.stdin.isTTY = originalStdinIsTty;
|
|
115
|
+
process.stdout.isTTY = originalStdoutIsTty;
|
|
116
|
+
}
|
|
117
|
+
assert(logs.some((line) => line.includes("sequential-thinking")));
|
|
118
|
+
assert(!logs.some((line) => line.length > 100));
|
|
119
|
+
|
|
120
|
+
assert(renderLogo().includes("repo-pattern"));
|
|
121
|
+
assert(renderLogo({ color: true }).some((line) => line.includes("\x1b[38;5;")));
|
|
122
|
+
|
|
123
|
+
process.stdin.isTTY = true;
|
|
124
|
+
process.stdout.isTTY = true;
|
|
125
|
+
process.env.NO_COLOR = "1";
|
|
126
|
+
try {
|
|
127
|
+
assert.equal(style("success", "ready"), "ready");
|
|
128
|
+
} finally {
|
|
129
|
+
delete process.env.NO_COLOR;
|
|
130
|
+
process.stdin.isTTY = originalStdinIsTty;
|
|
131
|
+
process.stdout.isTTY = originalStdoutIsTty;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function runCli(args) {
|
|
135
|
+
return spawnSync(process.execPath, [cliPath, ...args], { cwd: repoRoot, encoding: "utf8" });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let result = runCli(["help"]);
|
|
139
|
+
assert.equal(result.status, 0);
|
|
140
|
+
assert.match(result.stdout, /repo-pattern help/);
|
|
141
|
+
|
|
142
|
+
result = runCli(["doctor", "--bogus"]);
|
|
143
|
+
assert.equal(result.status, 2);
|
|
144
|
+
assert.match(result.stderr, /Unknown argument: --bogus/);
|
|
145
|
+
|
|
146
|
+
result = runCli(["setup", "--target"]);
|
|
147
|
+
assert.equal(result.status, 2);
|
|
148
|
+
assert.match(result.stderr, /Missing value for --target/);
|
|
149
|
+
|
|
150
|
+
result = runCli(["setup", "--with-skill", "nope"]);
|
|
151
|
+
assert.equal(result.status, 2);
|
|
152
|
+
assert.match(result.stderr, /Unknown optional skill\(s\): nope/);
|
|
153
|
+
|
|
154
|
+
result = runCli(["help"]);
|
|
155
|
+
assert.equal(result.status, 0);
|
|
156
|
+
assert.match(result.stdout, /--with-skill <name>/);
|
|
157
|
+
assert.match(result.stdout, /ui-ux-pro-max/);
|
|
158
|
+
assert.match(result.stdout, /impeccable/);
|
|
159
|
+
assert.match(result.stdout, /huashu-design/);
|
|
160
|
+
|
|
161
|
+
result = runCli(["audit"]);
|
|
162
|
+
assert.equal(result.status, 0);
|
|
163
|
+
assert.match(result.stdout, /Target\s+.*repo-pattern/);
|
|
164
|
+
|
|
165
|
+
result = runCli(["mcp", "--profile", "minimal", "--yes", "--dry-run"]);
|
|
166
|
+
assert.equal(result.status, 0);
|
|
167
|
+
assert.match(result.stdout, /MCP generated/);
|
|
168
|
+
|
|
169
|
+
result = runCli(["mcp", "--profile", "nope", "--yes"]);
|
|
170
|
+
assert.equal(result.status, 1);
|
|
171
|
+
assert.match(result.stderr, /MCP profile not found: nope\. Available profiles: /);
|
|
172
|
+
assert.match(result.stderr, /minimal/);
|
|
173
|
+
assert.match(result.stderr, /web/);
|
|
174
|
+
|
|
175
|
+
console.log("self-check passed");
|