@softspark/ai-toolkit 1.2.0 → 1.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.
Files changed (142) hide show
  1. package/AGENTS.md +5 -29
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +170 -27
  4. package/app/.claude-plugin/plugin.json +2 -2
  5. package/app/ARCHITECTURE.md +35 -19
  6. package/app/agents/ai-engineer.md +47 -5
  7. package/app/agents/fact-checker.md +1 -1
  8. package/app/agents/llm-ops-engineer.md +2 -2
  9. package/app/agents/{mcp-server-architect.md → mcp-specialist.md} +124 -18
  10. package/app/agents/mcp-testing-engineer.md +2 -2
  11. package/app/agents/nlp-engineer.md +1 -1
  12. package/app/agents/orchestrator.md +3 -4
  13. package/app/agents/project-planner.md +1 -1
  14. package/app/agents/technical-researcher.md +71 -21
  15. package/app/hooks/commit-quality.sh +53 -0
  16. package/app/hooks/governance-capture.sh +77 -0
  17. package/app/hooks/guard-config.sh +46 -0
  18. package/app/hooks/mcp-health.sh +53 -0
  19. package/app/hooks/pre-compact-save.sh +34 -0
  20. package/app/hooks/session-context.sh +60 -0
  21. package/app/hooks.json +60 -0
  22. package/app/mcp-templates/README.md +105 -0
  23. package/app/mcp-templates/brave-search.json +13 -0
  24. package/app/mcp-templates/cloudflare.json +14 -0
  25. package/app/mcp-templates/context7.json +10 -0
  26. package/app/mcp-templates/custom-template.json +13 -0
  27. package/app/mcp-templates/datadog.json +15 -0
  28. package/app/mcp-templates/docker.json +10 -0
  29. package/app/mcp-templates/fetch.json +10 -0
  30. package/app/mcp-templates/filesystem.json +10 -0
  31. package/app/mcp-templates/git.json +10 -0
  32. package/app/mcp-templates/github.json +13 -0
  33. package/app/mcp-templates/google-drive.json +15 -0
  34. package/app/mcp-templates/google-maps.json +13 -0
  35. package/app/mcp-templates/grafana.json +14 -0
  36. package/app/mcp-templates/linear.json +13 -0
  37. package/app/mcp-templates/memory.json +10 -0
  38. package/app/mcp-templates/notion.json +13 -0
  39. package/app/mcp-templates/postgres.json +10 -0
  40. package/app/mcp-templates/puppeteer.json +10 -0
  41. package/app/mcp-templates/redis.json +13 -0
  42. package/app/mcp-templates/sentry.json +14 -0
  43. package/app/mcp-templates/sequential-thinking.json +10 -0
  44. package/app/mcp-templates/slack.json +14 -0
  45. package/app/mcp-templates/sqlite.json +10 -0
  46. package/app/mcp-templates/supabase.json +14 -0
  47. package/app/mcp-templates/vercel.json +13 -0
  48. package/app/plugins/research-pack/README.md +1 -1
  49. package/app/plugins/research-pack/plugin.json +1 -1
  50. package/app/rules/claude-toolkit-rules.md +1 -1
  51. package/app/rules/common/coding-style.md +52 -0
  52. package/app/rules/common/git-workflow.md +45 -0
  53. package/app/rules/common/performance.md +49 -0
  54. package/app/rules/common/security.md +59 -0
  55. package/app/rules/common/testing.md +53 -0
  56. package/app/rules/cpp/coding-style.md +55 -0
  57. package/app/rules/cpp/frameworks.md +55 -0
  58. package/app/rules/cpp/patterns.md +57 -0
  59. package/app/rules/cpp/security.md +61 -0
  60. package/app/rules/cpp/testing.md +58 -0
  61. package/app/rules/csharp/coding-style.md +54 -0
  62. package/app/rules/csharp/frameworks.md +60 -0
  63. package/app/rules/csharp/patterns.md +61 -0
  64. package/app/rules/csharp/security.md +60 -0
  65. package/app/rules/csharp/testing.md +58 -0
  66. package/app/rules/dart/coding-style.md +61 -0
  67. package/app/rules/dart/frameworks.md +59 -0
  68. package/app/rules/dart/patterns.md +62 -0
  69. package/app/rules/dart/security.md +60 -0
  70. package/app/rules/dart/testing.md +60 -0
  71. package/app/rules/golang/coding-style.md +50 -0
  72. package/app/rules/golang/frameworks.md +56 -0
  73. package/app/rules/golang/patterns.md +52 -0
  74. package/app/rules/golang/security.md +61 -0
  75. package/app/rules/golang/testing.md +54 -0
  76. package/app/rules/java/coding-style.md +56 -0
  77. package/app/rules/java/frameworks.md +54 -0
  78. package/app/rules/java/patterns.md +59 -0
  79. package/app/rules/java/security.md +60 -0
  80. package/app/rules/java/testing.md +55 -0
  81. package/app/rules/kotlin/coding-style.md +56 -0
  82. package/app/rules/kotlin/frameworks.md +54 -0
  83. package/app/rules/kotlin/patterns.md +54 -0
  84. package/app/rules/kotlin/security.md +62 -0
  85. package/app/rules/kotlin/testing.md +56 -0
  86. package/app/rules/php/coding-style.md +53 -0
  87. package/app/rules/php/frameworks.md +57 -0
  88. package/app/rules/php/patterns.md +63 -0
  89. package/app/rules/php/security.md +61 -0
  90. package/app/rules/php/testing.md +60 -0
  91. package/app/rules/python/coding-style.md +52 -0
  92. package/app/rules/python/frameworks.md +53 -0
  93. package/app/rules/python/patterns.md +53 -0
  94. package/app/rules/python/security.md +58 -0
  95. package/app/rules/python/testing.md +52 -0
  96. package/app/rules/ruby/coding-style.md +56 -0
  97. package/app/rules/ruby/frameworks.md +56 -0
  98. package/app/rules/ruby/patterns.md +61 -0
  99. package/app/rules/ruby/security.md +61 -0
  100. package/app/rules/ruby/testing.md +63 -0
  101. package/app/rules/rust/coding-style.md +52 -0
  102. package/app/rules/rust/frameworks.md +59 -0
  103. package/app/rules/rust/patterns.md +58 -0
  104. package/app/rules/rust/security.md +64 -0
  105. package/app/rules/rust/testing.md +54 -0
  106. package/app/rules/swift/coding-style.md +55 -0
  107. package/app/rules/swift/frameworks.md +63 -0
  108. package/app/rules/swift/patterns.md +63 -0
  109. package/app/rules/swift/security.md +63 -0
  110. package/app/rules/swift/testing.md +60 -0
  111. package/app/rules/typescript/coding-style.md +51 -0
  112. package/app/rules/typescript/frameworks.md +51 -0
  113. package/app/rules/typescript/patterns.md +51 -0
  114. package/app/rules/typescript/security.md +55 -0
  115. package/app/rules/typescript/testing.md +52 -0
  116. package/app/skills/app-builder/SKILL.md +1 -1
  117. package/app/skills/brand-voice/SKILL.md +92 -0
  118. package/app/skills/council/SKILL.md +133 -0
  119. package/app/skills/introspect/SKILL.md +125 -0
  120. package/bin/ai-toolkit.js +103 -2
  121. package/kb/reference/agents-catalog.md +10 -13
  122. package/kb/reference/architecture-overview.md +29 -6
  123. package/kb/reference/commands-catalog.md +4 -4
  124. package/kb/reference/competitive-features-implementation.md +677 -0
  125. package/kb/reference/extension-api.md +175 -0
  126. package/kb/reference/hooks-catalog.md +90 -14
  127. package/kb/reference/language-rules.md +167 -0
  128. package/kb/reference/manifest-install.md +165 -0
  129. package/kb/reference/mcp-templates.md +110 -0
  130. package/kb/reference/skills-catalog.md +9 -6
  131. package/llms-full.txt +1511 -59
  132. package/llms.txt +8 -3
  133. package/package.json +2 -2
  134. package/scripts/inject_hook_cli.py +330 -0
  135. package/scripts/install.py +241 -7
  136. package/scripts/install_steps/ai_tools.py +63 -1
  137. package/scripts/install_steps/detect_language.py +50 -0
  138. package/scripts/install_steps/install_state.py +117 -0
  139. package/scripts/mcp_manager.py +206 -0
  140. package/app/agents/mcp-expert.md +0 -228
  141. package/app/agents/rag-engineer.md +0 -201
  142. package/app/agents/research-synthesizer.md +0 -138
@@ -30,12 +30,13 @@ Options:
30
30
  --reset Wipe and recreate local configs
31
31
  --profile <p> minimal|standard|strict
32
32
  --persona <p> backend-lead|frontend-lead|devops-eng|junior-dev
33
-
34
- Components: agents, skills, hooks, constitution, architecture, rules,
35
- cursor, windsurf, gemini, augment
33
+ --modules <list> Install specific modules (comma-separated)
34
+ --auto-detect Detect project languages and install matching rules
35
+ --status Show installed modules and exit
36
36
  """
37
37
  from __future__ import annotations
38
38
 
39
+ import json
39
40
  import sys
40
41
  from pathlib import Path
41
42
 
@@ -48,6 +49,134 @@ from install_steps.symlinks import install_agents, install_skills, clean_legacy_
48
49
  from install_steps.hooks import install_hooks
49
50
  from install_steps.markers import install_marker_files, inject_rules
50
51
  from install_steps.ai_tools import install_ai_tools, install_local_project, run_script
52
+ from install_steps.install_state import (
53
+ load_state,
54
+ record_install,
55
+ get_installed_modules,
56
+ get_installed_profile,
57
+ print_status,
58
+ )
59
+ from install_steps.detect_language import detect_languages
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Manifest helpers
64
+ # ---------------------------------------------------------------------------
65
+
66
+ def _load_manifest() -> dict:
67
+ """Load manifest.json from toolkit root."""
68
+ manifest_path = toolkit_dir / "manifest.json"
69
+ if not manifest_path.is_file():
70
+ return {}
71
+ with open(manifest_path, encoding="utf-8") as f:
72
+ return json.load(f)
73
+
74
+
75
+ def _get_manifest_profiles() -> dict[str, list[str]]:
76
+ """Return profiles from manifest.json."""
77
+ manifest = _load_manifest()
78
+ return manifest.get("profiles", {})
79
+
80
+
81
+ def _get_manifest_modules() -> dict[str, dict]:
82
+ """Return modules from manifest.json."""
83
+ manifest = _load_manifest()
84
+ return manifest.get("modules", {})
85
+
86
+
87
+ def resolve_modules_from_profile(profile: str) -> list[str]:
88
+ """Resolve a profile name to a list of module names.
89
+
90
+ Returns an empty list if the profile is not found in manifest.
91
+ """
92
+ profiles = _get_manifest_profiles()
93
+ return profiles.get(profile, [])
94
+
95
+
96
+ def resolve_requested_modules(
97
+ modules_arg: str,
98
+ profile: str,
99
+ auto_detect: bool,
100
+ project_dir: Path,
101
+ ) -> list[str] | None:
102
+ """Determine which modules to install based on CLI flags.
103
+
104
+ Only activates when ``--modules`` or ``--auto-detect`` is passed.
105
+ A bare ``--profile`` without those flags returns None so the
106
+ legacy code path handles it unchanged.
107
+
108
+ Returns:
109
+ A sorted list of module names to install, or None if no
110
+ module-level flags were provided (legacy mode).
111
+ """
112
+ # If neither --modules nor --auto-detect was given, stay in legacy mode.
113
+ # This ensures --profile alone behaves exactly as before.
114
+ if not modules_arg and not auto_detect:
115
+ return None
116
+
117
+ manifest_modules = _get_manifest_modules()
118
+ result: set[str] = set()
119
+
120
+ # Always include required modules
121
+ for name, cfg in manifest_modules.items():
122
+ if cfg.get("required"):
123
+ result.add(name)
124
+
125
+ # --modules flag takes precedence
126
+ if modules_arg:
127
+ for name in modules_arg.split(","):
128
+ name = name.strip()
129
+ if name and name in manifest_modules:
130
+ result.add(name)
131
+ elif name:
132
+ print(f"Warning: unknown module '{name}' (skipped)")
133
+ if auto_detect:
134
+ detected = detect_languages(project_dir, toolkit_dir)
135
+ result.update(detected)
136
+ return sorted(result)
137
+
138
+ # --auto-detect (without --modules): add profile or defaults + detected
139
+ if auto_detect:
140
+ if profile:
141
+ profile_modules = resolve_modules_from_profile(profile)
142
+ if profile_modules:
143
+ result.update(profile_modules)
144
+ else:
145
+ for name, cfg in manifest_modules.items():
146
+ if cfg.get("default"):
147
+ result.add(name)
148
+ detected = detect_languages(project_dir, toolkit_dir)
149
+ result.update(detected)
150
+ return sorted(result)
151
+
152
+ # Should not reach here, but return None for safety
153
+ return None
154
+
155
+
156
+ def modules_to_component_filter(modules: list[str]) -> str:
157
+ """Convert module names to a comma-separated component filter string.
158
+
159
+ Maps module names back to the component names used by the existing
160
+ ``--only`` system (e.g. "agents", "skills", "hooks", "rules").
161
+ This bridges the new module system with the old component system.
162
+ """
163
+ components: set[str] = set()
164
+
165
+ for module in modules:
166
+ if module == "core":
167
+ # Core maps to hooks, constitution, architecture, output-styles
168
+ components.update(["hooks", "constitution", "architecture", "rules"])
169
+ elif module == "agents":
170
+ components.add("agents")
171
+ elif module == "skills":
172
+ components.add("skills")
173
+ elif module.startswith("rules-"):
174
+ components.add("rules")
175
+ elif module == "mcp-templates":
176
+ # MCP templates don't map to a legacy component
177
+ pass
178
+
179
+ return ",".join(sorted(components))
51
180
 
52
181
 
53
182
  # ---------------------------------------------------------------------------
@@ -65,6 +194,9 @@ def parse_args(argv: list[str]) -> dict:
65
194
  "reset": False,
66
195
  "profile": "",
67
196
  "persona": "",
197
+ "modules": "",
198
+ "auto_detect": False,
199
+ "status": False,
68
200
  }
69
201
  i = 0
70
202
  while i < len(argv):
@@ -75,6 +207,10 @@ def parse_args(argv: list[str]) -> dict:
75
207
  cfg["local"] = True
76
208
  elif arg == "--reset":
77
209
  cfg["reset"] = True
210
+ elif arg == "--auto-detect":
211
+ cfg["auto_detect"] = True
212
+ elif arg == "--status":
213
+ cfg["status"] = True
78
214
  elif arg.startswith("--only="):
79
215
  cfg["only"] = arg.split("=", 1)[1]
80
216
  elif arg == "--only":
@@ -95,6 +231,11 @@ def parse_args(argv: list[str]) -> dict:
95
231
  elif arg == "--persona":
96
232
  i += 1
97
233
  cfg["persona"] = argv[i] if i < len(argv) else ""
234
+ elif arg.startswith("--modules="):
235
+ cfg["modules"] = arg.split("=", 1)[1]
236
+ elif arg == "--modules":
237
+ i += 1
238
+ cfg["modules"] = argv[i] if i < len(argv) else ""
98
239
  elif arg.startswith("-"):
99
240
  print(f"Unknown option: {arg}")
100
241
  sys.exit(1)
@@ -139,7 +280,8 @@ def resolve_profile(profile: str, only: str) -> str:
139
280
 
140
281
 
141
282
  def print_banner(target_dir: Path, rules_dir: Path, profile: str,
142
- only: str, skip: str, dry_run: bool) -> None:
283
+ only: str, skip: str, dry_run: bool,
284
+ modules: list[str] | None = None) -> None:
143
285
  print("AI Toolkit Installer")
144
286
  print("========================")
145
287
  print(f"Toolkit: {toolkit_dir}")
@@ -147,6 +289,8 @@ def print_banner(target_dir: Path, rules_dir: Path, profile: str,
147
289
  print(f"Rules: {rules_dir}")
148
290
  if profile:
149
291
  print(f"Profile: {profile}")
292
+ if modules is not None:
293
+ print(f"Modules: {', '.join(modules)}")
150
294
  if only:
151
295
  print(f"Only: {only}")
152
296
  if skip:
@@ -165,6 +309,7 @@ def print_summary() -> None:
165
309
  print(" 2. Per project: ai-toolkit install --local (or: ai-toolkit update --local)")
166
310
  print(" 3. To update: npm install -g @softspark/ai-toolkit@latest && ai-toolkit update")
167
311
  print(" 4. To register rules from other tools: ai-toolkit add-rule <rule.md>")
312
+ print(" 5. Check install state: ai-toolkit status")
168
313
 
169
314
 
170
315
  # ---------------------------------------------------------------------------
@@ -224,6 +369,20 @@ def install_strict_git_hooks(profile: str, local: bool, dry_run: bool) -> None:
224
369
  print(f" Strict profile: git hooks installed in {cwd}")
225
370
 
226
371
 
372
+ # ---------------------------------------------------------------------------
373
+ # Version helper
374
+ # ---------------------------------------------------------------------------
375
+
376
+ def _get_toolkit_version() -> str:
377
+ """Read version from package.json."""
378
+ pkg = toolkit_dir / "package.json"
379
+ if pkg.is_file():
380
+ with open(pkg, encoding="utf-8") as f:
381
+ data = json.load(f)
382
+ return data.get("version", "unknown")
383
+ return "unknown"
384
+
385
+
227
386
  # ---------------------------------------------------------------------------
228
387
  # Main
229
388
  # ---------------------------------------------------------------------------
@@ -231,6 +390,11 @@ def install_strict_git_hooks(profile: str, local: bool, dry_run: bool) -> None:
231
390
  def main() -> None:
232
391
  cfg = parse_args(sys.argv[1:])
233
392
 
393
+ # Handle --status early exit
394
+ if cfg["status"]:
395
+ print_status()
396
+ sys.exit(0)
397
+
234
398
  target_dir: Path = cfg["target_dir"]
235
399
  only: str = cfg["only"]
236
400
  skip: str = cfg["skip"]
@@ -239,13 +403,35 @@ def main() -> None:
239
403
  reset: bool = cfg["reset"]
240
404
  profile: str = cfg["profile"]
241
405
  persona: str = cfg["persona"]
406
+ modules_arg: str = cfg["modules"]
407
+ auto_detect: bool = cfg["auto_detect"]
242
408
 
243
409
  rules_dir = Path.home() / ".ai-toolkit" / "rules"
244
410
  hooks_scripts_dir = Path.home() / ".ai-toolkit" / "hooks"
245
411
 
246
- only = resolve_profile(profile, only)
412
+ # Resolve modules if module-level flags are present
413
+ # --auto-detect only makes sense with --local (language rules are project-specific)
414
+ if auto_detect and not local:
415
+ print("Warning: --auto-detect requires --local (language rules are project-specific). Adding --local.")
416
+ local = True
417
+ project_dir = Path.cwd() if local else target_dir
418
+ resolved_modules = resolve_requested_modules(
419
+ modules_arg, profile, auto_detect, project_dir,
420
+ )
421
+
422
+ # If module-level flags produced a module list, bridge to legacy --only
423
+ if resolved_modules is not None:
424
+ component_filter = modules_to_component_filter(resolved_modules)
425
+ if component_filter and not only:
426
+ only = component_filter
427
+
428
+ # Legacy profile resolution (only when no module-level override)
429
+ if resolved_modules is None:
430
+ only = resolve_profile(profile, only)
431
+
247
432
  check_dependencies()
248
- print_banner(target_dir, rules_dir, profile, only, skip, dry_run)
433
+ print_banner(target_dir, rules_dir, profile, only, skip, dry_run,
434
+ modules=resolved_modules)
249
435
 
250
436
  if not dry_run:
251
437
  rules_dir.mkdir(parents=True, exist_ok=True)
@@ -255,12 +441,60 @@ def main() -> None:
255
441
  install_ai_tools(target_dir, rules_dir, only, skip, dry_run)
256
442
 
257
443
  if local:
258
- install_local_project(rules_dir, dry_run, reset)
444
+ # Pass language modules for --auto-detect / --modules rules-*
445
+ lang_modules = [m for m in (resolved_modules or []) if m.startswith("rules-")]
446
+ install_local_project(rules_dir, dry_run, reset, lang_modules or None)
259
447
 
260
448
  install_persona(target_dir, persona, dry_run)
261
449
  install_strict_git_hooks(profile, local, dry_run)
450
+
451
+ # Record install state (skip for dry-run)
452
+ if not dry_run:
453
+ auto_detected = None
454
+ if auto_detect:
455
+ auto_detected = detect_languages(project_dir, toolkit_dir)
456
+
457
+ # Determine what modules to record
458
+ if resolved_modules is not None:
459
+ record_modules = resolved_modules
460
+ else:
461
+ # Legacy mode: infer modules from profile/only
462
+ record_modules = _infer_modules_from_legacy(profile, only)
463
+
464
+ record_install(
465
+ version=_get_toolkit_version(),
466
+ modules=record_modules,
467
+ profile=profile or "standard",
468
+ auto_detected=auto_detected,
469
+ )
470
+
262
471
  print_summary()
263
472
 
264
473
 
474
+ def _infer_modules_from_legacy(profile: str, only: str) -> list[str]:
475
+ """Best-effort inference of module names from legacy flags.
476
+
477
+ Used when the user did not pass --modules or a manifest-aware
478
+ --profile, so we approximate from what was actually installed.
479
+ """
480
+ modules: set[str] = {"core"} # core is always installed
481
+
482
+ if only:
483
+ components = {c.strip() for c in only.split(",")}
484
+ else:
485
+ # Standard install: all main components
486
+ components = {"agents", "skills", "hooks", "constitution",
487
+ "architecture", "rules"}
488
+
489
+ if "agents" in components:
490
+ modules.add("agents")
491
+ if "skills" in components:
492
+ modules.add("skills")
493
+ if "rules" in components:
494
+ modules.add("rules-common")
495
+
496
+ return sorted(modules)
497
+
498
+
265
499
  if __name__ == "__main__":
266
500
  main()
@@ -120,7 +120,8 @@ def run_script(script_name: str, *args: str, capture: bool = False) -> str:
120
120
  return result.stdout if capture else ""
121
121
 
122
122
 
123
- def install_local_project(rules_dir: Path, dry_run: bool, reset: bool) -> None:
123
+ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
124
+ language_modules: list[str] | None = None) -> None:
124
125
  """Install project-local configs."""
125
126
  cwd = Path.cwd()
126
127
  print()
@@ -131,6 +132,8 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool) -> None:
131
132
 
132
133
  if dry_run:
133
134
  _install_local_dry_run(reset)
135
+ if language_modules:
136
+ print(f" Would inject language rules: {', '.join(language_modules)}")
134
137
  return
135
138
 
136
139
  (cwd / ".claude").mkdir(parents=True, exist_ok=True)
@@ -156,9 +159,68 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool) -> None:
156
159
  )
157
160
  print(" Injected: .claude/constitution.md")
158
161
 
162
+ # Inject language-specific rules into project CLAUDE.md
163
+ _inject_language_rules(cwd, language_modules)
164
+
159
165
  _create_local_ai_tool_configs(cwd, rules_dir)
160
166
 
161
167
 
168
+ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
169
+ """Inject language-specific rules into project's CLAUDE.md.
170
+
171
+ Concatenates all .md files from matching app/rules/<lang>/ directories
172
+ into a single marker-injected section in the project's .claude/CLAUDE.md.
173
+ Also always includes common rules.
174
+ """
175
+ if not language_modules:
176
+ return
177
+
178
+ rules_src = app_dir / "rules"
179
+ if not rules_src.is_dir():
180
+ return
181
+
182
+ # Always include common + detected languages
183
+ lang_dirs: list[str] = ["common"]
184
+ for mod in language_modules:
185
+ # module name format: "rules-typescript" → dir "typescript"
186
+ if mod.startswith("rules-"):
187
+ lang_dirs.append(mod[6:])
188
+
189
+ # Collect all rule content
190
+ parts: list[str] = []
191
+ for lang_dir in lang_dirs:
192
+ lang_path = rules_src / lang_dir
193
+ if not lang_path.is_dir():
194
+ continue
195
+ for rule_file in sorted(lang_path.glob("*.md")):
196
+ content = rule_file.read_text(encoding="utf-8").strip()
197
+ # Strip YAML frontmatter
198
+ if content.startswith("---"):
199
+ end = content.find("---", 3)
200
+ if end != -1:
201
+ content = content[end + 3:].strip()
202
+ if content:
203
+ parts.append(content)
204
+
205
+ if not parts:
206
+ return
207
+
208
+ # Write concatenated rules to a temp file, then inject as a section
209
+ import tempfile
210
+ combined = "\n\n".join(parts)
211
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False,
212
+ encoding="utf-8") as tmp:
213
+ tmp.write(combined)
214
+ tmp_path = Path(tmp.name)
215
+
216
+ try:
217
+ inject_section(tmp_path, cwd / ".claude" / "CLAUDE.md", "language-rules")
218
+ detected = ", ".join(d for d in lang_dirs if d != "common")
219
+ print(f" Injected: language rules (common + {detected})")
220
+ finally:
221
+ tmp_path.unlink(missing_ok=True)
222
+
223
+
162
224
  def _install_local_dry_run(reset: bool) -> None:
163
225
  if reset:
164
226
  print(" Would remove: CLAUDE.md, .claude/settings.local.json")
@@ -0,0 +1,50 @@
1
+ """Detect project language from file markers in the manifest."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ def _load_manifest_modules(toolkit_dir: Path) -> dict:
9
+ """Load the modules section from manifest.json."""
10
+ manifest_path = toolkit_dir / "manifest.json"
11
+ if not manifest_path.is_file():
12
+ return {}
13
+ with open(manifest_path, encoding="utf-8") as f:
14
+ data = json.load(f)
15
+ return data.get("modules", {})
16
+
17
+
18
+ def _glob_matches(project_dir: Path, pattern: str) -> bool:
19
+ """Check if a glob pattern matches any file in the project directory.
20
+
21
+ Only checks the top-level directory (no recursive descent)
22
+ to keep detection fast and predictable.
23
+ """
24
+ return any(project_dir.glob(pattern))
25
+
26
+
27
+ def detect_languages(project_dir: Path, toolkit_dir: Path) -> list[str]:
28
+ """Return list of detected language module names.
29
+
30
+ Scans ``project_dir`` for marker files defined in each module's
31
+ ``auto_detect`` list inside ``manifest.json``.
32
+
33
+ Returns:
34
+ Sorted list of matching module names, e.g.
35
+ ``["rules-python", "rules-typescript"]``.
36
+ """
37
+ modules = _load_manifest_modules(toolkit_dir)
38
+ detected: list[str] = []
39
+
40
+ for module_name, module_cfg in modules.items():
41
+ markers = module_cfg.get("auto_detect")
42
+ if not markers:
43
+ continue
44
+
45
+ for pattern in markers:
46
+ if _glob_matches(project_dir, pattern):
47
+ detected.append(module_name)
48
+ break # one match is enough for this module
49
+
50
+ return sorted(detected)
@@ -0,0 +1,117 @@
1
+ """Track installed modules and versions in ~/.ai-toolkit/state.json."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ STATE_FILENAME = "state.json"
9
+
10
+
11
+ def _state_path() -> Path:
12
+ """Return the canonical path to state.json."""
13
+ return Path.home() / ".ai-toolkit" / STATE_FILENAME
14
+
15
+
16
+ def load_state() -> dict:
17
+ """Load state from ~/.ai-toolkit/state.json.
18
+
19
+ Returns an empty dict if the file does not exist or is malformed.
20
+ """
21
+ path = _state_path()
22
+ if not path.is_file():
23
+ return {}
24
+ try:
25
+ with open(path, encoding="utf-8") as f:
26
+ data = json.load(f)
27
+ if not isinstance(data, dict):
28
+ return {}
29
+ return data
30
+ except (json.JSONDecodeError, OSError):
31
+ return {}
32
+
33
+
34
+ def save_state(state: dict) -> None:
35
+ """Save state to ~/.ai-toolkit/state.json.
36
+
37
+ Creates the parent directory if it does not exist.
38
+ """
39
+ path = _state_path()
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ with open(path, "w", encoding="utf-8") as f:
42
+ json.dump(state, f, indent=2)
43
+ f.write("\n")
44
+
45
+
46
+ def get_installed_modules() -> list[str]:
47
+ """Return list of installed module names from state, or empty list."""
48
+ state = load_state()
49
+ modules = state.get("installed_modules", [])
50
+ if isinstance(modules, list):
51
+ return modules
52
+ return []
53
+
54
+
55
+ def get_installed_profile() -> str:
56
+ """Return the profile name from state, or empty string."""
57
+ state = load_state()
58
+ return state.get("profile", "")
59
+
60
+
61
+ def _now_iso() -> str:
62
+ """Return current UTC time in ISO 8601 format."""
63
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
64
+
65
+
66
+ def record_install(
67
+ version: str,
68
+ modules: list[str],
69
+ profile: str,
70
+ auto_detected: list[str] | None = None,
71
+ ) -> None:
72
+ """Record a successful install in state.json.
73
+
74
+ If state already exists, preserves ``installed_at`` and updates
75
+ ``last_updated``. Otherwise sets both timestamps.
76
+ """
77
+ state = load_state()
78
+ now = _now_iso()
79
+
80
+ state["installed_version"] = version
81
+ if "installed_at" not in state:
82
+ state["installed_at"] = now
83
+ state["last_updated"] = now
84
+ state["installed_modules"] = sorted(set(modules))
85
+ state["profile"] = profile
86
+ if auto_detected is not None:
87
+ state["auto_detected_languages"] = sorted(auto_detected)
88
+
89
+ save_state(state)
90
+
91
+
92
+ def print_status() -> None:
93
+ """Print a human-readable summary of the install state."""
94
+ state = load_state()
95
+ if not state:
96
+ print("No install state found (~/.ai-toolkit/state.json missing).")
97
+ print("Run 'ai-toolkit install' to set up the toolkit.")
98
+ return
99
+
100
+ print("AI Toolkit Install Status")
101
+ print("=========================")
102
+ print(f" Version: {state.get('installed_version', 'unknown')}")
103
+ print(f" Installed: {state.get('installed_at', 'unknown')}")
104
+ print(f" Updated: {state.get('last_updated', 'unknown')}")
105
+ print(f" Profile: {state.get('profile', 'unknown')}")
106
+
107
+ modules = state.get("installed_modules", [])
108
+ if modules:
109
+ print(f" Modules: {', '.join(modules)}")
110
+ else:
111
+ print(" Modules: (none recorded)")
112
+
113
+ detected = state.get("auto_detected_languages", [])
114
+ if detected:
115
+ # Strip "rules-" prefix for readability
116
+ langs = [m.replace("rules-", "") for m in detected]
117
+ print(f" Detected: {', '.join(langs)}")