@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.
Files changed (39) hide show
  1. package/.claude/CLAUDE.md +61 -0
  2. package/.claude/settings.example.json +40 -0
  3. package/.claude/settings.local.example.json +24 -0
  4. package/.github/workflows/nodejs-package.yml +60 -0
  5. package/.repo-pattern.json +25 -0
  6. package/.repo-pattern.lock.json +23 -0
  7. package/LICENSE +21 -0
  8. package/README.md +145 -0
  9. package/THIRD_PARTY_NOTICES.md +51 -0
  10. package/docs/repo-pattern/setup-guide.md +725 -0
  11. package/docs/repo-pattern/workflow.md +429 -0
  12. package/mcp/profiles/backend.json +7 -0
  13. package/mcp/profiles/full.json +11 -0
  14. package/mcp/profiles/minimal.json +6 -0
  15. package/mcp/profiles/research.json +8 -0
  16. package/mcp/profiles/web.json +8 -0
  17. package/mcp/servers/chrome-devtools.json +10 -0
  18. package/mcp/servers/context7.json +13 -0
  19. package/mcp/servers/filesystem.json +11 -0
  20. package/mcp/servers/gitnexus.json +11 -0
  21. package/mcp/servers/playwright.json +12 -0
  22. package/mcp/servers/sequential-thinking.json +10 -0
  23. package/mcp/servers/tavily.json +13 -0
  24. package/package.json +71 -0
  25. package/scripts/lib/audit.mjs +127 -0
  26. package/scripts/lib/cleanup.mjs +35 -0
  27. package/scripts/lib/doctor.mjs +86 -0
  28. package/scripts/lib/ecc-rules.mjs +98 -0
  29. package/scripts/lib/ecc.mjs +61 -0
  30. package/scripts/lib/fs-utils.mjs +133 -0
  31. package/scripts/lib/mcp.mjs +203 -0
  32. package/scripts/lib/project-detect.mjs +107 -0
  33. package/scripts/lib/prompt.mjs +226 -0
  34. package/scripts/lib/provision.mjs +184 -0
  35. package/scripts/lib/rules.mjs +142 -0
  36. package/scripts/lib/setup.mjs +299 -0
  37. package/scripts/lib/skills.mjs +249 -0
  38. package/scripts/repo-pattern.mjs +156 -0
  39. 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");