@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,203 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { appendGitignoreLine, ensureDir, exists, isTracked, readJson, writeJson } from "./fs-utils.mjs";
|
|
4
|
+
import { askPassword, askText, isInteractive, printSummary, style } from "./prompt.mjs";
|
|
5
|
+
|
|
6
|
+
const PLACEHOLDER_RE = /^\$\{([A-Z0-9_]+)(?::-(.*))?\}$/;
|
|
7
|
+
const SECRET_RE = /(API_KEY|TOKEN|SECRET|PASSWORD)$/;
|
|
8
|
+
const PATH_RE = /(PROJECT_DIR|_DIR|_PATH)$/;
|
|
9
|
+
const SECRET_HELP_URLS = {
|
|
10
|
+
CONTEXT7_API_KEY: "https://context7.com/dashboard",
|
|
11
|
+
TAVILY_API_KEY: "https://app.tavily.com/home"
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function listAvailableMcpServers(sourceRoot) {
|
|
15
|
+
const serverDir = path.join(sourceRoot, "mcp", "servers");
|
|
16
|
+
const entries = await fs.readdir(serverDir);
|
|
17
|
+
return entries.filter((name) => name.endsWith(".json")).map((name) => path.basename(name, ".json")).sort();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parsePlaceholder(value) {
|
|
21
|
+
if (typeof value !== "string") return null;
|
|
22
|
+
const match = value.match(PLACEHOLDER_RE);
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
return { name: match[1], defaultValue: match[2] || "" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateRelativeMcpPath(value) {
|
|
28
|
+
const input = String(value || "").trim();
|
|
29
|
+
if (!input) return "Required";
|
|
30
|
+
if (input.startsWith("~") || path.isAbsolute(input) || /^[A-Za-z]:[\\/]/.test(input)) return "Use a relative path, not an absolute machine path.";
|
|
31
|
+
if (input.split(/[\\/]+/).includes("..")) return "Path must not contain '..'.";
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mcpSecretPrompt(input) {
|
|
36
|
+
const helpUrl = SECRET_HELP_URLS[input.name];
|
|
37
|
+
return helpUrl ? `MCP secret ā ${input.label} (get key: ${helpUrl})` : `MCP secret ā ${input.label}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findMcpInputs(mcpServers) {
|
|
41
|
+
const inputs = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
|
|
44
|
+
function add(input) {
|
|
45
|
+
if (seen.has(input.name)) return;
|
|
46
|
+
seen.add(input.name);
|
|
47
|
+
inputs.push(input);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const [serverName, server] of Object.entries(mcpServers)) {
|
|
51
|
+
for (const [envName, envValue] of Object.entries(server.env || {})) {
|
|
52
|
+
const placeholder = parsePlaceholder(envValue);
|
|
53
|
+
if (!placeholder) continue;
|
|
54
|
+
add({
|
|
55
|
+
...placeholder,
|
|
56
|
+
kind: SECRET_RE.test(placeholder.name) ? "secret" : "text",
|
|
57
|
+
label: `${serverName}: ${envName}`
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const arg of server.args || []) {
|
|
62
|
+
const placeholder = parsePlaceholder(arg);
|
|
63
|
+
if (!placeholder) continue;
|
|
64
|
+
add({
|
|
65
|
+
...placeholder,
|
|
66
|
+
kind: PATH_RE.test(placeholder.name) ? "path" : "text",
|
|
67
|
+
label: `${serverName}: ${placeholder.name}`
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return inputs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function missingRequiredInputs(inputs, values) {
|
|
76
|
+
return inputs.filter((input) => !input.defaultValue && !values[input.name]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function replacePlaceholders(value, values) {
|
|
80
|
+
if (Array.isArray(value)) return value.map((item) => replacePlaceholders(item, values));
|
|
81
|
+
if (!value || typeof value !== "object") {
|
|
82
|
+
const placeholder = parsePlaceholder(value);
|
|
83
|
+
return placeholder && values[placeholder.name] ? values[placeholder.name] : value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return Object.fromEntries(
|
|
87
|
+
Object.entries(value).map(([key, nested]) => [key, replacePlaceholders(nested, values)])
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function applyMcpValues(mcpServers, values = {}) {
|
|
92
|
+
return replacePlaceholders(mcpServers, values);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function readMcpConfig({ sourceRoot, profile = "web", mcpServers: selectedServers = null }) {
|
|
96
|
+
const profileDir = path.join(sourceRoot, "mcp", "profiles");
|
|
97
|
+
const profileData = selectedServers ? null : await readJson(path.join(profileDir, `${profile}.json`));
|
|
98
|
+
const profileServers = selectedServers || profileData?.servers;
|
|
99
|
+
if (!profileServers) {
|
|
100
|
+
const profiles = (await fs.readdir(profileDir)).filter((name) => name.endsWith(".json")).map((name) => path.basename(name, ".json")).sort();
|
|
101
|
+
throw new Error(`MCP profile not found: ${profile}. Available profiles: ${profiles.join(", ")}`);
|
|
102
|
+
}
|
|
103
|
+
if (profile === "custom" && profileServers.length === 0) throw new Error("Custom MCP profile requires at least one server.");
|
|
104
|
+
|
|
105
|
+
const mcpServers = {};
|
|
106
|
+
|
|
107
|
+
for (const name of profileServers) {
|
|
108
|
+
const serverPath = path.join(sourceRoot, "mcp", "servers", `${name}.json`);
|
|
109
|
+
if (!exists(serverPath)) throw new Error(`MCP server definition not found: ${name}`);
|
|
110
|
+
const serverData = await readJson(serverPath);
|
|
111
|
+
Object.assign(mcpServers, serverData);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { profileServers, mcpServers };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function collectMcpValues(mcpServers, { yes = false, values = {} } = {}) {
|
|
118
|
+
const inputs = findMcpInputs(mcpServers);
|
|
119
|
+
const nextValues = { ...values };
|
|
120
|
+
|
|
121
|
+
if (yes || !isInteractive()) return nextValues;
|
|
122
|
+
|
|
123
|
+
for (const input of inputs) {
|
|
124
|
+
if (nextValues[input.name]) continue;
|
|
125
|
+
const initial = process.env[input.name] || input.defaultValue;
|
|
126
|
+
if (input.kind === "path") {
|
|
127
|
+
nextValues[input.name] = await askText(`MCP path ā ${input.label}`, {
|
|
128
|
+
initial: initial || ".",
|
|
129
|
+
validate: validateRelativeMcpPath
|
|
130
|
+
});
|
|
131
|
+
} else if (input.kind === "secret") {
|
|
132
|
+
nextValues[input.name] = await askPassword(mcpSecretPrompt(input), {
|
|
133
|
+
initial,
|
|
134
|
+
validate: (value) => String(value || "").trim() ? true : "Required"
|
|
135
|
+
});
|
|
136
|
+
} else {
|
|
137
|
+
nextValues[input.name] = await askText(`MCP value ā ${input.label}`, {
|
|
138
|
+
initial,
|
|
139
|
+
validate: (value) => String(value || "").trim() ? true : "Required"
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return nextValues;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function warnMissingMcpValues(mcpServers, values) {
|
|
148
|
+
const missing = missingRequiredInputs(findMcpInputs(mcpServers), { ...process.env, ...values });
|
|
149
|
+
if (missing.length === 0) return [];
|
|
150
|
+
const names = missing.map((input) => input.name);
|
|
151
|
+
console.warn(style("info", `MCP values missing: ${names.join(", ")}. Export env vars or edit .mcp.json before using those servers.`));
|
|
152
|
+
return names;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function syncEnabledMcpServers(target, servers, { dryRun = false } = {}) {
|
|
156
|
+
const settingsPath = path.join(target, ".claude", "settings.json");
|
|
157
|
+
const settings = await readJson(settingsPath, {
|
|
158
|
+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
|
159
|
+
"permissions": {
|
|
160
|
+
"allow": [],
|
|
161
|
+
"ask": [],
|
|
162
|
+
"deny": [],
|
|
163
|
+
"additionalDirectories": [],
|
|
164
|
+
"defaultMode": "default",
|
|
165
|
+
"disableBypassPermissionsMode": "disable"
|
|
166
|
+
},
|
|
167
|
+
"hooks": {}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
settings.enabledMcpjsonServers = servers;
|
|
171
|
+
|
|
172
|
+
await writeJson(settingsPath, settings, { dryRun });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function generateMcp({ sourceRoot, target, profile = "web", mcpServers: selectedServers = null, mcpValues = {}, yes = false, dryRun = false }) {
|
|
176
|
+
const { profileServers, mcpServers } = await readMcpConfig({ sourceRoot, profile, mcpServers: selectedServers });
|
|
177
|
+
if (isTracked(target, ".mcp.json")) throw new Error(".mcp.json is tracked. Untrack it before generating MCP config with local values.");
|
|
178
|
+
if (isTracked(target, ".claude/settings.json")) throw new Error(".claude/settings.json is tracked. Untrack it before enabling MCP servers.");
|
|
179
|
+
const values = await collectMcpValues(mcpServers, { yes, values: mcpValues });
|
|
180
|
+
const resolvedMcpServers = applyMcpValues(mcpServers, values);
|
|
181
|
+
|
|
182
|
+
await ensureDir(target, { dryRun });
|
|
183
|
+
await writeJson(path.join(target, ".mcp.json"), { mcpServers: resolvedMcpServers }, { dryRun });
|
|
184
|
+
await appendGitignoreLine(target, ".mcp.json", { dryRun });
|
|
185
|
+
|
|
186
|
+
await syncEnabledMcpServers(target, profileServers, { dryRun });
|
|
187
|
+
|
|
188
|
+
const lockPath = path.join(target, ".repo-pattern.lock.json");
|
|
189
|
+
const lock = await readJson(lockPath, {});
|
|
190
|
+
lock.mcp = lock.mcp || {};
|
|
191
|
+
lock.mcp.profile = profile;
|
|
192
|
+
lock.mcp.enabledServers = profileServers;
|
|
193
|
+
lock.mcp.generatedAt = new Date().toISOString();
|
|
194
|
+
await writeJson(lockPath, lock, { dryRun });
|
|
195
|
+
|
|
196
|
+
const missingValues = warnMissingMcpValues(mcpServers, values);
|
|
197
|
+
printSummary("MCP generated", [
|
|
198
|
+
["Profile", profile],
|
|
199
|
+
["Enabled servers", Object.keys(mcpServers).join(", ")],
|
|
200
|
+
["Claude enabled", profileServers.join(", ")]
|
|
201
|
+
]);
|
|
202
|
+
return { missingValues };
|
|
203
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exists, readJson } from "./fs-utils.mjs";
|
|
4
|
+
|
|
5
|
+
async function hasAny(target, rels) {
|
|
6
|
+
for (const rel of rels) {
|
|
7
|
+
if (exists(path.join(target, rel))) return true;
|
|
8
|
+
}
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function depNames(pkg) {
|
|
13
|
+
return new Set([
|
|
14
|
+
...Object.keys(pkg.dependencies || {}),
|
|
15
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
16
|
+
...Object.keys(pkg.peerDependencies || {}),
|
|
17
|
+
...Object.keys(pkg.optionalDependencies || {})
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hasDep(deps, names) {
|
|
22
|
+
return names.some((name) => deps.has(name));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function findByExtension(target, extension) {
|
|
26
|
+
try {
|
|
27
|
+
const entries = await fs.readdir(target);
|
|
28
|
+
return entries.some((entry) => entry.endsWith(extension));
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function detectProject(target) {
|
|
35
|
+
const pkg = await readJson(path.join(target, "package.json"), null);
|
|
36
|
+
const deps = pkg ? depNames(pkg) : new Set();
|
|
37
|
+
|
|
38
|
+
const languages = new Set();
|
|
39
|
+
const frameworks = new Set();
|
|
40
|
+
const tools = new Set();
|
|
41
|
+
|
|
42
|
+
const hasPackageJson = !!pkg;
|
|
43
|
+
const hasTsConfig = await hasAny(target, ["tsconfig.json", "tsconfig.base.json"]);
|
|
44
|
+
|
|
45
|
+
if (hasPackageJson) languages.add("javascript");
|
|
46
|
+
if (hasTsConfig || hasDep(deps, ["typescript", "ts-node", "tsx"])) languages.add("typescript");
|
|
47
|
+
|
|
48
|
+
if (await hasAny(target, ["pyproject.toml", "requirements.txt", "setup.py", "setup.cfg", "uv.lock", "poetry.lock"])) languages.add("python");
|
|
49
|
+
if (await hasAny(target, ["go.mod"])) languages.add("go");
|
|
50
|
+
if (await hasAny(target, ["pom.xml", "build.gradle", "build.gradle.kts", "gradlew"])) languages.add("java");
|
|
51
|
+
if (hasDep(deps, ["kotlin", "org.jetbrains.kotlin.jvm", "org.jetbrains.kotlin.android"])) languages.add("kotlin");
|
|
52
|
+
if (await hasAny(target, ["Cargo.toml"])) languages.add("rust");
|
|
53
|
+
if (await hasAny(target, ["pubspec.yaml"])) languages.add("dart");
|
|
54
|
+
if (await hasAny(target, ["CMakeLists.txt", "conanfile.txt", "conanfile.py", "vcpkg.json"]) || await findByExtension(target, ".cpp") || await findByExtension(target, ".cc") || await findByExtension(target, ".cxx")) languages.add("cpp");
|
|
55
|
+
if (await findByExtension(target, ".csproj") || await findByExtension(target, ".sln")) languages.add("csharp");
|
|
56
|
+
if (await findByExtension(target, ".fsproj") || await findByExtension(target, ".fs")) languages.add("fsharp");
|
|
57
|
+
if (await hasAny(target, ["cpanfile", "Makefile.PL", "Build.PL"]) || await findByExtension(target, ".pl") || await findByExtension(target, ".pm")) languages.add("perl");
|
|
58
|
+
if (await hasAny(target, ["composer.json"])) languages.add("php");
|
|
59
|
+
if (await hasAny(target, ["Gemfile"]) || await findByExtension(target, ".gemspec")) languages.add("ruby");
|
|
60
|
+
if (await hasAny(target, ["Package.swift"]) || await findByExtension(target, ".xcodeproj") || await findByExtension(target, ".xcworkspace")) languages.add("swift");
|
|
61
|
+
if (await hasAny(target, ["oh-package.json5", "build-profile.json5", "hvigorfile.ts"])) languages.add("arkts");
|
|
62
|
+
|
|
63
|
+
if (hasDep(deps, ["next"]) || await hasAny(target, ["next.config.js", "next.config.mjs", "next.config.ts"])) {
|
|
64
|
+
frameworks.add("nextjs");
|
|
65
|
+
frameworks.add("react");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (hasDep(deps, ["react", "react-dom"])) frameworks.add("react");
|
|
69
|
+
if (hasDep(deps, ["react-native", "expo"])) frameworks.add("react-native");
|
|
70
|
+
if (hasDep(deps, ["@angular/core"]) || await hasAny(target, ["angular.json"])) frameworks.add("angular");
|
|
71
|
+
if (hasDep(deps, ["vue", "@vitejs/plugin-vue"])) frameworks.add("vue");
|
|
72
|
+
if (hasDep(deps, ["nuxt"]) || await hasAny(target, ["nuxt.config.js", "nuxt.config.mjs", "nuxt.config.ts"])) {
|
|
73
|
+
frameworks.add("nuxt");
|
|
74
|
+
frameworks.add("vue");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (await hasAny(target, ["vite.config.js", "vite.config.mjs", "vite.config.ts", "webpack.config.js"])) tools.add("bundler");
|
|
78
|
+
if (hasDep(deps, ["vite", "webpack", "rollup", "parcel", "playwright", "@playwright/test", "cypress"])) tools.add("frontend-tooling");
|
|
79
|
+
|
|
80
|
+
const monorepo = await hasAny(target, ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json", "rush.json"]);
|
|
81
|
+
|
|
82
|
+
let packageManager = null;
|
|
83
|
+
if (await hasAny(target, ["pnpm-lock.yaml"])) packageManager = "pnpm";
|
|
84
|
+
else if (await hasAny(target, ["yarn.lock"])) packageManager = "yarn";
|
|
85
|
+
else if (await hasAny(target, ["bun.lockb", "bun.lock"])) packageManager = "bun";
|
|
86
|
+
else if (await hasAny(target, ["package-lock.json"])) packageManager = "npm";
|
|
87
|
+
|
|
88
|
+
const frontendFrameworks = ["nextjs", "react", "react-native", "angular", "vue", "nuxt"];
|
|
89
|
+
const backendLanguages = ["python", "go", "java", "kotlin", "rust", "cpp", "csharp", "fsharp", "perl", "php", "ruby", "swift", "arkts"];
|
|
90
|
+
const hasFrontend = [...frameworks].some((fw) => frontendFrameworks.includes(fw)) || tools.has("frontend-tooling");
|
|
91
|
+
const hasBackend = backendLanguages.some((language) => languages.has(language));
|
|
92
|
+
|
|
93
|
+
let repoType = "generic";
|
|
94
|
+
if (hasFrontend && hasBackend) repoType = "fullstack";
|
|
95
|
+
else if (hasFrontend) repoType = "frontend";
|
|
96
|
+
else if (hasBackend) repoType = "backend";
|
|
97
|
+
else if (hasPackageJson) repoType = "node";
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
languages: [...languages],
|
|
101
|
+
frameworks: [...frameworks],
|
|
102
|
+
tools: [...tools],
|
|
103
|
+
packageManager,
|
|
104
|
+
monorepo,
|
|
105
|
+
repoType
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { confirm, isCancel, multiselect, note, password, select, spinner, text } from "@clack/prompts";
|
|
2
|
+
|
|
3
|
+
export function isInteractive() {
|
|
4
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function handleCancel(value) {
|
|
8
|
+
if (isCancel(value)) throw new Error("Setup cancelled.");
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function clackValidate(validate, fallback = null) {
|
|
13
|
+
if (!validate) return undefined;
|
|
14
|
+
return (value) => {
|
|
15
|
+
const result = validate(value || fallback || "");
|
|
16
|
+
if (result === true) return undefined;
|
|
17
|
+
if (result === false) return "Invalid";
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function askText(message, { initial = "", validate = null } = {}) {
|
|
23
|
+
return handleCancel(await text({
|
|
24
|
+
message,
|
|
25
|
+
initialValue: initial,
|
|
26
|
+
validate: clackValidate(validate)
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function askPassword(message, { initial = "", validate = null } = {}) {
|
|
31
|
+
const value = handleCancel(await password({
|
|
32
|
+
message: initial ? `${message} (leave empty to keep current value)` : message,
|
|
33
|
+
validate: clackValidate(validate, initial)
|
|
34
|
+
}));
|
|
35
|
+
return value || initial;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function askConfirm(message, defaultYes = true) {
|
|
39
|
+
return handleCancel(await confirm({
|
|
40
|
+
message,
|
|
41
|
+
initialValue: defaultYes
|
|
42
|
+
})) === true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeOptions(options) {
|
|
46
|
+
return options.map((option) => {
|
|
47
|
+
if (typeof option === "string") return { label: option, value: option };
|
|
48
|
+
return {
|
|
49
|
+
label: option.title || option.label || option.value,
|
|
50
|
+
value: option.value,
|
|
51
|
+
hint: option.description || option.hint,
|
|
52
|
+
selected: option.selected,
|
|
53
|
+
disabled: option.disabled
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function enabledOptions(options) {
|
|
59
|
+
return options.filter((option) => !option.disabled);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function selectOne({ message, options, initialValue = null }) {
|
|
63
|
+
const normalized = normalizeOptions(options);
|
|
64
|
+
if (normalized.some((choice) => choice.disabled && choice.value === initialValue)) {
|
|
65
|
+
throw new Error(`Initial option is disabled: ${initialValue}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const choices = enabledOptions(normalized);
|
|
69
|
+
return handleCancel(await select({
|
|
70
|
+
message,
|
|
71
|
+
options: choices,
|
|
72
|
+
initialValue
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function selectMany({ message, options, initialValues = [] }) {
|
|
77
|
+
const choices = enabledOptions(normalizeOptions(options));
|
|
78
|
+
const enabledValues = new Set(choices.map((choice) => choice.value));
|
|
79
|
+
const selected = new Set(initialValues.filter((value) => enabledValues.has(value)));
|
|
80
|
+
for (const choice of choices) {
|
|
81
|
+
if (choice.selected) selected.add(choice.value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return handleCancel(await multiselect({
|
|
85
|
+
message,
|
|
86
|
+
options: choices,
|
|
87
|
+
initialValues: [...selected],
|
|
88
|
+
required: false
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function printBox(title, lines = []) {
|
|
93
|
+
if (isInteractive()) {
|
|
94
|
+
note(lines.join("\n"), title);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`\n== ${title} ==`);
|
|
99
|
+
for (const line of lines) console.log(line);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ANSI_RESET = "\x1b[0m";
|
|
103
|
+
const ANSI_STYLES = {
|
|
104
|
+
dim: "\x1b[2m",
|
|
105
|
+
error: "\x1b[31m",
|
|
106
|
+
info: "\x1b[34m",
|
|
107
|
+
success: "\x1b[32m"
|
|
108
|
+
};
|
|
109
|
+
const LOGO_PALETTE = ["\x1b[38;5;39m", "\x1b[38;5;81m", "\x1b[38;5;141m", "\x1b[38;5;213m"];
|
|
110
|
+
const LOGO_LINES = [
|
|
111
|
+
" ____ ____ ",
|
|
112
|
+
" | _ \\| _ \\",
|
|
113
|
+
" | |_) | |_) |",
|
|
114
|
+
" | _ <| __/ ",
|
|
115
|
+
" |_| \\_\\_| ",
|
|
116
|
+
"repo-pattern"
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
function supportsAnsiColor() {
|
|
120
|
+
return isInteractive() && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function style(kind, value) {
|
|
124
|
+
const color = ANSI_STYLES[kind];
|
|
125
|
+
return color && supportsAnsiColor() ? `${color}${value}${ANSI_RESET}` : String(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function gradient(line) {
|
|
129
|
+
let painted = 0;
|
|
130
|
+
const total = [...line].filter((char) => char !== " ").length || 1;
|
|
131
|
+
return [...line].map((char) => {
|
|
132
|
+
if (char === " ") return char;
|
|
133
|
+
const color = LOGO_PALETTE[Math.floor(painted++ * LOGO_PALETTE.length / total)];
|
|
134
|
+
return `${color}${char}`;
|
|
135
|
+
}).join("") + ANSI_RESET;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function renderLogo({ color = false } = {}) {
|
|
139
|
+
const lines = color ? LOGO_LINES.map(gradient) : LOGO_LINES;
|
|
140
|
+
return [...lines, "ECC-first Claude Code setup"];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function printLogo() {
|
|
144
|
+
printBox("repo-pattern", renderLogo({ color: supportsAnsiColor() }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const SUMMARY_VALUE_WIDTH = 72;
|
|
148
|
+
|
|
149
|
+
function wrapValue(value) {
|
|
150
|
+
const text = String(value);
|
|
151
|
+
if (text.length <= SUMMARY_VALUE_WIDTH) return [text];
|
|
152
|
+
|
|
153
|
+
const rawParts = text.includes(", ") ? text.split(", ") : text.split(" ");
|
|
154
|
+
const parts = rawParts.map((part, index) => text.includes(", ") && index < rawParts.length - 1 ? `${part},` : part);
|
|
155
|
+
const lines = [];
|
|
156
|
+
let line = "";
|
|
157
|
+
|
|
158
|
+
function pushLine(value) {
|
|
159
|
+
lines.push(value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const part of parts) {
|
|
163
|
+
if (part.length > SUMMARY_VALUE_WIDTH) {
|
|
164
|
+
if (line) {
|
|
165
|
+
pushLine(line);
|
|
166
|
+
line = "";
|
|
167
|
+
}
|
|
168
|
+
for (let i = 0; i < part.length; i += SUMMARY_VALUE_WIDTH) {
|
|
169
|
+
pushLine(part.slice(i, i + SUMMARY_VALUE_WIDTH));
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const next = line ? `${line} ${part}` : part;
|
|
175
|
+
if (next.length > SUMMARY_VALUE_WIDTH && line) {
|
|
176
|
+
pushLine(line);
|
|
177
|
+
line = part;
|
|
178
|
+
} else {
|
|
179
|
+
line = next;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (line) pushLine(line);
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function summaryLines(rows) {
|
|
188
|
+
const width = Math.max(...rows.map(([label]) => label.length), 0);
|
|
189
|
+
const lines = [];
|
|
190
|
+
for (const [label, value] of rows) {
|
|
191
|
+
const wrapped = wrapValue(value);
|
|
192
|
+
lines.push(`${label.padEnd(width)} ${wrapped[0]}`);
|
|
193
|
+
for (const continuation of wrapped.slice(1)) {
|
|
194
|
+
lines.push(`${"".padEnd(width)} ${continuation}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function printSummary(title, rows = []) {
|
|
201
|
+
const lines = summaryLines(rows);
|
|
202
|
+
if (!isInteractive()) {
|
|
203
|
+
printBox(title, lines);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
note(lines.join("\n"), title);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function withSpinner(message, task) {
|
|
210
|
+
if (!isInteractive()) return task();
|
|
211
|
+
|
|
212
|
+
const s = spinner();
|
|
213
|
+
s.start(message);
|
|
214
|
+
try {
|
|
215
|
+
const result = await task();
|
|
216
|
+
s.stop(`${message} done`);
|
|
217
|
+
return result;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
s.stop(`${message} failed`);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function printSection(title) {
|
|
225
|
+
console.log(isInteractive() ? `\nā ${title}` : `\n== ${title} ==`);
|
|
226
|
+
}
|