@event4u/agent-config 1.9.1 → 1.12.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.
Files changed (77) hide show
  1. package/.agent-src/commands/agent-handoff.md +15 -0
  2. package/.agent-src/commands/chat-history-clear.md +98 -0
  3. package/.agent-src/commands/chat-history-resume.md +178 -0
  4. package/.agent-src/commands/chat-history.md +102 -0
  5. package/.agent-src/commands/compress.md +9 -9
  6. package/.agent-src/commands/copilot-agents-init.md +1 -1
  7. package/.agent-src/commands/fix-portability.md +2 -2
  8. package/.agent-src/commands/fix-pr-bot-comments.md +1 -1
  9. package/.agent-src/commands/fix-pr-developer-comments.md +1 -1
  10. package/.agent-src/commands/fix-references.md +2 -2
  11. package/.agent-src/commands/mode.md +5 -5
  12. package/.agent-src/commands/onboard.md +171 -0
  13. package/.agent-src/commands/roadmap-create.md +7 -2
  14. package/.agent-src/commands/roadmap-execute.md +2 -2
  15. package/.agent-src/commands/set-cost-profile.md +101 -0
  16. package/.agent-src/commands/sync-agent-settings.md +122 -0
  17. package/.agent-src/commands/sync-gitignore.md +104 -0
  18. package/.agent-src/commands/tests-execute.md +6 -6
  19. package/.agent-src/commands/upstream-contribute.md +5 -4
  20. package/.agent-src/contexts/augment-infrastructure.md +2 -2
  21. package/.agent-src/contexts/override-system.md +1 -1
  22. package/.agent-src/contexts/subagent-configuration.md +3 -3
  23. package/.agent-src/guidelines/agent-infra/layered-settings.md +48 -5
  24. package/.agent-src/rules/ask-when-uncertain.md +56 -3
  25. package/.agent-src/rules/augment-portability.md +52 -1
  26. package/.agent-src/rules/augment-source-of-truth.md +10 -10
  27. package/.agent-src/rules/chat-history.md +171 -0
  28. package/.agent-src/rules/docker-commands.md +5 -7
  29. package/.agent-src/rules/docs-sync.md +13 -9
  30. package/.agent-src/rules/improve-before-implement.md +2 -0
  31. package/.agent-src/rules/onboarding-gate.md +94 -0
  32. package/.agent-src/rules/package-ci-checks.md +6 -5
  33. package/.agent-src/rules/roadmap-progress-sync.md +24 -13
  34. package/.agent-src/rules/size-enforcement.md +1 -1
  35. package/.agent-src/rules/skill-quality.md +1 -1
  36. package/.agent-src/rules/think-before-action.md +1 -0
  37. package/.agent-src/scripts/update_roadmap_progress.py +26 -9
  38. package/.agent-src/skills/check-refs/SKILL.md +1 -1
  39. package/.agent-src/skills/command-routing/SKILL.md +1 -1
  40. package/.agent-src/skills/command-writing/SKILL.md +4 -3
  41. package/.agent-src/skills/file-editor/SKILL.md +2 -2
  42. package/.agent-src/skills/guideline-writing/SKILL.md +4 -3
  43. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +2 -2
  44. package/.agent-src/skills/lint-skills/SKILL.md +1 -1
  45. package/.agent-src/skills/roadmap-management/SKILL.md +13 -10
  46. package/.agent-src/skills/rtk-output-filtering/SKILL.md +20 -30
  47. package/.agent-src/skills/rule-writing/SKILL.md +5 -5
  48. package/.agent-src/skills/terragrunt/SKILL.md +0 -8
  49. package/.agent-src/skills/upstream-contribute/SKILL.md +5 -4
  50. package/.agent-src/templates/agent-settings.md +86 -34
  51. package/.claude-plugin/marketplace.json +1 -1
  52. package/AGENTS.md +2 -2
  53. package/CHANGELOG.md +296 -0
  54. package/CONTRIBUTING.md +89 -40
  55. package/README.md +3 -3
  56. package/composer.json +2 -1
  57. package/config/agent-settings.template.yml +45 -6
  58. package/config/gitignore-block.txt +24 -0
  59. package/config/profiles/balanced.ini +5 -0
  60. package/config/profiles/full.ini +5 -0
  61. package/config/profiles/minimal.ini +5 -0
  62. package/docs/customization.md +30 -4
  63. package/docs/getting-started.md +52 -3
  64. package/docs/mcp.md +15 -4
  65. package/package.json +13 -2
  66. package/scripts/agent-config +155 -0
  67. package/scripts/chat_history.py +519 -0
  68. package/scripts/check_portability.py +151 -1
  69. package/scripts/install.py +55 -3
  70. package/scripts/install.sh +50 -21
  71. package/scripts/mcp_render.py +30 -16
  72. package/scripts/release.py +588 -0
  73. package/scripts/sync_agent_settings.py +211 -0
  74. package/scripts/sync_gitignore.py +226 -0
  75. package/templates/agent-config-wrapper.sh +47 -0
  76. package/.agent-src/commands/config-agent-settings.md +0 -126
  77. package/.agent-src/skills/eloquent/evals/last-run.json +0 -99
@@ -248,10 +248,137 @@ def check_file(filepath: Path, patterns: list, allowlist: list) -> List[Violatio
248
248
  return violations
249
249
 
250
250
 
251
+ # ── Task-command detector ───────────────────────────────────────────────
252
+ # Artefact files shipped in the package must not reference `task <name>`
253
+ # invocations (per augment-portability rule). Consumer projects may not
254
+ # have Taskfile installed; agents must use direct script paths instead.
255
+ ARTIFACT_SUBDIRS = ["skills", "rules", "commands", "guidelines", "personas", "contexts"]
256
+
257
+ # Inline code: `task foo` or `task foo-bar` or `task foo:bar`
258
+ _TASK_INLINE_RE = re.compile(r"`task\s+([a-z][a-z0-9:_-]*)`")
259
+ # Code-fence line: "task foo …" (optional leading whitespace)
260
+ _TASK_FENCE_RE = re.compile(r"^\s*task\s+([a-z][a-z0-9:_-]*)\b")
261
+
262
+ # Files that legitimately document the forbidden pattern — they define
263
+ # the rule itself. Any path containing one of these suffixes is skipped
264
+ # by the task-invocation detector (but still scanned for layer 1 + 2).
265
+ _TASK_DETECTOR_SKIP = (
266
+ "rules/augment-portability.md",
267
+ )
268
+
269
+
270
+ def check_task_invocations(filepath: Path) -> List[Violation]:
271
+ """Flag `task <cmd>` invocations in inline code or code fence lines."""
272
+ violations: List[Violation] = []
273
+ try:
274
+ lines = filepath.read_text(encoding="utf-8").splitlines()
275
+ except Exception:
276
+ return violations
277
+
278
+ in_code_block = False
279
+ for i, line in enumerate(lines, 1):
280
+ stripped = line.strip()
281
+ if stripped.startswith("```"):
282
+ in_code_block = not in_code_block
283
+ continue
284
+ if in_code_block:
285
+ m = _TASK_FENCE_RE.search(line)
286
+ if m:
287
+ violations.append(Violation(
288
+ file=str(filepath), line=i, match=m.group(0).strip(),
289
+ pattern_name="task-invocation", severity="error",
290
+ context=stripped,
291
+ ))
292
+ else:
293
+ for m in _TASK_INLINE_RE.finditer(line):
294
+ violations.append(Violation(
295
+ file=str(filepath), line=i, match=m.group(0),
296
+ pattern_name="task-invocation", severity="error",
297
+ context=stripped,
298
+ ))
299
+
300
+ return violations
301
+
302
+
303
+ # ── Direct script-invocation detector ───────────────────────────────────
304
+ # Artefacts shipped to consumers must use the `./agent-config` CLI for
305
+ # commands it already covers. Direct `python3 scripts/…` / `bash scripts/…`
306
+ # invocations only work inside the package repo, not in a consumer project
307
+ # where the scripts live under node_modules/ or vendor/.
308
+ #
309
+ # Each entry: (regex, suggested replacement). Patterns match inside inline
310
+ # backticks OR anywhere on a code-fence line.
311
+ _CLI_INVOCATION_MAP: list[tuple[re.Pattern, str]] = [
312
+ (
313
+ re.compile(r"python3\s+scripts/mcp_render\.py\s+--check\b"),
314
+ "./agent-config mcp:check",
315
+ ),
316
+ (
317
+ re.compile(r"python3\s+scripts/mcp_render\.py\b"),
318
+ "./agent-config mcp:render",
319
+ ),
320
+ (
321
+ re.compile(r"python3\s+\.(?:agent-src|augment)/scripts/update_roadmap_progress\.py\s+--check\b"),
322
+ "./agent-config roadmap:progress-check",
323
+ ),
324
+ (
325
+ re.compile(r"python3\s+\.(?:agent-src|augment)/scripts/update_roadmap_progress\.py\b"),
326
+ "./agent-config roadmap:progress",
327
+ ),
328
+ (
329
+ re.compile(r"bash\s+scripts/first-run\.sh\b"),
330
+ "./agent-config first-run",
331
+ ),
332
+ ]
333
+
334
+ # Paths that legitimately document the raw invocations (e.g. the CLI's
335
+ # own help, the portability rule that defines the mapping).
336
+ _CLI_DETECTOR_SKIP = (
337
+ "rules/augment-portability.md",
338
+ )
339
+
340
+
341
+ def check_cli_invocations(filepath: Path) -> List[Violation]:
342
+ """Flag direct script invocations that should go through `./agent-config`."""
343
+ violations: List[Violation] = []
344
+ try:
345
+ lines = filepath.read_text(encoding="utf-8").splitlines()
346
+ except Exception:
347
+ return violations
348
+
349
+ in_code_block = False
350
+ for i, line in enumerate(lines, 1):
351
+ stripped = line.strip()
352
+ if stripped.startswith("```"):
353
+ in_code_block = not in_code_block
354
+ continue
355
+
356
+ # In prose lines, only check content inside inline `...` spans to
357
+ # avoid false positives in running text. In code fences, check the
358
+ # whole line.
359
+ if in_code_block:
360
+ segments = [line]
361
+ else:
362
+ segments = re.findall(r"`([^`]+)`", line)
363
+
364
+ for seg in segments:
365
+ for pattern, replacement in _CLI_INVOCATION_MAP:
366
+ m = pattern.search(seg)
367
+ if m:
368
+ violations.append(Violation(
369
+ file=str(filepath), line=i, match=m.group(0),
370
+ pattern_name=f"cli-bypass → use `{replacement}`",
371
+ severity="error", context=stripped,
372
+ ))
373
+ break # one hit per segment is enough
374
+
375
+ return violations
376
+
377
+
251
378
  def scan_all(root: Path) -> tuple[List[Violation], list[str]]:
252
379
  """Scan all package files for portability violations. Returns (violations, detected_identifiers).
253
380
 
254
- Scanning has two layers:
381
+ Scanning has four layers:
255
382
  1. Auto-detected identifiers — applied to `.agent-src/` and
256
383
  `.agent-src.uncompressed/` only. The package's own root AGENTS.md and
257
384
  copilot-instructions.md are meta docs ABOUT the package, so the
@@ -259,6 +386,13 @@ def scan_all(root: Path) -> tuple[List[Violation], list[str]]:
259
386
  2. Optional FORBIDDEN_IDENTIFIERS from AGENT_CONFIG_BLOCKLIST —
260
387
  applied to every scanned file, including the root files. Catches
261
388
  leakage from renamed or adjacent projects in downstream forks.
389
+ 3. `task <name>` invocations inside artefact subdirs — skills, rules,
390
+ commands, guidelines, personas, contexts. These shipped artefacts
391
+ run in consumer projects that may not have Taskfile installed.
392
+ 4. Direct script invocations that bypass the `./agent-config` CLI
393
+ (e.g. `python3 scripts/mcp_render.py`). Same artefact-subdir scope
394
+ as layer 3; consumer projects only have the package under
395
+ `node_modules/` or `vendor/`, so the raw paths never resolve.
262
396
  """
263
397
  patterns, detected = _compile_patterns(root)
264
398
  forbidden = _compile_forbidden_patterns()
@@ -279,6 +413,22 @@ def scan_all(root: Path) -> tuple[List[Violation], list[str]]:
279
413
  if f.is_file():
280
414
  violations.extend(check_file(f, forbidden, allowlist))
281
415
 
416
+ # Layer 3 + 4: artefact-subdir-only scans (task invocations, CLI bypass)
417
+ for scan_dir in SCAN_DIRS:
418
+ base = root / scan_dir
419
+ if not base.exists():
420
+ continue
421
+ for sub in ARTIFACT_SUBDIRS:
422
+ d = base / sub
423
+ if not d.exists():
424
+ continue
425
+ for f in sorted(d.rglob("*.md")):
426
+ path_str = str(f)
427
+ if not any(path_str.endswith(skip) for skip in _TASK_DETECTOR_SKIP):
428
+ violations.extend(check_task_invocations(f))
429
+ if not any(path_str.endswith(skip) for skip in _CLI_DETECTOR_SKIP):
430
+ violations.extend(check_cli_invocations(f))
431
+
282
432
  return violations, detected
283
433
 
284
434
 
@@ -223,11 +223,17 @@ def _parse_legacy_settings(text: str) -> "tuple[dict, list]":
223
223
  return values, unknown
224
224
 
225
225
 
226
+ _BARE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
227
+
228
+
226
229
  def _yaml_scalar(value: str) -> str:
227
230
  """Format a string value as a YAML scalar with minimal quoting.
228
231
 
229
- Booleans and non-negative integers are emitted unquoted; everything
230
- else is double-quoted so the migrated file is unambiguous.
232
+ Booleans and non-negative integers are emitted unquoted. Bare
233
+ lowercase identifiers (``per_turn``, ``rotate``, ``getters_setters``
234
+ — the shape of profile values and enum-like strings) are emitted
235
+ unquoted so `sync_agent_settings.py` stays idempotent against its
236
+ own output. Everything else is double-quoted.
231
237
  """
232
238
  if value == "":
233
239
  return '""'
@@ -235,6 +241,8 @@ def _yaml_scalar(value: str) -> str:
235
241
  return value
236
242
  if value.isdigit():
237
243
  return value
244
+ if _BARE_ID_RE.match(value):
245
+ return value
238
246
  # Escape backslashes and double-quotes, then wrap
239
247
  escaped = value.replace("\\", "\\\\").replace('"', '\\"')
240
248
  return f'"{escaped}"'
@@ -330,6 +338,44 @@ def _migrate_legacy_if_present(project_root: Path, template_body: str) -> "str |
330
338
 
331
339
  # --- Bridge generators ---
332
340
 
341
+ def _parse_profile_ini(path: Path) -> "dict[str, str]":
342
+ """Parse a simple key=value profile preset (comments start with ; or #)."""
343
+ values: "dict[str, str]" = {}
344
+ for raw in path.read_text(encoding="utf-8").splitlines():
345
+ line = raw.strip()
346
+ if not line or line.startswith(";") or line.startswith("#"):
347
+ continue
348
+ if "=" not in line:
349
+ continue
350
+ key, _, val = line.partition("=")
351
+ values[key.strip()] = val.strip()
352
+ return values
353
+
354
+
355
+ _PLACEHOLDER_RE = re.compile(r"__[A-Z][A-Z0-9_]*__")
356
+
357
+
358
+ def _render_template(template: str, profile_values: "dict[str, str]") -> str:
359
+ """Substitute __UPPER_KEY__ placeholders using ini values.
360
+
361
+ Each ini key `foo_bar` maps to the `__FOO_BAR__` placeholder. Fails
362
+ if any placeholder remains unfilled — catches typos and missing
363
+ profile entries early.
364
+ """
365
+ body = template
366
+ for key, value in profile_values.items():
367
+ placeholder = f"__{key.upper()}__"
368
+ if placeholder in body:
369
+ body = body.replace(placeholder, value)
370
+ leftover = sorted(set(_PLACEHOLDER_RE.findall(body)))
371
+ if leftover:
372
+ fail(
373
+ "Template has unfilled placeholders after profile render: "
374
+ + ", ".join(leftover)
375
+ )
376
+ return body
377
+
378
+
333
379
  def ensure_agent_settings(project_root: Path, package_root: Path, profile: str, force: bool) -> None:
334
380
  target = project_root / SETTINGS_FILE
335
381
  profile_source = package_root / "config" / "profiles" / f"{profile}.ini"
@@ -343,7 +389,13 @@ def ensure_agent_settings(project_root: Path, package_root: Path, profile: str,
343
389
  template = template_source.read_text(encoding="utf-8")
344
390
  if COST_PROFILE_PLACEHOLDER not in template:
345
391
  fail(f"Template is missing placeholder {COST_PROFILE_PLACEHOLDER}")
346
- template_body = template.replace(COST_PROFILE_PLACEHOLDER, profile)
392
+ profile_values = _parse_profile_ini(profile_source)
393
+ if profile_values.get("cost_profile") != profile:
394
+ fail(
395
+ f"Profile preset {profile_source.name} has cost_profile="
396
+ f"{profile_values.get('cost_profile')!r} but --profile={profile}"
397
+ )
398
+ template_body = _render_template(template, profile_values)
347
399
 
348
400
  legacy_target = project_root / LEGACY_SETTINGS_FILE
349
401
  if legacy_target.is_file() and target.exists():
@@ -19,7 +19,6 @@ set -euo pipefail
19
19
 
20
20
  # --- Configuration ---
21
21
  COPY_DIRS="rules" # Subdirectories where files must be real copies (space-separated)
22
- GITIGNORE_MARKER="# event4u/agent-config"
23
22
 
24
23
  # Rules that are internal to the agent-config package and should NOT be shipped to consumers.
25
24
  # These are only relevant when developing the agent-config package itself.
@@ -35,6 +34,7 @@ TARGET_DIR=""
35
34
  DRY_RUN=false
36
35
  VERBOSE=false
37
36
  QUIET=false
37
+ SKIP_GITIGNORE=false
38
38
 
39
39
  # --- Logging ---
40
40
  log_info() { $QUIET || echo " ✅ $*"; }
@@ -51,6 +51,7 @@ parse_args() {
51
51
  --dry-run) DRY_RUN=true; shift ;;
52
52
  --verbose) VERBOSE=true; shift ;;
53
53
  --quiet) QUIET=true; shift ;;
54
+ --skip-gitignore) SKIP_GITIGNORE=true; shift ;;
54
55
  --help|-h) show_help; exit 0 ;;
55
56
  *) log_error "Unknown argument: $1"; show_help; exit 1 ;;
56
57
  esac
@@ -103,6 +104,7 @@ Options:
103
104
  --dry-run Show what would happen without making changes
104
105
  --verbose Show detailed output
105
106
  --quiet Suppress all output except errors
107
+ --skip-gitignore Do not touch the target project's .gitignore
106
108
  --help, -h Show this help
107
109
 
108
110
  Environment:
@@ -561,39 +563,63 @@ copy_if_missing() {
561
563
  cp "$source" "$target"
562
564
  }
563
565
 
564
- # Ensure .gitignore contains agent-config entries
566
+ # Ensure .gitignore contains the managed agent-config block.
567
+ # Delegates to scripts/sync_gitignore.py so the installer and the
568
+ # standalone /sync-gitignore command share one source of truth
569
+ # (config/gitignore-block.txt). Honors --dry-run and --skip-gitignore.
565
570
  ensure_gitignore() {
566
571
  local project_root="$1"
567
572
  local gitignore="$project_root/.gitignore"
573
+ local sync_script="$SOURCE_DIR/scripts/sync_gitignore.py"
574
+ local template="$SOURCE_DIR/config/gitignore-block.txt"
568
575
 
576
+ if $SKIP_GITIGNORE; then
577
+ log_verbose "skip .gitignore (--skip-gitignore)"
578
+ return 0
579
+ fi
580
+
581
+ # Match the pre-refactor behavior: don't create .gitignore in a
582
+ # project that doesn't use git / doesn't already have one.
569
583
  if [[ ! -f "$gitignore" ]]; then
570
584
  return 0
571
585
  fi
572
586
 
573
- if grep -qF "$GITIGNORE_MARKER" "$gitignore"; then
574
- return 0 # Already present
587
+ if [[ ! -f "$sync_script" || ! -f "$template" ]]; then
588
+ log_warn ".gitignore sync skipped — script or template missing"
589
+ return 0
590
+ fi
591
+
592
+ local args=(--path "$gitignore" --template "$template" --quiet)
593
+ $DRY_RUN && args+=(--dry-run)
594
+
595
+ if python3 "$sync_script" "${args[@]}" >/dev/null 2>&1; then
596
+ log_verbose ".gitignore synced"
597
+ else
598
+ log_warn ".gitignore sync failed (exit $?)"
599
+ fi
600
+ }
601
+
602
+ # Install the consumer-facing CLI wrapper `./agent-config` at the project
603
+ # root. Gitignored, overwritten on every install, delegates to the master
604
+ # CLI shipped in the package (node_modules or vendor).
605
+ install_cli_wrapper() {
606
+ local project_root="$1"
607
+ local template="$SOURCE_DIR/templates/agent-config-wrapper.sh"
608
+ local target="$project_root/agent-config"
609
+
610
+ if [[ ! -f "$template" ]]; then
611
+ log_verbose "CLI wrapper template missing: $template — skipping"
612
+ return 0
575
613
  fi
576
614
 
577
615
  if $DRY_RUN; then
578
- log_verbose "append .gitignore block"
616
+ log_verbose "install CLI wrapper → $target"
579
617
  return
580
618
  fi
581
619
 
582
- cat >> "$gitignore" << 'BLOCK'
583
-
584
- # event4u/agent-config
585
- # Agent config — symlinked from vendor (auto-managed)
586
- .augment/skills/
587
- .augment/commands/
588
- .augment/guidelines/
589
- .augment/templates/
590
- .augment/contexts/
591
- .augment/scripts/
592
- .augment/README.md
593
-
594
- # Agent config — NOT ignored (real copies, may contain project overrides)
595
- # .augment/rules/
596
- BLOCK
620
+ cp "$template" "$target"
621
+ chmod +x "$target"
622
+ log_info "Installed ./agent-config wrapper"
597
623
  }
598
624
 
599
625
  # --- Main ---
@@ -626,7 +652,10 @@ main() {
626
652
  generate_windsurfrules "$TARGET_DIR"
627
653
  create_gemini_md "$TARGET_DIR"
628
654
 
629
- # 5. Manage .gitignore
655
+ # 5. Install consumer CLI wrapper (gitignored, overwritten on every install)
656
+ install_cli_wrapper "$TARGET_DIR"
657
+
658
+ # 6. Manage .gitignore
630
659
  ensure_gitignore "$TARGET_DIR"
631
660
 
632
661
  echo ""
@@ -31,16 +31,23 @@ import sys
31
31
  from pathlib import Path
32
32
  from typing import Any
33
33
 
34
- PROJECT_ROOT = Path(__file__).resolve().parent.parent
35
- SOURCE_FILE = PROJECT_ROOT / "mcp.json"
36
-
37
34
  ENV_PLACEHOLDER = re.compile(r"\$\{env:([^}]+)\}")
38
35
 
39
- # In-project targets. Claude Desktop is user-scope, opt-in via --claude-desktop.
40
- IN_PROJECT_TARGETS: dict[str, Path] = {
41
- "cursor": PROJECT_ROOT / ".cursor" / "mcp.json",
42
- "windsurf": PROJECT_ROOT / ".windsurf" / "mcp.json",
43
- }
36
+ # Project root defaults to the current working directory so the renderer
37
+ # works both for package maintainers (running from the package root via
38
+ # Taskfile) and for consumer projects (running via `./agent-config
39
+ # mcp:render` from their own repo root). Override with --project-root.
40
+ def default_project_root() -> Path:
41
+ return Path.cwd().resolve()
42
+
43
+
44
+ def in_project_targets(project_root: Path) -> dict[str, Path]:
45
+ return {
46
+ "cursor": project_root / ".cursor" / "mcp.json",
47
+ "windsurf": project_root / ".windsurf" / "mcp.json",
48
+ }
49
+
50
+
44
51
  CLAUDE_DESKTOP_TARGET = Path.home() / ".config" / "claude-desktop" / "claude_desktop_config.json"
45
52
 
46
53
 
@@ -104,20 +111,25 @@ def write_target(path: Path, content: dict[str, Any]) -> None:
104
111
  path.write_text(serialized, encoding="utf-8")
105
112
 
106
113
 
107
- def collect_targets(include_claude_desktop: bool) -> dict[str, Path]:
108
- targets = dict(IN_PROJECT_TARGETS)
114
+ def collect_targets(project_root: Path, include_claude_desktop: bool) -> dict[str, Path]:
115
+ targets = dict(in_project_targets(project_root))
109
116
  if include_claude_desktop:
110
117
  targets["claude-desktop"] = CLAUDE_DESKTOP_TARGET
111
118
  return targets
112
119
 
113
120
 
121
+ def resolve_source(args: argparse.Namespace, project_root: Path) -> Path:
122
+ return Path(args.source) if args.source else project_root / "mcp.json"
123
+
124
+
114
125
  def cmd_render(args: argparse.Namespace) -> int:
115
- data = load_source(Path(args.source))
126
+ project_root = Path(args.project_root).resolve() if args.project_root else default_project_root()
127
+ data = load_source(resolve_source(args, project_root))
116
128
  rendered, missing = render(data)
117
129
  if missing:
118
130
  print(format_missing_report(missing), file=sys.stderr)
119
131
  return 1
120
- targets = collect_targets(args.claude_desktop)
132
+ targets = collect_targets(project_root, args.claude_desktop)
121
133
  for name, path in targets.items():
122
134
  write_target(path, rendered)
123
135
  print(f"✅ {name:16} → {path}")
@@ -125,20 +137,21 @@ def cmd_render(args: argparse.Namespace) -> int:
125
137
 
126
138
 
127
139
  def cmd_check(args: argparse.Namespace) -> int:
128
- data = load_source(Path(args.source))
140
+ project_root = Path(args.project_root).resolve() if args.project_root else default_project_root()
141
+ data = load_source(resolve_source(args, project_root))
129
142
  rendered, missing = render(data)
130
143
  if missing:
131
144
  print(format_missing_report(missing), file=sys.stderr)
132
145
  return 1
133
146
  serialized = json.dumps(rendered, indent=2, sort_keys=True) + "\n"
134
- targets = collect_targets(args.claude_desktop)
147
+ targets = collect_targets(project_root, args.claude_desktop)
135
148
  diffs = []
136
149
  for name, path in targets.items():
137
150
  actual = path.read_text(encoding="utf-8") if path.exists() else ""
138
151
  if actual != serialized:
139
152
  diffs.append((name, path))
140
153
  if diffs:
141
- print("❌ Targets out of date (run `task mcp:render`):", file=sys.stderr)
154
+ print("❌ Targets out of date (run `./agent-config mcp:render`):", file=sys.stderr)
142
155
  for name, path in diffs:
143
156
  print(f" - {name}: {path}", file=sys.stderr)
144
157
  return 1
@@ -148,7 +161,8 @@ def cmd_check(args: argparse.Namespace) -> int:
148
161
 
149
162
  def main(argv: list[str] | None = None) -> int:
150
163
  parser = argparse.ArgumentParser(description="Render mcp.json → per-tool config files.")
151
- parser.add_argument("--source", default=str(SOURCE_FILE), help="Source mcp.json (default: repo root)")
164
+ parser.add_argument("--source", default=None, help="Source mcp.json (default: <project-root>/mcp.json)")
165
+ parser.add_argument("--project-root", default=None, help="Project root for resolving source and targets (default: CWD)")
152
166
  parser.add_argument("--claude-desktop", action="store_true", help="Also write Claude Desktop user-scope config")
153
167
  parser.add_argument("--check", action="store_true", help="Dry-run; exit non-zero if targets are stale")
154
168
  args = parser.parse_args(argv)