agent-rules-init 0.2.0 → 0.3.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.
@@ -0,0 +1,198 @@
1
+ import path from "node:path";
2
+ import { parse } from "yaml";
3
+ import { UI } from "./i18n.js";
4
+ const NPM_DIRECT_LIFECYCLE = new Set(["test", "start", "stop", "restart"]);
5
+ export function extractNpmCommands(signals) {
6
+ const scripts = signals.packageJson?.scripts ?? {};
7
+ const entries = [];
8
+ for (const [name, body] of Object.entries(scripts)) {
9
+ if (typeof body !== "string" || body.trim() === "")
10
+ continue;
11
+ entries.push({
12
+ source: "npm",
13
+ invocation: NPM_DIRECT_LIFECYCLE.has(name) ? `npm ${name}` : `npm run ${name}`,
14
+ detail: body.trim(),
15
+ });
16
+ }
17
+ return entries;
18
+ }
19
+ export function extractMakeTargets(signals) {
20
+ const makefile = signals.makefile;
21
+ if (!makefile)
22
+ return [];
23
+ const targets = new Set();
24
+ for (const line of makefile.split(/\r?\n/)) {
25
+ // Un target va a inicio de línea (las recetas van indentadas con tab) y su nombre
26
+ // no lleva %, ni empieza por "." (targets especiales tipo .PHONY). El (?!=) evita
27
+ // asignaciones ":=". La clase de caracteres ya excluye espacios, con lo que
28
+ // "CFLAGS :=", los comentarios "#" y las URLs en comentarios tampoco matchean.
29
+ const match = /^([A-Za-z0-9_/-][A-Za-z0-9_./-]*)\s*:(?!=)/.exec(line);
30
+ if (!match)
31
+ continue;
32
+ targets.add(match[1]);
33
+ }
34
+ return [...targets].map((target) => ({ source: "make", invocation: `make ${target}` }));
35
+ }
36
+ export function extractMixAliases(signals) {
37
+ const mixExs = signals.mixExs;
38
+ if (!mixExs)
39
+ return [];
40
+ // Cuerpo de la función aliases (defp aliases do ... end). Si no existe con esa
41
+ // forma, no se emite nada — omitir antes que inventar.
42
+ const fnMatch = mixExs.match(/defp?\s+aliases\s*(?:\(\))?\s*do([\s\S]*?)\n\s*end/);
43
+ if (!fnMatch)
44
+ return [];
45
+ const body = fnMatch[1];
46
+ const names = new Set();
47
+ // Claves del keyword list cuyo valor empieza por lista o string: `setup: [...]`,
48
+ // `"ecto.setup": [...]`. Un alias con valor función (&fun/1) se omite.
49
+ for (const match of body.matchAll(/(?:"([^"]+)"|([a-z_][a-zA-Z0-9_.]*)):\s*(?=[["'])/g)) {
50
+ names.add(match[1] ?? match[2]);
51
+ }
52
+ return [...names].map((alias) => ({ source: "mix", invocation: `mix ${alias}` }));
53
+ }
54
+ export function extractToxEnvs(signals) {
55
+ const toxIni = signals.toxIni;
56
+ if (!toxIni)
57
+ return [];
58
+ const match = toxIni.match(/^[ \t]*env_?list\s*=\s*(.+(?:\n[ \t]+\S.*)*)/m);
59
+ if (!match)
60
+ return [];
61
+ const envs = match[1]
62
+ .split(/[,\s]+/)
63
+ .map((env) => env.trim())
64
+ // Los envs con factores generadores (py3{10,11}) se omiten en vez de expandirlos.
65
+ // El split por comas los parte en trozos ("py3{10", "11}"), así que basta con
66
+ // descartar cualquier trozo que contenga una llave.
67
+ .filter((env) => env !== "" && !env.startsWith("#") && !/[{}]/.test(env));
68
+ return [...new Set(envs)].map((env) => ({ source: "tox", invocation: `tox -e ${env}` }));
69
+ }
70
+ export function extractComposerCommands(signals) {
71
+ const scripts = signals.composerJson?.scripts ?? {};
72
+ const entries = [];
73
+ for (const [name, raw] of Object.entries(scripts)) {
74
+ const parts = Array.isArray(raw)
75
+ ? raw.filter((p) => typeof p === "string")
76
+ : typeof raw === "string"
77
+ ? [raw]
78
+ : [];
79
+ if (parts.length === 0)
80
+ continue;
81
+ entries.push({ source: "composer", invocation: `composer ${name}`, detail: parts.join(" && ") });
82
+ }
83
+ return entries;
84
+ }
85
+ const WELL_KNOWN_NAMES = new Set([
86
+ "test", "tests", "build", "lint", "fmt", "format", "check", "dev", "start",
87
+ "typecheck", "ci", "coverage", "e2e", "unit", "docs", "clean", "install",
88
+ "setup", "release", "watch", "fix", "all",
89
+ ]);
90
+ const MAX_COMMANDS_PER_SOURCE = 15;
91
+ function invocationName(entry) {
92
+ const parts = entry.invocation.split(" ");
93
+ return parts[parts.length - 1];
94
+ }
95
+ export function filterCommands(entries) {
96
+ const kept = [];
97
+ const omitted = [];
98
+ const sources = [...new Set(entries.map((e) => e.source))];
99
+ for (const source of sources) {
100
+ const group = entries.filter((e) => e.source === source);
101
+ const wellKnown = group.filter((e) => WELL_KNOWN_NAMES.has(invocationName(e)));
102
+ const rest = group.filter((e) => !WELL_KNOWN_NAMES.has(invocationName(e)));
103
+ const keptGroup = new Set([...wellKnown, ...rest].slice(0, MAX_COMMANDS_PER_SOURCE));
104
+ // Se emite en el orden original del manifiesto, no con los conocidos primero.
105
+ kept.push(...group.filter((e) => keptGroup.has(e)));
106
+ const omittedCount = group.length - keptGroup.size;
107
+ if (omittedCount > 0)
108
+ omitted.push({ source, count: omittedCount });
109
+ }
110
+ return { kept, omitted };
111
+ }
112
+ const MAX_CI_COMMANDS = 30;
113
+ // Líneas de un script multilínea que son control de flujo del shell, no comandos:
114
+ // `if [[ ... ]]; then`, `else`, `fi`, `for x in ...; do`, `done`, `case`/`esac`...
115
+ const SHELL_CONTROL_FLOW = /^(?:if|elif|else|fi|then|do|done|for|while|until|case|esac)\b|^(?:fi|then|else|done|esac)$/;
116
+ function isShellControlFlow(line) {
117
+ return SHELL_CONTROL_FLOW.test(line);
118
+ }
119
+ export function extractCiCommands(signals) {
120
+ const seen = new Map(); // comando -> workflow de origen
121
+ for (const workflow of signals.githubWorkflows ?? []) {
122
+ let doc;
123
+ try {
124
+ doc = parse(workflow.content);
125
+ }
126
+ catch {
127
+ continue; // YAML inválido: omitir antes que inventar
128
+ }
129
+ if (!doc || typeof doc !== "object")
130
+ continue;
131
+ const jobs = doc.jobs;
132
+ if (!jobs || typeof jobs !== "object")
133
+ continue;
134
+ const workflowName = workflow.path.split("/").pop() ?? workflow.path;
135
+ for (const job of Object.values(jobs)) {
136
+ if (!job || typeof job !== "object")
137
+ continue;
138
+ const steps = job.steps;
139
+ if (!Array.isArray(steps))
140
+ continue;
141
+ for (const step of steps) {
142
+ if (!step || typeof step !== "object")
143
+ continue;
144
+ const run = step.run;
145
+ if (typeof run !== "string")
146
+ continue;
147
+ for (const rawLine of run.split(/\r?\n/)) {
148
+ const line = rawLine.trim();
149
+ if (line === "" || line.startsWith("#") || isShellControlFlow(line))
150
+ continue;
151
+ if (!seen.has(line))
152
+ seen.set(line, workflowName);
153
+ }
154
+ }
155
+ }
156
+ }
157
+ const all = [...seen.entries()].map(([command, workflow]) => ({ command, workflow }));
158
+ return {
159
+ commands: all.slice(0, MAX_CI_COMMANDS),
160
+ omittedCount: Math.max(0, all.length - MAX_CI_COMMANDS),
161
+ };
162
+ }
163
+ const MAX_DIRS = 20;
164
+ export function extractStructure(signals, lang) {
165
+ const dirNotes = UI[lang].dirNotes;
166
+ const dirs = new Set();
167
+ for (const file of signals.files) {
168
+ const normalized = file.split(path.sep).join("/");
169
+ const slash = normalized.indexOf("/");
170
+ if (slash > 0)
171
+ dirs.add(normalized.slice(0, slash));
172
+ }
173
+ return [...dirs]
174
+ .sort()
175
+ .slice(0, MAX_DIRS)
176
+ .map((dir) => {
177
+ const note = dirNotes[dir.toLowerCase()];
178
+ return note ? { dir: `${dir}/`, note } : { dir: `${dir}/` };
179
+ });
180
+ }
181
+ export function buildRepoFacts(signals, lang) {
182
+ const allCommands = [
183
+ ...extractNpmCommands(signals),
184
+ ...extractComposerCommands(signals),
185
+ ...extractMakeTargets(signals),
186
+ ...extractMixAliases(signals),
187
+ ...extractToxEnvs(signals),
188
+ ];
189
+ const { kept, omitted } = filterCommands(allCommands);
190
+ const { commands: ciCommands, omittedCount: omittedCiCount } = extractCiCommands(signals);
191
+ return {
192
+ commands: kept,
193
+ omittedCommands: omitted,
194
+ structure: extractStructure(signals, lang),
195
+ ciCommands,
196
+ omittedCiCount,
197
+ };
198
+ }
@@ -28,11 +28,16 @@ function walk(rootPath) {
28
28
  recurse(rootPath, 0);
29
29
  return results;
30
30
  }
31
+ // Windows editors (Notepad, PowerShell's Set-Content, some IDE defaults) save UTF-8
32
+ // with a leading BOM, which JSON.parse rejects and line-anchored regexes trip over.
33
+ function stripBom(content) {
34
+ return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
35
+ }
31
36
  function readJsonIfExists(filePath) {
32
37
  if (!fs.existsSync(filePath))
33
38
  return undefined;
34
39
  try {
35
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
40
+ return JSON.parse(stripBom(fs.readFileSync(filePath, "utf-8")));
36
41
  }
37
42
  catch {
38
43
  return undefined;
@@ -42,7 +47,7 @@ function readTextIfExists(filePath) {
42
47
  if (!fs.existsSync(filePath))
43
48
  return undefined;
44
49
  try {
45
- return fs.readFileSync(filePath, "utf-8");
50
+ return stripBom(fs.readFileSync(filePath, "utf-8"));
46
51
  }
47
52
  catch {
48
53
  return undefined;
@@ -56,8 +61,43 @@ function shallowest(matches) {
56
61
  function findFirst(files, fileName) {
57
62
  return shallowest(files.filter((f) => path.basename(f) === fileName));
58
63
  }
59
- function findFirstByExtension(files, extension) {
60
- return shallowest(files.filter((f) => f.toLowerCase().endsWith(extension)));
64
+ // Directories that routinely carry their own throwaway Python tooling manifest (a
65
+ // mkdocs/sphinx docs build, a one-off script, a benchmark harness) in projects whose
66
+ // primary language is something else entirely (e.g. nlohmann/json, a C++ library, ships
67
+ // docs/mkdocs/requirements.txt). Prefer a candidate outside these dirs when one exists.
68
+ const NON_PROJECT_DIRS = new Set([
69
+ "docs",
70
+ "doc",
71
+ "tools",
72
+ "tool",
73
+ "scripts",
74
+ "script",
75
+ "examples",
76
+ "example",
77
+ "benchmark",
78
+ "benchmarks",
79
+ ]);
80
+ function isUnderNonProjectDir(relativePath) {
81
+ const segments = relativePath.split(path.sep).slice(0, -1);
82
+ return segments.some((segment) => NON_PROJECT_DIRS.has(segment.toLowerCase()));
83
+ }
84
+ function findFirstPreferringRealProjectDirs(files, fileName) {
85
+ const matches = files.filter((f) => path.basename(f) === fileName && !isUnderNonProjectDir(f));
86
+ return shallowest(matches);
87
+ }
88
+ function findAllByExtension(files, extension) {
89
+ return files.filter((f) => f.toLowerCase().endsWith(extension));
90
+ }
91
+ function findAllByNames(files, names) {
92
+ return files.filter((f) => names.includes(path.basename(f)));
93
+ }
94
+ function readAllConcatenated(rootPath, relativePaths) {
95
+ if (relativePaths.length === 0)
96
+ return undefined;
97
+ const contents = relativePaths
98
+ .map((p) => readTextIfExists(path.join(rootPath, p)))
99
+ .filter((content) => content !== undefined);
100
+ return contents.length > 0 ? contents.join("\n") : undefined;
61
101
  }
62
102
  function pickShallowest(paths) {
63
103
  return shallowest(paths.filter((p) => p !== undefined));
@@ -75,6 +115,7 @@ export function scanRepo(rootPath) {
75
115
  dependencies: rawPackageJson.dependencies ?? {},
76
116
  devDependencies: rawPackageJson.devDependencies ?? {},
77
117
  scripts: rawPackageJson.scripts ?? {},
118
+ moduleType: rawPackageJson.type === "module" ? "module" : "commonjs",
78
119
  }
79
120
  : undefined;
80
121
  const composerJsonPath = findFirst(files, "composer.json");
@@ -85,11 +126,13 @@ export function scanRepo(rootPath) {
85
126
  ? {
86
127
  require: rawComposerJson.require ?? {},
87
128
  requireDev: rawComposerJson["require-dev"] ?? {},
129
+ scripts: rawComposerJson.scripts ?? {},
88
130
  }
89
131
  : undefined;
90
- const pyprojectCandidate = findFirst(files, "pyproject.toml");
91
- const requirementsCandidate = findFirst(files, "requirements.txt");
92
- const environmentYmlCandidate = findFirst(files, "environment.yml") ?? findFirst(files, "environment.yaml");
132
+ const pyprojectCandidate = findFirstPreferringRealProjectDirs(files, "pyproject.toml");
133
+ const requirementsCandidate = findFirstPreferringRealProjectDirs(files, "requirements.txt");
134
+ const environmentYmlCandidate = findFirstPreferringRealProjectDirs(files, "environment.yml") ??
135
+ findFirstPreferringRealProjectDirs(files, "environment.yaml");
93
136
  // Only the shallowest of the three "wins" as the project's Python manifest — otherwise an
94
137
  // unrelated pyproject.toml nested inside a vendored/data subdirectory (e.g. a bundled dataset
95
138
  // package) would always outrank a real root-level environment.yml just by file type.
@@ -101,20 +144,40 @@ export function scanRepo(rootPath) {
101
144
  const pyprojectPath = primaryPythonManifest === pyprojectCandidate ? pyprojectCandidate : undefined;
102
145
  const requirementsPath = primaryPythonManifest === requirementsCandidate ? requirementsCandidate : undefined;
103
146
  const environmentYmlPath = primaryPythonManifest === environmentYmlCandidate ? environmentYmlCandidate : undefined;
104
- const pomPath = findFirst(files, "pom.xml");
105
- const buildGradlePath = findFirst(files, "build.gradle") ?? findFirst(files, "build.gradle.kts");
147
+ // Multi-module Maven/Gradle projects (parent + child modules) each have their own
148
+ // pom.xml/build.gradle, and the module that actually declares the framework/Kotlin
149
+ // plugin/test runner isn't necessarily the shallowest one. Aggregate all of them.
150
+ const pomPaths = findAllByNames(files, ["pom.xml"]);
151
+ const buildGradlePaths = findAllByNames(files, ["build.gradle", "build.gradle.kts"]);
106
152
  const gemfilePath = findFirst(files, "Gemfile");
107
153
  const goModPath = findFirst(files, "go.mod");
108
154
  const cargoTomlPath = findFirst(files, "Cargo.toml");
109
- const csprojPath = findFirstByExtension(files, ".csproj");
155
+ // .NET solutions routinely split into several .csproj files (domain/infra/web/tests);
156
+ // picking just the "shallowest" one is close to arbitrary and often lands on a plain
157
+ // class library that has neither the web framework nor the test runner reference.
158
+ // Concatenate all of them so detection can find those references wherever they live.
159
+ const csprojPaths = findAllByExtension(files, ".csproj");
110
160
  const packageSwiftPath = findFirst(files, "Package.swift");
111
- const pubspecYamlPath = findFirst(files, "pubspec.yaml");
161
+ // Melos/pub workspaces have a root pubspec.yaml that's just workspace glue (no
162
+ // `flutter`/`flutter_test` dependency) plus one real pubspec.yaml per package under
163
+ // packages/*. Aggregate all of them so the actual framework/test runner is found.
164
+ const pubspecYamlPaths = findAllByNames(files, ["pubspec.yaml"]);
112
165
  const cmakeListsPath = findFirst(files, "CMakeLists.txt");
113
- const makefilePath = findFirst(files, "Makefile") ?? findFirst(files, "makefile");
166
+ // Un Makefile que solo existe bajo docs/, tools/, etc. es tooling auxiliar (p. ej. el
167
+ // Makefile de Sphinx en docs/ de Flask): sus targets no se pueden ejecutar desde la
168
+ // raíz y no describen el proyecto. Mismo criterio que los manifiestos Python.
169
+ const makefilePath = findFirstPreferringRealProjectDirs(files, "Makefile") ??
170
+ findFirstPreferringRealProjectDirs(files, "makefile");
114
171
  const mixExsPath = findFirst(files, "mix.exs");
115
172
  const buildSbtPath = findFirst(files, "build.sbt");
116
173
  const rDescriptionPath = findFirst(files, "DESCRIPTION");
117
174
  const renvLockPath = findFirst(files, "renv.lock");
175
+ const toxIniPath = findFirst(files, "tox.ini");
176
+ const workflowPaths = files.filter((f) => {
177
+ const normalized = f.split(path.sep).join("/");
178
+ return (normalized.startsWith(".github/workflows/") &&
179
+ (normalized.endsWith(".yml") || normalized.endsWith(".yaml")));
180
+ });
118
181
  return {
119
182
  rootPath,
120
183
  files,
@@ -129,20 +192,27 @@ export function scanRepo(rootPath) {
129
192
  environmentYml: environmentYmlPath
130
193
  ? readTextIfExists(path.join(rootPath, environmentYmlPath))
131
194
  : undefined,
132
- pomXml: pomPath ? readTextIfExists(path.join(rootPath, pomPath)) : undefined,
133
- buildGradle: buildGradlePath ? readTextIfExists(path.join(rootPath, buildGradlePath)) : undefined,
195
+ pomXml: readAllConcatenated(rootPath, pomPaths),
196
+ buildGradle: readAllConcatenated(rootPath, buildGradlePaths),
134
197
  composerJson,
135
198
  gemfile: gemfilePath ? readTextIfExists(path.join(rootPath, gemfilePath)) : undefined,
136
199
  goMod: goModPath ? readTextIfExists(path.join(rootPath, goModPath)) : undefined,
137
200
  cargoToml: cargoTomlPath ? readTextIfExists(path.join(rootPath, cargoTomlPath)) : undefined,
138
- csproj: csprojPath ? readTextIfExists(path.join(rootPath, csprojPath)) : undefined,
201
+ csproj: readAllConcatenated(rootPath, csprojPaths),
139
202
  packageSwift: packageSwiftPath ? readTextIfExists(path.join(rootPath, packageSwiftPath)) : undefined,
140
- pubspecYaml: pubspecYamlPath ? readTextIfExists(path.join(rootPath, pubspecYamlPath)) : undefined,
203
+ pubspecYaml: readAllConcatenated(rootPath, pubspecYamlPaths),
141
204
  cmakeLists: cmakeListsPath ? readTextIfExists(path.join(rootPath, cmakeListsPath)) : undefined,
142
205
  makefile: makefilePath ? readTextIfExists(path.join(rootPath, makefilePath)) : undefined,
143
206
  mixExs: mixExsPath ? readTextIfExists(path.join(rootPath, mixExsPath)) : undefined,
144
207
  buildSbt: buildSbtPath ? readTextIfExists(path.join(rootPath, buildSbtPath)) : undefined,
145
208
  rDescription: rDescriptionPath ? readTextIfExists(path.join(rootPath, rDescriptionPath)) : undefined,
146
209
  renvLock: renvLockPath ? readTextIfExists(path.join(rootPath, renvLockPath)) : undefined,
210
+ toxIni: toxIniPath ? readTextIfExists(path.join(rootPath, toxIniPath)) : undefined,
211
+ githubWorkflows: workflowPaths
212
+ .map((p) => ({
213
+ path: p.split(path.sep).join("/"),
214
+ content: readTextIfExists(path.join(rootPath, p)),
215
+ }))
216
+ .filter((w) => w.content !== undefined),
147
217
  };
148
218
  }
@@ -1,11 +1,13 @@
1
- import type { DetectionResult, PromptTemplate, RuleSet } from "./types.js";
1
+ import { type Lang } from "./i18n.js";
2
+ import type { DetectionResult, PromptTemplate, RepoFacts, RuleSet } from "./types.js";
2
3
  export interface RenderEntry {
3
4
  detection: DetectionResult;
4
5
  ruleSet: RuleSet;
5
6
  }
6
- export declare function renderClaudeMd(entries: RenderEntry[]): string;
7
- export declare function renderAgentsMd(entries: RenderEntry[]): string;
8
- export declare function renderCopilotInstructions(entries: RenderEntry[]): string;
7
+ export declare function renderRepoFacts(facts: RepoFacts, lang: Lang): string;
8
+ export declare function renderClaudeMd(entries: RenderEntry[], facts: RepoFacts | undefined, lang: Lang): string;
9
+ export declare function renderAgentsMd(entries: RenderEntry[], facts: RepoFacts | undefined, lang: Lang): string;
10
+ export declare function renderCopilotInstructions(entries: RenderEntry[], facts: RepoFacts | undefined, lang: Lang): string;
9
11
  export declare function renderPromptFiles(packId: string, templates: PromptTemplate[]): {
10
12
  path: string;
11
13
  content: string;
@@ -1,4 +1,6 @@
1
- function renderSection(entries) {
1
+ import { UI } from "./i18n.js";
2
+ function renderSection(entries, lang) {
3
+ const ui = UI[lang];
2
4
  return entries
3
5
  .map(({ detection, ruleSet }) => {
4
6
  const conventions = ruleSet.conventions.map((c) => `- ${c}`).join("\n");
@@ -8,41 +10,64 @@ function renderSection(entries) {
8
10
  "",
9
11
  ruleSet.summary,
10
12
  "",
11
- "### Convenciones",
13
+ `### ${ui.sections.conventions}`,
12
14
  conventions,
13
15
  "",
14
- "### Arquitectura",
16
+ `### ${ui.sections.architecture}`,
15
17
  architecture,
16
18
  ].join("\n");
17
19
  })
18
20
  .join("\n\n");
19
21
  }
20
- export function renderClaudeMd(entries) {
21
- return [
22
- "# CLAUDE.md",
23
- "",
24
- "Generado por agent-rules-init a partir de lo detectado en este repo.",
25
- "",
26
- renderSection(entries),
27
- ].join("\n");
22
+ const SOURCE_FILES = {
23
+ npm: "package.json",
24
+ composer: "composer.json",
25
+ make: "Makefile",
26
+ mix: "mix.exs",
27
+ tox: "tox.ini",
28
+ };
29
+ export function renderRepoFacts(facts, lang) {
30
+ const ui = UI[lang];
31
+ const sections = [];
32
+ if (facts.commands.length > 0) {
33
+ const lines = facts.commands.map((c) => c.detail && c.detail !== c.invocation
34
+ ? `- \`${c.invocation}\` → \`${c.detail}\` (${SOURCE_FILES[c.source]})`
35
+ : `- \`${c.invocation}\` (${SOURCE_FILES[c.source]})`);
36
+ for (const o of facts.omittedCommands)
37
+ lines.push(`- ${ui.andMore(o.count, SOURCE_FILES[o.source])}`);
38
+ sections.push([`## ${ui.sections.commands}`, "", ...lines].join("\n"));
39
+ }
40
+ if (facts.structure.length > 0) {
41
+ const lines = facts.structure.map((d) => (d.note ? `- \`${d.dir}\` — ${d.note}` : `- \`${d.dir}\``));
42
+ sections.push([`## ${ui.sections.structure}`, "", ...lines].join("\n"));
43
+ }
44
+ if (facts.ciCommands.length > 0) {
45
+ const lines = facts.ciCommands.map((c) => `- \`${c.command}\` (${c.workflow})`);
46
+ if (facts.omittedCiCount > 0)
47
+ lines.push(`- ${ui.andMore(facts.omittedCiCount)}`);
48
+ sections.push([`## ${ui.sections.ci}`, "", ...lines].join("\n"));
49
+ }
50
+ return sections.join("\n\n");
28
51
  }
29
- export function renderAgentsMd(entries) {
52
+ function renderDocument(title, entries, facts, lang) {
53
+ const factsBlock = facts ? renderRepoFacts(facts, lang) : "";
30
54
  return [
31
- "# AGENTS.md",
55
+ title,
32
56
  "",
33
- "Generado por agent-rules-init a partir de lo detectado en este repo.",
57
+ UI[lang].generatedHeader,
34
58
  "",
35
- renderSection(entries),
59
+ renderSection(entries, lang),
60
+ ...(factsBlock ? ["", factsBlock] : []),
36
61
  ].join("\n");
37
62
  }
38
- export function renderCopilotInstructions(entries) {
39
- return [
40
- "# Copilot Instructions",
41
- "",
42
- "Generado por agent-rules-init a partir de lo detectado en este repo.",
43
- "",
44
- renderSection(entries),
45
- ].join("\n");
63
+ export function renderClaudeMd(entries, facts, lang) {
64
+ return renderDocument("# CLAUDE.md", entries, facts, lang);
65
+ }
66
+ export function renderAgentsMd(entries, facts, lang) {
67
+ return renderDocument("# AGENTS.md", entries, facts, lang);
68
+ }
69
+ export function renderCopilotInstructions(entries, facts, lang) {
70
+ return renderDocument("# Copilot Instructions", entries, facts, lang);
46
71
  }
47
72
  export function renderPromptFiles(packId, templates) {
48
73
  return templates.flatMap((template) => [
@@ -1,12 +1,15 @@
1
+ import type { Lang } from "./i18n.js";
1
2
  export interface PackageJsonManifest {
2
3
  name?: string;
3
4
  dependencies: Record<string, string>;
4
5
  devDependencies: Record<string, string>;
5
6
  scripts: Record<string, string>;
7
+ moduleType: "module" | "commonjs";
6
8
  }
7
9
  export interface ComposerJsonManifest {
8
10
  require: Record<string, string>;
9
11
  requireDev: Record<string, string>;
12
+ scripts?: Record<string, unknown>;
10
13
  }
11
14
  export interface RepoSignals {
12
15
  rootPath: string;
@@ -32,6 +35,11 @@ export interface RepoSignals {
32
35
  buildSbt?: string;
33
36
  rDescription?: string;
34
37
  renvLock?: string;
38
+ toxIni?: string;
39
+ githubWorkflows?: {
40
+ path: string;
41
+ content: string;
42
+ }[];
35
43
  }
36
44
  export type Confidence = "high" | "low";
37
45
  export interface DetectionField<T> {
@@ -45,6 +53,8 @@ export interface DetectionResult {
45
53
  packageManager?: DetectionField<string>;
46
54
  testRunner?: DetectionField<string>;
47
55
  linter?: DetectionField<string>;
56
+ usesTypeScript?: boolean;
57
+ moduleFormat?: "module" | "commonjs";
48
58
  }
49
59
  export interface RuleSet {
50
60
  summary: string;
@@ -56,9 +66,33 @@ export interface PromptTemplate {
56
66
  title: string;
57
67
  body: string;
58
68
  }
69
+ export type CommandSource = "npm" | "composer" | "make" | "mix" | "tox";
70
+ export interface CommandEntry {
71
+ source: CommandSource;
72
+ invocation: string;
73
+ detail?: string;
74
+ }
75
+ export interface DirEntry {
76
+ dir: string;
77
+ note?: string;
78
+ }
79
+ export interface CiCommand {
80
+ command: string;
81
+ workflow: string;
82
+ }
83
+ export interface RepoFacts {
84
+ commands: CommandEntry[];
85
+ omittedCommands: {
86
+ source: CommandSource;
87
+ count: number;
88
+ }[];
89
+ structure: DirEntry[];
90
+ ciCommands: CiCommand[];
91
+ omittedCiCount: number;
92
+ }
59
93
  export interface Pack {
60
94
  id: string;
61
95
  detect(signals: RepoSignals): DetectionResult | null;
62
- rules(detection: DetectionResult): RuleSet;
63
- promptTemplates(detection: DetectionResult): PromptTemplate[];
96
+ rules(detection: DetectionResult, lang: Lang): RuleSet;
97
+ promptTemplates(detection: DetectionResult, lang: Lang): PromptTemplate[];
64
98
  }
@@ -4,7 +4,7 @@ export interface GeneratedFile {
4
4
  }
5
5
  export interface WriteResult {
6
6
  path: string;
7
- status: "written" | "error";
7
+ status: "written" | "skipped" | "error";
8
8
  error?: string;
9
9
  }
10
10
  export declare function writeGeneratedFiles(rootPath: string, files: GeneratedFile[]): WriteResult[];
@@ -4,8 +4,10 @@ export function writeGeneratedFiles(rootPath, files) {
4
4
  return files.map(({ path: relativePath, content }) => {
5
5
  const absolutePath = path.join(rootPath, relativePath);
6
6
  try {
7
+ // An already-existing file is the expected outcome on a re-run (the tool's
8
+ // no-overwrite guarantee), not a failure — report it as skipped, exit 0.
7
9
  if (fs.existsSync(absolutePath)) {
8
- return { path: relativePath, status: "error", error: "file already exists" };
10
+ return { path: relativePath, status: "skipped" };
9
11
  }
10
12
  fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
11
13
  fs.writeFileSync(absolutePath, content);