agent-rules-init 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agent-rules-init contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # agent-rules-init
2
+
3
+ Generate repository-specific instructions for Claude Code, Codex and GitHub Copilot from the manifests and commands that already exist in your project.
4
+
5
+ ```bash
6
+ npx agent-rules-init
7
+ ```
8
+
9
+ The CLI detects JavaScript/TypeScript, Python, Java, PHP, Ruby, Go, Rust, C#/.NET, Kotlin, Swift, Dart/Flutter, C/C++, Elixir, Scala and R projects. It also understands mixed repositories and npm, pnpm, Yarn and Bun workspaces, generating package-scoped `AGENTS.generated.md` files.
10
+
11
+ All output is written with a `.generated` suffix and existing files are never overwritten. Review the generated files and remove the suffix when you are ready to activate them.
12
+
13
+ Use `--lang es` or `--lang en` to select the output language. See the [full documentation](https://github.com/racama29/agent-rules-init#readme) for supported frameworks, generated files and contribution instructions.
14
+
15
+ Automation is supported through `--dry-run`, `--check`, `--json` and `--non-interactive`. Repository defaults and per-project overrides can be stored in `.agent-rules-init.yml`.
16
+
17
+ Generated documents share an evidence-backed model but are not duplicates: Claude receives broad repository context, AGENTS receives operational commands and scope, and Copilot receives concise implementation conventions. Observed architecture and local conventions include their source files so specific claims can be audited.
18
+
19
+ ## License
20
+
21
+ MIT
package/dist/cli.d.ts CHANGED
@@ -1,18 +1,37 @@
1
- import { type WriteResult } from "./core/writer.js";
1
+ import { type GeneratedFile, type WriteResult } from "./core/writer.js";
2
2
  import { type Lang } from "./core/i18n.js";
3
+ import { type AgentRulesConfig } from "./core/config.js";
3
4
  import { type PromptFn } from "./core/prompt-engine.js";
4
5
  import { type ExecFn } from "./core/llm-bridge.js";
6
+ import type { RepoFacts } from "./core/types.js";
5
7
  export interface RunCliOptions {
6
8
  promptFn?: PromptFn;
7
9
  execFn?: ExecFn;
8
10
  skipLlm?: boolean;
9
11
  lang?: Lang;
12
+ /** Generate results without changing the filesystem. */
13
+ dryRun?: boolean;
14
+ /** Do not prompt or offer AI polishing. */
15
+ nonInteractive?: boolean;
16
+ /** Receives the rendered files before they are written (or planned). */
17
+ onGeneratedFiles?: (files: readonly GeneratedFile[]) => void;
18
+ /** Preloaded repository configuration; loaded from disk when omitted. */
19
+ config?: AgentRulesConfig;
20
+ onConfigWarnings?: (warnings: readonly string[]) => void;
21
+ /** Receives the facts extracted from the repo (including canonical commands). */
22
+ onFacts?: (facts: RepoFacts) => void;
10
23
  }
11
24
  export declare function runCli(rootPath: string, options?: RunCliOptions): Promise<WriteResult[]>;
12
- export type CliAction = {
13
- kind: "run";
25
+ export interface CliRunOptions {
14
26
  lang?: Lang;
15
- } | {
27
+ dryRun?: true;
28
+ check?: true;
29
+ json?: true;
30
+ nonInteractive?: true;
31
+ }
32
+ export type CliAction = ({
33
+ kind: "run";
34
+ } & CliRunOptions) | {
16
35
  kind: "help";
17
36
  } | {
18
37
  kind: "version";
package/dist/cli.js CHANGED
@@ -1,12 +1,17 @@
1
1
  import { createRequire } from "node:module";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import * as clack from "@clack/prompts";
3
5
  import { scanRepo } from "./core/scanner.js";
4
6
  import { writeGeneratedFiles } from "./core/writer.js";
5
- import { renderClaudeMd, renderAgentsMd, renderCopilotInstructions, renderPromptFiles, renderRepoFacts, } from "./core/templates.js";
7
+ import { renderClaudeMd, renderAgentsMd, renderCopilotInstructions, renderPromptFiles, } from "./core/templates.js";
6
8
  import { buildRepoFacts } from "./core/repo-facts.js";
7
9
  import { UI, detectLang } from "./core/i18n.js";
10
+ import { loadConfig } from "./core/config.js";
11
+ import { applyProjectExcludes, buildPackageUnits } from "./core/project-units.js";
12
+ import { renderProjectUnitAgents } from "./core/project-unit-output.js";
8
13
  import { collectLowConfidenceQuestions, askQuestions, applyAnswers, makeDefaultPromptFn, hasInteractiveTty, } from "./core/prompt-engine.js";
9
- import { detectAvailableAssistants, polishWithAssistant, defaultExecFn } from "./core/llm-bridge.js";
14
+ import { detectAvailableAssistants, polishFilesWithAssistant, defaultExecFn } from "./core/llm-bridge.js";
10
15
  import { jsTsPack } from "./packs/js-ts.js";
11
16
  import { pythonPack } from "./packs/python.js";
12
17
  import { javaPack } from "./packs/java.js";
@@ -40,19 +45,24 @@ const ALL_PACKS = [
40
45
  rPack,
41
46
  ];
42
47
  export async function runCli(rootPath, options = {}) {
48
+ const loadedConfig = options.config ? { config: options.config, warnings: [] } : loadConfig(rootPath);
49
+ const config = loadedConfig.config;
50
+ options.onConfigWarnings?.(loadedConfig.warnings);
43
51
  const execFn = options.execFn ?? defaultExecFn;
44
- const lang = options.lang ?? detectLang();
52
+ const lang = options.lang ?? config.lang ?? detectLang();
45
53
  const promptFn = options.promptFn ?? makeDefaultPromptFn(lang);
46
54
  const ui = UI[lang];
47
- const signals = scanRepo(rootPath);
55
+ const signals = applyProjectExcludes(scanRepo(rootPath), config.exclude ?? []);
48
56
  const rawDetections = ALL_PACKS.map((pack) => pack.detect(signals)).filter((d) => d !== null);
49
57
  const questions = collectLowConfidenceQuestions(rawDetections, lang);
50
- const answers = await askQuestions(questions, promptFn);
58
+ const answers = options.nonInteractive ? {} : await askQuestions(questions, promptFn);
51
59
  const detections = applyAnswers(rawDetections, answers);
52
60
  const facts = buildRepoFacts(signals, lang);
61
+ options.onFacts?.(facts);
62
+ const ctx = { facts, signals };
53
63
  const entries = detections.map((detection) => {
54
64
  const pack = ALL_PACKS.find((p) => p.id === detection.packId);
55
- return { detection, ruleSet: pack.rules(detection, lang) };
65
+ return { detection, ruleSet: pack.rules(detection, lang, ctx) };
56
66
  });
57
67
  const files = [];
58
68
  if (entries.length > 0) {
@@ -64,38 +74,51 @@ export async function runCli(rootPath, options = {}) {
64
74
  });
65
75
  for (const detection of detections) {
66
76
  const pack = ALL_PACKS.find((p) => p.id === detection.packId);
67
- for (const file of renderPromptFiles(detection.packId, pack.promptTemplates(detection, lang))) {
77
+ for (const file of renderPromptFiles(detection.packId, pack.promptTemplates(detection, lang, ctx))) {
68
78
  files.push(file);
69
79
  }
70
80
  }
71
81
  }
72
82
  else {
73
- const factsBlock = renderRepoFacts(facts, lang);
74
- files.push({
75
- path: "CLAUDE.generated.md",
76
- content: `# CLAUDE.md\n\n${ui.noStackFallback}\n` + (factsBlock ? `\n${factsBlock}\n` : ""),
83
+ const withFallback = (content) => content.replace(ui.generatedHeader, `${ui.generatedHeader}\n\n${ui.noStackFallback}`);
84
+ files.push({ path: "CLAUDE.generated.md", content: withFallback(renderClaudeMd([], facts, lang)) }, { path: "AGENTS.generated.md", content: withFallback(renderAgentsMd([], facts, lang)) }, {
85
+ path: ".github/copilot-instructions.generated.md",
86
+ content: withFallback(renderCopilotInstructions([], facts, lang)),
77
87
  });
78
88
  }
79
- if (!options.skipLlm && hasInteractiveTty()) {
89
+ // Nested AGENTS files are intentionally limited to package-local facts and stack
90
+ // signals. The root documents remain the cross-repository overview.
91
+ for (const unit of buildPackageUnits(signals)) {
92
+ const scoped = renderProjectUnitAgents(unit, lang, config.projects?.[unit.path]);
93
+ if (scoped)
94
+ files.push(scoped);
95
+ }
96
+ if (!options.skipLlm && !config.noAi && !options.nonInteractive && hasInteractiveTty()) {
80
97
  const assistants = await detectAvailableAssistants(execFn);
81
98
  if (assistants.length > 0) {
82
99
  const chosenAssistant = assistants[0];
83
100
  clack.log.info(ui.polishDetected(chosenAssistant));
84
101
  const usePolish = await clack.confirm({ message: ui.polishConfirm(chosenAssistant) });
85
102
  if (usePolish === true) {
86
- for (const file of files) {
87
- file.content = await polishWithAssistant(chosenAssistant, file.content, execFn, lang);
88
- }
103
+ const polished = await polishFilesWithAssistant(chosenAssistant, files, execFn, lang);
104
+ files.splice(0, files.length, ...polished);
89
105
  }
90
106
  }
91
107
  }
108
+ options.onGeneratedFiles?.(files);
109
+ if (options.dryRun) {
110
+ return files.map((file) => ({
111
+ path: file.path,
112
+ status: fs.existsSync(path.join(rootPath, file.path)) ? "skipped" : "written",
113
+ }));
114
+ }
92
115
  return writeGeneratedFiles(rootPath, files);
93
116
  }
94
117
  function isLang(value) {
95
118
  return value === "es" || value === "en";
96
119
  }
97
120
  export function resolveCliAction(argv) {
98
- let lang;
121
+ const options = {};
99
122
  for (let i = 0; i < argv.length; i++) {
100
123
  const arg = argv[i];
101
124
  if (arg === "--help" || arg === "-h")
@@ -106,12 +129,31 @@ export function resolveCliAction(argv) {
106
129
  const value = arg.startsWith("--lang=") ? arg.slice("--lang=".length) : argv[++i] ?? "";
107
130
  if (!isLang(value))
108
131
  return { kind: "invalid-lang", value };
109
- lang = value;
132
+ options.lang = value;
133
+ continue;
134
+ }
135
+ if (arg === "--dry-run") {
136
+ options.dryRun = true;
137
+ continue;
138
+ }
139
+ if (arg === "--check") {
140
+ options.check = true;
141
+ continue;
142
+ }
143
+ if (arg === "--json") {
144
+ options.json = true;
145
+ continue;
146
+ }
147
+ if (arg === "--non-interactive") {
148
+ options.nonInteractive = true;
110
149
  continue;
111
150
  }
112
151
  return { kind: "unknown", flag: arg };
113
152
  }
114
- return lang ? { kind: "run", lang } : { kind: "run" };
153
+ return { kind: "run", ...options };
154
+ }
155
+ function usageWithAutomationOptions(ui) {
156
+ return `${ui.usage}\n\n${ui.automationUsage}`;
115
157
  }
116
158
  export function getVersion() {
117
159
  // Works both from src/ (tests) and dist/ (published bin): ../package.json
@@ -121,10 +163,10 @@ export function getVersion() {
121
163
  }
122
164
  export async function main() {
123
165
  const action = resolveCliAction(process.argv.slice(2));
124
- const lang = action.kind === "run" && action.lang ? action.lang : detectLang();
125
- const ui = UI[lang];
166
+ const defaultLang = action.kind === "run" && action.lang ? action.lang : detectLang();
167
+ let ui = UI[defaultLang];
126
168
  if (action.kind === "help") {
127
- console.log(ui.usage);
169
+ console.log(usageWithAutomationOptions(ui));
128
170
  return;
129
171
  }
130
172
  if (action.kind === "version") {
@@ -132,46 +174,113 @@ export async function main() {
132
174
  return;
133
175
  }
134
176
  if (action.kind === "unknown") {
135
- console.error(`${ui.unknownOption(action.flag)}\n\n${ui.usage}`);
177
+ console.error(`${ui.unknownOption(action.flag)}\n\n${usageWithAutomationOptions(ui)}`);
136
178
  process.exitCode = 1;
137
179
  return;
138
180
  }
139
181
  if (action.kind === "invalid-lang") {
140
- console.error(`${ui.invalidLang(action.value)}\n\n${ui.usage}`);
182
+ console.error(`${ui.invalidLang(action.value)}\n\n${usageWithAutomationOptions(ui)}`);
141
183
  process.exitCode = 1;
142
184
  return;
143
185
  }
144
- clack.intro("agent-rules-init");
145
- if (!hasInteractiveTty()) {
186
+ const machineOutput = action.json === true;
187
+ const planningOnly = action.dryRun === true || action.check === true;
188
+ const nonInteractive = action.nonInteractive === true || machineOutput || planningOnly;
189
+ if (!machineOutput)
190
+ clack.intro("agent-rules-init");
191
+ if (!machineOutput && !nonInteractive && !hasInteractiveTty()) {
146
192
  console.warn(ui.noTtyWarning);
147
193
  }
148
194
  try {
149
- const results = await runCli(process.cwd(), { lang });
195
+ const loadedConfig = loadConfig(process.cwd());
196
+ const lang = action.lang ?? loadedConfig.config.lang ?? defaultLang;
197
+ ui = UI[lang];
198
+ if (!machineOutput) {
199
+ for (const warning of loadedConfig.warnings)
200
+ console.warn(warning);
201
+ }
202
+ let generatedFiles = [];
203
+ let repoFacts;
204
+ const results = await runCli(process.cwd(), {
205
+ lang,
206
+ config: loadedConfig.config,
207
+ dryRun: planningOnly,
208
+ nonInteractive,
209
+ skipLlm: nonInteractive,
210
+ onGeneratedFiles: (files) => {
211
+ generatedFiles = files;
212
+ },
213
+ onFacts: (facts) => {
214
+ repoFacts = facts;
215
+ },
216
+ });
150
217
  const written = results.filter((r) => r.status === "written");
151
218
  const failures = results.filter((r) => r.status === "error");
152
- for (const result of results) {
153
- if (result.status === "written") {
154
- clack.log.success(result.path);
155
- }
156
- else if (result.status === "skipped") {
157
- clack.log.info(ui.fileSkipped(result.path));
219
+ const outdated = action.check
220
+ ? generatedFiles.filter((file) => {
221
+ const absolutePath = path.join(process.cwd(), file.path);
222
+ return fs.existsSync(absolutePath) && fs.readFileSync(absolutePath, "utf8") !== file.content;
223
+ })
224
+ : [];
225
+ const checkIssues = written.length + outdated.length;
226
+ if (machineOutput) {
227
+ const contentByPath = new Map(generatedFiles.map((file) => [file.path, file.content]));
228
+ console.log(JSON.stringify({
229
+ mode: action.check ? "check" : action.dryRun ? "dry-run" : "write",
230
+ configWarnings: loadedConfig.warnings,
231
+ facts: repoFacts,
232
+ wouldCreate: written.length,
233
+ outdated: outdated.map((file) => file.path),
234
+ results: results.map((result) => ({
235
+ ...result,
236
+ ...(planningOnly ? { content: contentByPath.get(result.path) } : {}),
237
+ })),
238
+ }));
239
+ }
240
+ else if (action.dryRun) {
241
+ const statusByPath = new Map(results.map((result) => [result.path, result.status]));
242
+ for (const file of generatedFiles) {
243
+ console.log(`\n--- ${file.path} (${statusByPath.get(file.path) === "written" ? "would create" : "exists"}) ---\n${file.content}`);
158
244
  }
159
- else {
160
- clack.log.warn(`${result.path}: ${result.error}`);
245
+ }
246
+ else if (!action.check)
247
+ for (const result of results) {
248
+ if (result.status === "written") {
249
+ clack.log.success(result.path);
250
+ }
251
+ else if (result.status === "skipped") {
252
+ clack.log.info(ui.fileSkipped(result.path));
253
+ }
254
+ else {
255
+ clack.log.warn(`${result.path}: ${result.error}`);
256
+ }
161
257
  }
258
+ if (!machineOutput && action.check) {
259
+ console.log(checkIssues > 0
260
+ ? `${written.length} file(s) missing; ${outdated.length} file(s) outdated.`
261
+ : "Generated files are present and up to date.");
162
262
  }
163
- if (written.length > 0) {
263
+ else if (!machineOutput && action.dryRun) {
264
+ console.log(`\n${written.length} file(s) would be generated.`);
265
+ }
266
+ else if (!machineOutput && written.length > 0) {
164
267
  clack.outro(ui.outroWritten);
165
268
  }
166
- else {
269
+ else if (!machineOutput) {
167
270
  clack.outro(ui.outroNothing);
168
271
  }
169
- if (failures.length > 0) {
272
+ if (failures.length > 0 || (action.check && checkIssues > 0)) {
170
273
  process.exitCode = 1;
171
274
  }
172
275
  }
173
276
  catch (err) {
174
- clack.log.error(ui.unexpectedError(err.message));
277
+ const message = ui.unexpectedError(err.message);
278
+ if (machineOutput) {
279
+ console.log(JSON.stringify({ mode: action.check ? "check" : action.dryRun ? "dry-run" : "write", error: message }));
280
+ }
281
+ else {
282
+ clack.log.error(message);
283
+ }
175
284
  process.exitCode = 1;
176
285
  }
177
286
  }
@@ -0,0 +1,4 @@
1
+ import type { CanonicalCommand, CiCommand, CommandEntry, CommandSource, PackContext, RepoSignals } from "./types.js";
2
+ export declare const SOURCE_FILES: Record<CommandSource, string>;
3
+ export declare function selectCanonicalCommands(signals: RepoSignals, commands: CommandEntry[], ciCommands: CiCommand[]): CanonicalCommand[];
4
+ export declare function canonicalOf(ctx: PackContext | undefined, kind: CanonicalCommand["kind"], family?: "js-ts" | "python" | "java"): CanonicalCommand | undefined;
@@ -0,0 +1,169 @@
1
+ export const SOURCE_FILES = {
2
+ npm: "package.json",
3
+ pnpm: "package.json",
4
+ yarn: "package.json",
5
+ bun: "package.json",
6
+ composer: "composer.json",
7
+ make: "Makefile",
8
+ mix: "mix.exs",
9
+ tox: "tox.ini",
10
+ };
11
+ const KIND_ORDER = ["test", "lint", "build", "format", "typecheck"];
12
+ // Nombre de script → kind. Solo nombres inequívocos; "check" o "ci" quedan fuera a propósito.
13
+ const SCRIPT_KINDS = {
14
+ test: "test",
15
+ lint: "lint",
16
+ build: "build",
17
+ format: "format",
18
+ fmt: "format",
19
+ typecheck: "typecheck",
20
+ "type-check": "typecheck",
21
+ };
22
+ function scriptName(entry) {
23
+ const parts = entry.invocation.split(" ");
24
+ return parts[parts.length - 1];
25
+ }
26
+ function isRootManifest(entry) {
27
+ return entry.manifestPath === undefined || entry.manifestPath === "package.json";
28
+ }
29
+ function fromScripts(commands) {
30
+ const found = [];
31
+ for (const entry of commands) {
32
+ if (!isRootManifest(entry))
33
+ continue;
34
+ const kind = SCRIPT_KINDS[scriptName(entry)];
35
+ if (!kind || found.some((c) => c.kind === kind))
36
+ continue;
37
+ found.push({
38
+ kind,
39
+ command: entry.invocation,
40
+ source: entry.manifestPath ?? SOURCE_FILES[entry.source],
41
+ confidence: "high",
42
+ scope: ".",
43
+ });
44
+ }
45
+ return found;
46
+ }
47
+ // El comando de CI se conserva textual (evidencia), solo se clasifica su kind.
48
+ const CI_PATTERNS = [
49
+ [/^(?:npm|pnpm|yarn|bun)(?: run)? test\b/, "test"],
50
+ [/^(?:npm|pnpm|yarn|bun) run lint\b/, "lint"],
51
+ [/^(?:npm|pnpm|yarn|bun) run build\b/, "build"],
52
+ [/^(?:npm|pnpm|yarn|bun) run (?:format|fmt)\b/, "format"],
53
+ [/^(?:npm|pnpm|yarn|bun) run (?:typecheck|type-check)\b/, "typecheck"],
54
+ [/^(?:\.[\\/])?mvnw(?:\.cmd)?\b.*\b(?:verify|test)\b/, "test"],
55
+ [/^(?:\.[\\/])?mvnw(?:\.cmd)?\b.*\bpackage\b/, "build"],
56
+ [/^(?:\.[\\/])?gradlew(?:\.bat)?\b.*\b(?:check|test)\b/, "test"],
57
+ [/^(?:\.[\\/])?gradlew(?:\.bat)?\b.*\b(?:build|assemble)\b/, "build"],
58
+ [/^uv run\b.*\b(?:pytest|tox(?:\s+run)?)\b/, "test"],
59
+ [/^poetry run\b.*\b(?:pytest|tox)\b/, "test"],
60
+ [/^pytest\b/, "test"],
61
+ [/^tox(?:\s+run)?\b/, "test"],
62
+ [/^cargo test\b/, "test"],
63
+ [/^go test\b/, "test"],
64
+ ];
65
+ function fromCi(ciCommands) {
66
+ const found = [];
67
+ for (const ci of ciCommands) {
68
+ for (const [pattern, kind] of CI_PATTERNS) {
69
+ if (!pattern.test(ci.command))
70
+ continue;
71
+ if (found.some((c) => c.kind === kind))
72
+ break;
73
+ found.push({
74
+ kind,
75
+ command: ci.command,
76
+ source: `ci: ${ci.workflow}`,
77
+ confidence: "high",
78
+ scope: ".",
79
+ });
80
+ break;
81
+ }
82
+ }
83
+ return found;
84
+ }
85
+ function fromSignals(signals) {
86
+ const out = [];
87
+ if (signals.pomXml) {
88
+ const unixWrapper = signals.hasFile("mvnw");
89
+ const windowsWrapper = !unixWrapper && signals.hasFile("mvnw.cmd");
90
+ const hasWrapper = unixWrapper || windowsWrapper;
91
+ out.push({
92
+ kind: "test",
93
+ command: unixWrapper ? "./mvnw test" : windowsWrapper ? "mvnw.cmd test" : "mvn test",
94
+ source: unixWrapper ? "mvnw" : windowsWrapper ? "mvnw.cmd" : "pom.xml",
95
+ confidence: hasWrapper ? "high" : "low",
96
+ scope: ".",
97
+ });
98
+ }
99
+ else if (signals.buildGradle) {
100
+ const unixWrapper = signals.hasFile("gradlew");
101
+ const windowsWrapper = !unixWrapper && signals.hasFile("gradlew.bat");
102
+ const hasWrapper = unixWrapper || windowsWrapper;
103
+ out.push({
104
+ kind: "test",
105
+ command: unixWrapper ? "./gradlew test" : windowsWrapper ? "gradlew.bat test" : "gradle test",
106
+ source: unixWrapper ? "gradlew" : windowsWrapper ? "gradlew.bat" : "build.gradle",
107
+ confidence: hasWrapper ? "high" : "low",
108
+ scope: ".",
109
+ });
110
+ }
111
+ const pythonManifest = signals.pyprojectToml ?? signals.requirementsTxt ?? signals.environmentYml;
112
+ const hasPytest = pythonManifest !== undefined && /\bpytest\b/i.test(pythonManifest);
113
+ if (signals.toxIni) {
114
+ out.push({ kind: "test", command: "tox", source: "tox.ini", confidence: "high", scope: "." });
115
+ }
116
+ else if (hasPytest && signals.hasFile("uv.lock")) {
117
+ // El lock demuestra el gestor, no que pytest pertenezca a un grupo instalado por defecto.
118
+ out.push({ kind: "test", command: "uv run pytest", source: "uv.lock", confidence: "low", scope: "." });
119
+ }
120
+ else if (hasPytest && signals.hasFile("poetry.lock")) {
121
+ out.push({ kind: "test", command: "poetry run pytest", source: "poetry.lock", confidence: "low", scope: "." });
122
+ }
123
+ else if (hasPytest) {
124
+ const source = signals.pyprojectToml
125
+ ? "pyproject.toml"
126
+ : signals.requirementsTxt
127
+ ? "requirements.txt"
128
+ : "environment.yml";
129
+ out.push({ kind: "test", command: "pytest", source, confidence: "low", scope: "." });
130
+ }
131
+ return out;
132
+ }
133
+ export function selectCanonicalCommands(signals, commands, ciCommands) {
134
+ const byKind = new Map();
135
+ for (const candidate of [...fromScripts(commands), ...fromCi(ciCommands), ...fromSignals(signals)]) {
136
+ if (!byKind.has(candidate.kind))
137
+ byKind.set(candidate.kind, candidate);
138
+ }
139
+ return KIND_ORDER.flatMap((kind) => byKind.get(kind) ?? []);
140
+ }
141
+ export function canonicalOf(ctx, kind, family) {
142
+ if (family && ctx?.signals) {
143
+ return selectCanonicalForFamily({ ...ctx, signals: ctx.signals }, family).find((c) => c.kind === kind && c.confidence === "high");
144
+ }
145
+ return ctx?.facts.canonical.find((c) => c.kind === kind && c.confidence === "high");
146
+ }
147
+ function selectCanonicalForFamily(ctx, family) {
148
+ const jsSources = new Set(["npm", "pnpm", "yarn", "bun"]);
149
+ const commands = ctx.facts.commands.filter((command) => family === "js-ts" ? jsSources.has(command.source) : family === "python" ? command.source === "tox" : false);
150
+ const ciCommands = ctx.facts.ciCommands.filter(({ command }) => {
151
+ if (family === "js-ts")
152
+ return /^(?:npm|pnpm|yarn|bun)\b/.test(command);
153
+ if (family === "python")
154
+ return /^(?:uv|poetry|pytest|tox)\b/.test(command);
155
+ return /^(?:\.[\\/])?(?:mvnw(?:\.cmd)?|gradlew(?:\.bat)?)\b|^(?:mvn|gradle)\b/.test(command);
156
+ });
157
+ const signals = {
158
+ ...ctx.signals,
159
+ packageJson: family === "js-ts" ? ctx.signals.packageJson : undefined,
160
+ packageJsons: family === "js-ts" ? ctx.signals.packageJsons : undefined,
161
+ pyprojectToml: family === "python" ? ctx.signals.pyprojectToml : undefined,
162
+ requirementsTxt: family === "python" ? ctx.signals.requirementsTxt : undefined,
163
+ environmentYml: family === "python" ? ctx.signals.environmentYml : undefined,
164
+ toxIni: family === "python" ? ctx.signals.toxIni : undefined,
165
+ pomXml: family === "java" ? ctx.signals.pomXml : undefined,
166
+ buildGradle: family === "java" ? ctx.signals.buildGradle : undefined,
167
+ };
168
+ return selectCanonicalCommands(signals, commands, ciCommands);
169
+ }
@@ -0,0 +1,25 @@
1
+ import type { Lang } from "./i18n.js";
2
+ export interface ProjectConfig {
3
+ framework?: string;
4
+ testRunner?: string;
5
+ linter?: string;
6
+ packageManager?: string;
7
+ }
8
+ export interface AgentRulesConfig {
9
+ lang?: Lang;
10
+ exclude?: string[];
11
+ projects?: Record<string, ProjectConfig>;
12
+ noAi?: boolean;
13
+ }
14
+ export interface LoadedConfig {
15
+ config: AgentRulesConfig;
16
+ /** Absolute path to the selected config file, if one exists. */
17
+ sourcePath?: string;
18
+ warnings: string[];
19
+ }
20
+ export declare class ConfigError extends Error {
21
+ readonly configPath: string;
22
+ constructor(message: string, configPath: string, options?: ErrorOptions);
23
+ }
24
+ /** Loads and validates the optional repository-local agent-rules configuration. */
25
+ export declare function loadConfig(rootPath: string): LoadedConfig;