agent-notes 2.0.4__py3-none-any.whl

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 (162) hide show
  1. agent_notes/VERSION +1 -0
  2. agent_notes/__init__.py +1 -0
  3. agent_notes/__main__.py +4 -0
  4. agent_notes/cli.py +348 -0
  5. agent_notes/commands/__init__.py +27 -0
  6. agent_notes/commands/_install_helpers.py +262 -0
  7. agent_notes/commands/build.py +170 -0
  8. agent_notes/commands/doctor.py +112 -0
  9. agent_notes/commands/info.py +95 -0
  10. agent_notes/commands/install.py +99 -0
  11. agent_notes/commands/list.py +169 -0
  12. agent_notes/commands/memory.py +430 -0
  13. agent_notes/commands/regenerate.py +152 -0
  14. agent_notes/commands/set_role.py +143 -0
  15. agent_notes/commands/uninstall.py +26 -0
  16. agent_notes/commands/update.py +169 -0
  17. agent_notes/commands/validate.py +199 -0
  18. agent_notes/commands/wizard.py +720 -0
  19. agent_notes/config.py +154 -0
  20. agent_notes/data/agents/agents.yaml +352 -0
  21. agent_notes/data/agents/analyst.md +45 -0
  22. agent_notes/data/agents/api-reviewer.md +47 -0
  23. agent_notes/data/agents/architect.md +46 -0
  24. agent_notes/data/agents/coder.md +28 -0
  25. agent_notes/data/agents/database-specialist.md +45 -0
  26. agent_notes/data/agents/debugger.md +47 -0
  27. agent_notes/data/agents/devil.md +47 -0
  28. agent_notes/data/agents/devops.md +38 -0
  29. agent_notes/data/agents/explorer.md +23 -0
  30. agent_notes/data/agents/integrations.md +44 -0
  31. agent_notes/data/agents/lead.md +216 -0
  32. agent_notes/data/agents/performance-profiler.md +44 -0
  33. agent_notes/data/agents/refactorer.md +48 -0
  34. agent_notes/data/agents/reviewer.md +44 -0
  35. agent_notes/data/agents/security-auditor.md +44 -0
  36. agent_notes/data/agents/system-auditor.md +38 -0
  37. agent_notes/data/agents/tech-writer.md +32 -0
  38. agent_notes/data/agents/test-runner.md +36 -0
  39. agent_notes/data/agents/test-writer.md +39 -0
  40. agent_notes/data/cli/claude.yaml +25 -0
  41. agent_notes/data/cli/copilot.yaml +18 -0
  42. agent_notes/data/cli/opencode.yaml +22 -0
  43. agent_notes/data/commands/brainstorm.md +8 -0
  44. agent_notes/data/commands/debug.md +9 -0
  45. agent_notes/data/commands/review.md +10 -0
  46. agent_notes/data/global-claude.md +290 -0
  47. agent_notes/data/global-copilot.md +27 -0
  48. agent_notes/data/global-opencode.md +40 -0
  49. agent_notes/data/hooks/session-context.md.tpl +19 -0
  50. agent_notes/data/models/claude-haiku-4-5.yaml +15 -0
  51. agent_notes/data/models/claude-opus-4-1.yaml +16 -0
  52. agent_notes/data/models/claude-opus-4-5.yaml +16 -0
  53. agent_notes/data/models/claude-opus-4-6.yaml +16 -0
  54. agent_notes/data/models/claude-opus-4-7.yaml +15 -0
  55. agent_notes/data/models/claude-sonnet-4-5.yaml +16 -0
  56. agent_notes/data/models/claude-sonnet-4-6.yaml +15 -0
  57. agent_notes/data/models/claude-sonnet-4.yaml +16 -0
  58. agent_notes/data/pricing.yaml +33 -0
  59. agent_notes/data/roles/orchestrator.yaml +5 -0
  60. agent_notes/data/roles/reasoner.yaml +5 -0
  61. agent_notes/data/roles/scout.yaml +5 -0
  62. agent_notes/data/roles/worker.yaml +5 -0
  63. agent_notes/data/rules/code-quality.md +9 -0
  64. agent_notes/data/rules/safety.md +10 -0
  65. agent_notes/data/scripts/cost-report +211 -0
  66. agent_notes/data/skills/brainstorming/SKILL.md +57 -0
  67. agent_notes/data/skills/code-review/SKILL.md +64 -0
  68. agent_notes/data/skills/debugging-protocol/SKILL.md +51 -0
  69. agent_notes/data/skills/docker-compose/SKILL.md +318 -0
  70. agent_notes/data/skills/docker-compose-advanced/SKILL.md +575 -0
  71. agent_notes/data/skills/docker-dockerfile/SKILL.md +385 -0
  72. agent_notes/data/skills/docker-dockerfile-languages/SKILL.md +293 -0
  73. agent_notes/data/skills/git/SKILL.md +87 -0
  74. agent_notes/data/skills/rails-active-storage/SKILL.md +321 -0
  75. agent_notes/data/skills/rails-broadcasting/SKILL.md +374 -0
  76. agent_notes/data/skills/rails-concerns/SKILL.md +806 -0
  77. agent_notes/data/skills/rails-controllers/SKILL.md +510 -0
  78. agent_notes/data/skills/rails-controllers-advanced/SKILL.md +441 -0
  79. agent_notes/data/skills/rails-helpers/SKILL.md +677 -0
  80. agent_notes/data/skills/rails-initializers/SKILL.md +79 -0
  81. agent_notes/data/skills/rails-javascript/SKILL.md +567 -0
  82. agent_notes/data/skills/rails-jobs/SKILL.md +700 -0
  83. agent_notes/data/skills/rails-kamal/SKILL.md +483 -0
  84. agent_notes/data/skills/rails-lib/SKILL.md +101 -0
  85. agent_notes/data/skills/rails-mailers/SKILL.md +321 -0
  86. agent_notes/data/skills/rails-migrations/SKILL.md +268 -0
  87. agent_notes/data/skills/rails-models/SKILL.md +459 -0
  88. agent_notes/data/skills/rails-models-advanced/SKILL.md +398 -0
  89. agent_notes/data/skills/rails-routes/SKILL.md +804 -0
  90. agent_notes/data/skills/rails-style/SKILL.md +538 -0
  91. agent_notes/data/skills/rails-testing-controllers/SKILL.md +343 -0
  92. agent_notes/data/skills/rails-testing-models/SKILL.md +296 -0
  93. agent_notes/data/skills/rails-testing-system/SKILL.md +375 -0
  94. agent_notes/data/skills/rails-validations/SKILL.md +108 -0
  95. agent_notes/data/skills/rails-view-components/SKILL.md +511 -0
  96. agent_notes/data/skills/rails-view-components-advanced/SKILL.md +376 -0
  97. agent_notes/data/skills/rails-views/SKILL.md +413 -0
  98. agent_notes/data/skills/rails-views-advanced/SKILL.md +450 -0
  99. agent_notes/data/skills/refactoring-protocol/SKILL.md +64 -0
  100. agent_notes/data/skills/tdd/SKILL.md +57 -0
  101. agent_notes/data/templates/__init__.py +1 -0
  102. agent_notes/data/templates/__pycache__/__init__.cpython-314.pyc +0 -0
  103. agent_notes/data/templates/frontmatter/__init__.py +1 -0
  104. agent_notes/data/templates/frontmatter/__pycache__/__init__.cpython-314.pyc +0 -0
  105. agent_notes/data/templates/frontmatter/__pycache__/claude.cpython-314.pyc +0 -0
  106. agent_notes/data/templates/frontmatter/__pycache__/cursor.cpython-314.pyc +0 -0
  107. agent_notes/data/templates/frontmatter/__pycache__/opencode.cpython-314.pyc +0 -0
  108. agent_notes/data/templates/frontmatter/claude.py +44 -0
  109. agent_notes/data/templates/frontmatter/opencode.py +104 -0
  110. agent_notes/doctor_checks.py +189 -0
  111. agent_notes/domain/__init__.py +17 -0
  112. agent_notes/domain/agent.py +34 -0
  113. agent_notes/domain/cli_backend.py +40 -0
  114. agent_notes/domain/diagnostics.py +29 -0
  115. agent_notes/domain/diff.py +44 -0
  116. agent_notes/domain/model.py +27 -0
  117. agent_notes/domain/role.py +13 -0
  118. agent_notes/domain/rule.py +13 -0
  119. agent_notes/domain/skill.py +15 -0
  120. agent_notes/domain/state.py +46 -0
  121. agent_notes/install_state.py +11 -0
  122. agent_notes/registries/__init__.py +16 -0
  123. agent_notes/registries/_base.py +46 -0
  124. agent_notes/registries/agent_registry.py +107 -0
  125. agent_notes/registries/cli_registry.py +89 -0
  126. agent_notes/registries/model_registry.py +85 -0
  127. agent_notes/registries/role_registry.py +64 -0
  128. agent_notes/registries/rule_registry.py +80 -0
  129. agent_notes/registries/skill_registry.py +141 -0
  130. agent_notes/services/__init__.py +8 -0
  131. agent_notes/services/diagnostics/__init__.py +47 -0
  132. agent_notes/services/diagnostics/_checks.py +272 -0
  133. agent_notes/services/diagnostics/_display.py +346 -0
  134. agent_notes/services/diagnostics/_fix.py +169 -0
  135. agent_notes/services/diff.py +349 -0
  136. agent_notes/services/fs.py +195 -0
  137. agent_notes/services/install_state_builder.py +210 -0
  138. agent_notes/services/installer.py +293 -0
  139. agent_notes/services/memory_backend.py +155 -0
  140. agent_notes/services/rendering.py +329 -0
  141. agent_notes/services/session_context.py +23 -0
  142. agent_notes/services/settings_writer.py +79 -0
  143. agent_notes/services/state_store.py +249 -0
  144. agent_notes/services/ui.py +419 -0
  145. agent_notes/services/user_config.py +62 -0
  146. agent_notes/services/validation.py +67 -0
  147. agent_notes/state.py +21 -0
  148. agent_notes-2.0.4.dist-info/METADATA +14 -0
  149. agent_notes-2.0.4.dist-info/RECORD +162 -0
  150. agent_notes-2.0.4.dist-info/WHEEL +5 -0
  151. agent_notes-2.0.4.dist-info/entry_points.txt +2 -0
  152. agent_notes-2.0.4.dist-info/licenses/LICENSE +21 -0
  153. agent_notes-2.0.4.dist-info/top_level.txt +2 -0
  154. tests/conftest.py +20 -0
  155. tests/functional/__init__.py +0 -0
  156. tests/functional/test_build_commands.py +88 -0
  157. tests/functional/test_registries.py +128 -0
  158. tests/integration/__init__.py +0 -0
  159. tests/integration/test_build_output.py +129 -0
  160. tests/plugins/__init__.py +0 -0
  161. tests/plugins/test_agents.py +93 -0
  162. tests/plugins/test_skills.py +77 -0
@@ -0,0 +1,720 @@
1
+ """Interactive install wizard for agent-notes."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import List, Dict, Set, Optional
6
+
7
+ from .build import build
8
+ from ._install_helpers import (
9
+ count_agents, count_global, count_skills
10
+ )
11
+ from ..services.fs import place_file, place_dir_contents
12
+ from ..services.ui import (
13
+ _can_interactive, _safe_input, _checkbox_select, _radio_select,
14
+ _checkbox_select_fallback, _radio_select_fallback
15
+ )
16
+ from ..config import (
17
+ Color, AGENTS_HOME, PKG_DIR, get_version,
18
+ DIST_SKILLS_DIR, DIST_RULES_DIR, DIST_CLAUDE_DIR, DIST_OPENCODE_DIR,
19
+ )
20
+
21
+ _ROLE_ANSI = {
22
+ 'purple': "\033[0;35m",
23
+ 'red': "\033[0;31m",
24
+ 'cyan': "\033[0;36m",
25
+ 'blue': "\033[0;34m",
26
+ 'green': "\033[0;32m",
27
+ 'yellow': "\033[0;33m",
28
+ 'orange': "\033[0;33m",
29
+ }
30
+
31
+
32
+ def _get_skill_groups() -> Dict[str, List[str]]:
33
+ """Get skill names grouped by technology."""
34
+ # For testing, allow bypassing the registry
35
+ import os
36
+ if os.environ.get('_WIZARD_TEST_MODE'):
37
+ if not DIST_SKILLS_DIR.exists():
38
+ return {}
39
+ all_skills = [d.name for d in DIST_SKILLS_DIR.iterdir() if d.is_dir()]
40
+ else:
41
+ try:
42
+ from ..registries import default_skill_registry
43
+ registry = default_skill_registry()
44
+
45
+ # If the registry has per-skill grouping (skill.group field), use it.
46
+ # If every skill falls into "uncategorized" (the default), fall through
47
+ # to the hardcoded prefix-based grouping below so the wizard still
48
+ # presents meaningful groups.
49
+ if hasattr(registry, 'by_group'):
50
+ groups = registry.by_group()
51
+ real_groups = {
52
+ gn: [s.name for s in skills]
53
+ for gn, skills in groups.items()
54
+ if gn != "uncategorized" and skills
55
+ }
56
+ if real_groups:
57
+ return real_groups
58
+ # else fall through to hardcoded grouping with registry's skill list
59
+ all_skills = [s.name for s in registry.all()]
60
+ else:
61
+ # Fallback to old hardcoded grouping
62
+ all_skills = [skill.name for skill in registry.all()]
63
+ except Exception:
64
+ # Fallback to old behavior if registry fails
65
+ if not DIST_SKILLS_DIR.exists():
66
+ return {}
67
+ all_skills = [d.name for d in DIST_SKILLS_DIR.iterdir() if d.is_dir()]
68
+
69
+ # Hardcoded grouping for backward compatibility
70
+ groups = {
71
+ "Rails": [s for s in all_skills if s.startswith("rails-") and s != "rails-kamal"],
72
+ "Docker": [s for s in all_skills if s.startswith("docker-")],
73
+ "Kamal": [s for s in all_skills if s == "rails-kamal"],
74
+ "Git": [s for s in all_skills if s == "git"]
75
+ }
76
+
77
+ return {k: v for k, v in groups.items() if v}
78
+
79
+
80
+ def _count_rules() -> int:
81
+ """Count rule files."""
82
+ # For testing, allow bypassing the registry
83
+ import os
84
+ if os.environ.get('_WIZARD_TEST_MODE'):
85
+ if not DIST_RULES_DIR.exists():
86
+ return 0
87
+ return len(list(DIST_RULES_DIR.glob("*.md")))
88
+ else:
89
+ try:
90
+ from ..registries import default_rule_registry
91
+ registry = default_rule_registry()
92
+ return len(registry.all())
93
+ except Exception:
94
+ # Fallback to old behavior if registry fails
95
+ if not DIST_RULES_DIR.exists():
96
+ return 0
97
+ return len(list(DIST_RULES_DIR.glob("*.md")))
98
+
99
+
100
+ def _select_cli(step: int = 0, total: int = 0, version: str = '') -> Set[str]:
101
+ """Step 1: CLI selection."""
102
+ from ..registries.cli_registry import load_registry
103
+ registry = load_registry()
104
+ # Show only CLIs that have a global_template (i.e. are meant to be user-selectable)
105
+ # AND support at least one meaningful component (not just copilot which is config-only).
106
+ options = []
107
+ for backend in sorted(registry.all(), key=lambda b: b.name):
108
+ options.append((backend.label, backend.name))
109
+
110
+ # Default to Claude Code only
111
+ safe_defaults = {"claude"}
112
+
113
+ if _can_interactive():
114
+ result = _checkbox_select("Which CLI do you use?", options, defaults=safe_defaults,
115
+ step=step, total=total, version=version)
116
+ else:
117
+ result = _checkbox_select_fallback("Which CLI do you use?", options, defaults=safe_defaults,
118
+ step=step, total=total, version=version)
119
+
120
+ labels = [label for label, val in options if val in result]
121
+ print(f" {Color.GREEN}✓{Color.NC} CLI: {', '.join(labels) if labels else 'None'}")
122
+ return result
123
+
124
+
125
+ def _select_models_per_role(clis: Set[str], step: int = 0, total: int = 0, version: str = '') -> Dict[str, Dict[str, str]]:
126
+ """For each CLI that supports agents, ask user to pick a model per role.
127
+
128
+ Returns: {cli_name: {role_name: model_id}}. Config-only CLIs are skipped (no entry).
129
+ """
130
+ from ..registries.cli_registry import load_registry
131
+ from ..registries.model_registry import load_model_registry
132
+ from ..registries.role_registry import load_role_registry
133
+
134
+ registry = load_registry()
135
+ models = load_model_registry().all()
136
+ roles = load_role_registry().all()
137
+
138
+ # Sort roles by name for deterministic UI (registry already returns sorted)
139
+ roles_sorted = sorted(roles, key=lambda r: r.name)
140
+
141
+ result = {}
142
+ for backend_name in sorted(clis):
143
+ backend = registry.get(backend_name)
144
+ if backend is None or not backend.supports("agents"):
145
+ continue
146
+
147
+ # Compatible models = those with at least one alias in backend.accepted_providers
148
+ compatible = [m for m in models if backend.first_alias_for(m.aliases) is not None]
149
+ if not compatible:
150
+ # Could be: empty accepted_providers, or no models declare an alias for
151
+ # any accepted provider. Either way, we can't drive this CLI — skip it
152
+ # with a clear warning rather than hard-crashing the wizard.
153
+ print(
154
+ f" {Color.YELLOW}Warning:{Color.NC} no compatible models found for "
155
+ f"{backend.label} (accepted providers: "
156
+ f"{list(backend.accepted_providers) or 'none'}). Skipping model selection; "
157
+ f"this CLI will rely on legacy tier resolution."
158
+ )
159
+ continue
160
+
161
+ cli_role_models = {}
162
+ for role in roles_sorted:
163
+ # Default: newest model (by registry order; iterate reversed so e.g.
164
+ # claude-opus-4-7 wins over claude-opus-4-6) whose class == role.typical_class.
165
+ # Fallback to first compatible if nothing matches.
166
+ default_model = next(
167
+ (m for m in reversed(compatible) if m.model_class == role.typical_class),
168
+ compatible[0],
169
+ )
170
+ default_idx = compatible.index(default_model)
171
+
172
+ # Build options: "Claude Opus 4.7 (via anthropic)" style
173
+ options = []
174
+ for m in compatible:
175
+ prov_alias = backend.first_alias_for(m.aliases)
176
+ provider = prov_alias[0] if prov_alias else "?"
177
+ options.append((f"{m.label} (via {provider})", m.id))
178
+
179
+ role_color = (_ROLE_ANSI.get(role.color, '') if sys.stdout.isatty() else '') if role.color else ''
180
+ role_label_colored = f"{role_color}{role.label}{Color.NC}" if role_color else role.label
181
+ title = (
182
+ f"{Color.DIM}CLI{Color.NC} {Color.YELLOW}{backend.label}{Color.NC}\n"
183
+ f" {Color.DIM}Role{Color.NC} {role_label_colored}\n"
184
+ f" {Color.DIM}Description{Color.NC} {role.description}"
185
+ )
186
+ if _can_interactive():
187
+ picked = _radio_select(title, options, default=default_idx,
188
+ step=step, total=total, version=version)
189
+ else:
190
+ picked = _radio_select_fallback(title, options, default=default_idx,
191
+ step=step, total=total, version=version)
192
+ cli_role_models[role.name] = picked
193
+
194
+ picked_label = next(label for label, mid in options if mid == picked)
195
+ print(f" {Color.GREEN}✓{Color.NC} {role_label_colored}: {picked_label}")
196
+
197
+ result[backend_name] = cli_role_models
198
+ return result
199
+
200
+
201
+ def _select_scope(clis: Set[str] = None, step: int = 0, total: int = 0, version: str = '') -> str:
202
+ """Step 3: Install scope."""
203
+ from ..registries.cli_registry import load_registry
204
+
205
+ registry = load_registry()
206
+ selected_backends = [b for b in registry.all() if (not clis or b.name in clis)]
207
+
208
+ def _path_lines(backends, path_fn) -> str:
209
+ parts = [f"\n {Color.DIM}{b.label} → {path_fn(b)}{Color.NC}" for b in backends]
210
+ return "".join(parts)
211
+
212
+ global_label = "Global" + _path_lines(selected_backends, lambda b: str(b.global_home))
213
+ local_label = "Local" + _path_lines(selected_backends, lambda b: str(Path.cwd() / b.local_dir))
214
+
215
+ options = [
216
+ (global_label, "global"),
217
+ (local_label, "local"),
218
+ ]
219
+ if _can_interactive():
220
+ result = _radio_select("Where to install?", options, default=0,
221
+ step=step, total=total, version=version)
222
+ else:
223
+ result = _radio_select_fallback("Where to install?", options, default=0,
224
+ step=step, total=total, version=version)
225
+
226
+ label = "Global" if result == "global" else "Local"
227
+ print(f" {Color.GREEN}✓{Color.NC} Scope: {label}")
228
+ return result
229
+
230
+
231
+ def _select_mode(step: int = 0, total: int = 0, version: str = '') -> bool:
232
+ """Step 4: Install mode."""
233
+ options = [
234
+ ("Symlink (auto-updates when source changes)", "symlink"),
235
+ ("Copy (standalone, allows local customization)", "copy"),
236
+ ]
237
+ if _can_interactive():
238
+ result = _radio_select("How to install?", options, default=0,
239
+ step=step, total=total, version=version)
240
+ else:
241
+ result = _radio_select_fallback("How to install?", options, default=0,
242
+ step=step, total=total, version=version)
243
+
244
+ label = "Symlink" if result == "symlink" else "Copy"
245
+ print(f" {Color.GREEN}✓{Color.NC} Mode: {label}")
246
+ return result == "copy"
247
+
248
+
249
+ def _select_skills(step: int = 0, total: int = 0, version: str = '') -> List[str]:
250
+ """Step 5: Skill selection."""
251
+ skill_groups = _get_skill_groups()
252
+
253
+ if not skill_groups:
254
+ return []
255
+
256
+ # Process skills are always included — separate them from tech skill groups.
257
+ process_skills = skill_groups.get("process", [])
258
+ tech_groups = {k: v for k, v in skill_groups.items() if k != "process"}
259
+
260
+ descriptions = {
261
+ "rails": "models, controllers, views, routes, testing",
262
+ "docker": "Dockerfile, Compose patterns",
263
+ "kamal": "deployment with Kamal",
264
+ "git": "commit workflow, conventional commits",
265
+ }
266
+
267
+ selected_skills = list(process_skills)
268
+
269
+ if tech_groups:
270
+ options = []
271
+ for group_name, skills in tech_groups.items():
272
+ desc = descriptions.get(group_name, group_name.lower())
273
+ count = len(skills)
274
+ label = f"{group_name.capitalize()} — {desc} ({count} {'skill' if count == 1 else 'skills'})"
275
+ options.append((label, group_name))
276
+
277
+ all_group_names = set(tech_groups.keys())
278
+
279
+ title = "Which domain skills to include?\n (process skills are always included)"
280
+ if _can_interactive():
281
+ selected_groups = _checkbox_select(title, options, defaults=all_group_names,
282
+ step=step, total=total, version=version)
283
+ else:
284
+ selected_groups = _checkbox_select_fallback(title, options, defaults=all_group_names,
285
+ step=step, total=total, version=version)
286
+
287
+ skill_summary_parts = [f"process ({len(process_skills)})"] if process_skills else []
288
+ for group_name, skills in tech_groups.items():
289
+ if group_name in selected_groups:
290
+ selected_skills.extend(skills)
291
+ skill_summary_parts.append(f"{group_name.capitalize()} ({len(skills)})")
292
+ else:
293
+ skill_summary_parts = [f"process ({len(process_skills)})"] if process_skills else []
294
+
295
+ summary = ", ".join(skill_summary_parts) if skill_summary_parts else "None"
296
+ print(f" {Color.GREEN}✓{Color.NC} Skills: {summary}")
297
+ return selected_skills
298
+
299
+
300
+ def _render_install_summary(clis: Set[str], scope: str, copy_mode: bool, selected_skills: List[str], role_models: Dict[str, Dict[str, str]], skill_groups: Dict, registry, memory_backend: str = '', memory_path: str = '') -> None:
301
+ """Print the confirmation summary in per-CLI format with role colors."""
302
+ from ..services.installer import config_filename_for as _cfg_filename
303
+ from ..registries.model_registry import load_model_registry
304
+ from ..registries.role_registry import load_role_registry
305
+
306
+ # Evaluate color codes at render time so they're never stale from import-time disable
307
+ _tty = sys.stdout.isatty()
308
+ _DIM = "\033[2m" if _tty else ""
309
+ _NC = "\033[0m" if _tty else ""
310
+ _CYAN = "\033[0;36m" if _tty else ""
311
+
312
+ selected_backends = [b for b in registry.all() if b.name in clis]
313
+ models_registry = load_model_registry()
314
+ role_registry = load_role_registry()
315
+ role_map = {r.name: r for r in role_registry.all()}
316
+
317
+ # ── Shared section ────────────────────────────────────────────────────────
318
+ print("")
319
+ scope_label = "Global" if scope == "global" else "Local"
320
+ print(f" {_DIM}Scope{_NC} {scope_label}")
321
+ print(f" {_DIM}Mode{_NC} {'Copy' if copy_mode else 'Symlink'}")
322
+
323
+ if selected_skills:
324
+ all_grouped = {s for gs in skill_groups.values() for s in gs}
325
+ parts = []
326
+ for gname, gskills in skill_groups.items():
327
+ cnt = sum(1 for s in selected_skills if s in gskills)
328
+ if cnt:
329
+ parts.append(f"{gname.capitalize()} ({cnt})")
330
+ ungrouped = sum(1 for s in selected_skills if s not in all_grouped)
331
+ if ungrouped:
332
+ parts.append(f"Other ({ungrouped})")
333
+ print(f" {_DIM}Skills{_NC} {', '.join(parts) if parts else 'none'}")
334
+
335
+ if memory_backend and memory_backend != "none":
336
+ mem_label = (f"Obsidian → {memory_path}" if memory_path else "Obsidian") if memory_backend == "obsidian" else "Local markdown"
337
+ print(f" {_DIM}Memory{_NC} {mem_label}")
338
+
339
+ # ── Per-CLI sections ──────────────────────────────────────────────────────
340
+ rules_count = _count_rules()
341
+
342
+ for backend in selected_backends:
343
+ print(f"\n {_CYAN}{backend.label}{_NC}")
344
+
345
+ # Agent roles
346
+ if backend.name in role_models and role_models[backend.name]:
347
+ print(f" {_DIM}Agent roles:{_NC}")
348
+ for role_name, model_id in sorted(role_models[backend.name].items()):
349
+ role = role_map.get(role_name)
350
+ role_label = role.label if role else role_name
351
+ role_ansi = (_ROLE_ANSI.get(role.color, "") if role and role.color else "") if _tty else ""
352
+ colored_role = f"{role_ansi}{role_label}{_NC}" if role_ansi else role_label
353
+ # Pad using visible label length (not raw string length which includes ANSI codes)
354
+ padding = " " * max(0, 28 - len(role_label))
355
+ try:
356
+ model = models_registry.get(model_id)
357
+ prov_alias = backend.first_alias_for(model.aliases)
358
+ alias = prov_alias[1] if prov_alias else model_id
359
+ except KeyError:
360
+ alias = model_id
361
+ print(f" {colored_role}{padding} {_DIM}{alias}{_NC}")
362
+
363
+ # Agents count
364
+ if backend.supports("agents"):
365
+ n_agents = count_agents(backend)
366
+ print(f" {_DIM}Agents:{_NC} {n_agents}")
367
+
368
+ # Config + Rules
369
+ cfg = _cfg_filename(backend)
370
+ if cfg:
371
+ cfg_desc = cfg
372
+ if rules_count:
373
+ cfg_desc += f" + {rules_count} rules"
374
+ print(f" {_DIM}Config:{_NC} {cfg_desc}")
375
+
376
+ print("")
377
+
378
+
379
+ def _detect_project_name() -> str:
380
+ """Return git repo name, or cwd name as fallback."""
381
+ import subprocess
382
+ try:
383
+ result = subprocess.run(
384
+ ["git", "rev-parse", "--show-toplevel"],
385
+ capture_output=True, text=True, timeout=3,
386
+ )
387
+ if result.returncode == 0:
388
+ return Path(result.stdout.strip()).name
389
+ except (OSError, subprocess.TimeoutExpired):
390
+ pass
391
+ return Path.cwd().name
392
+
393
+
394
+ def _detect_obsidian_vaults() -> List[Path]:
395
+ """Scan common locations for Obsidian vaults (dirs containing .obsidian/)."""
396
+ candidates = []
397
+ search_roots = [Path.home() / "Documents", Path.home() / "Desktop", Path.home()]
398
+ for root in search_roots:
399
+ if not root.exists():
400
+ continue
401
+ try:
402
+ for d in root.iterdir():
403
+ try:
404
+ if d.is_dir() and (d / ".obsidian").exists():
405
+ candidates.append(d)
406
+ except (PermissionError, OSError):
407
+ continue
408
+ except (PermissionError, OSError):
409
+ continue
410
+ return candidates[:5]
411
+
412
+
413
+ def _select_memory(step: int, total: int, version: str = '') -> tuple:
414
+ """Step N: choose memory backend. Returns (backend, path)."""
415
+ from ..config import Color
416
+
417
+ options = [
418
+ ("Local markdown files (~/.claude/agent-memory/)", "local"),
419
+ ("Obsidian vault", "obsidian"),
420
+ ("None (disable memory)", "none"),
421
+ ]
422
+
423
+ if _can_interactive():
424
+ backend = _radio_select("How should agents store memory?", options, default=0,
425
+ step=step, total=total, version=version)
426
+ else:
427
+ backend = _radio_select_fallback("How should agents store memory?", options, default=0,
428
+ step=step, total=total, version=version)
429
+
430
+ path = ""
431
+
432
+ if backend == "obsidian":
433
+ project_name = _detect_project_name()
434
+ candidates = _detect_obsidian_vaults()
435
+ if candidates:
436
+ _hint_suffix = f"agent-notes/{project_name}" if project_name != "agent-notes" else "agent-notes"
437
+ print(f" {Color.DIM}Detected vaults (notes go into {_hint_suffix}/ inside):{Color.NC}")
438
+ for c in candidates[:3]:
439
+ print(f" {c}/{_hint_suffix}")
440
+ _mem_base = candidates[0] if candidates else Path.home() / "Documents" / "Obsidian Vault"
441
+ _mem_full = _mem_base / "agent-notes" / project_name
442
+ # Avoid agent-notes/agent-notes when project name matches parent folder
443
+ if _mem_full.parent.name == _mem_full.name:
444
+ _mem_full = _mem_full.parent
445
+ default_path = str(_mem_full)
446
+ raw = _safe_input(f" Memory folder path [{default_path}]: ", default_path)
447
+ path = raw.strip() or default_path
448
+
449
+ label = {"local": "Local markdown", "obsidian": f"Obsidian ({path})", "none": "Disabled"}[backend]
450
+ print(f" {Color.GREEN}✓{Color.NC} Memory: {label}")
451
+ return backend, path
452
+
453
+
454
+ def _confirm_install(clis: Set[str], scope: str, copy_mode: bool, selected_skills: List[str], role_models: Dict[str, Dict[str, str]], version: str = '', memory_backend: str = 'local', memory_path: str = '') -> bool:
455
+ """Step 7: Confirmation."""
456
+ from ..services.ui import _clear_screen, _render_step_header
457
+ from ..registries.cli_registry import load_registry
458
+ _clear_screen()
459
+ _render_step_header(7, 7, version)
460
+ skill_groups = _get_skill_groups()
461
+ registry = load_registry()
462
+
463
+ _render_install_summary(clis, scope, copy_mode, selected_skills, role_models, skill_groups, registry,
464
+ memory_backend=memory_backend, memory_path=memory_path)
465
+
466
+ choice = _safe_input("Proceed? [Y/n]: ", "Y").lower()
467
+ return choice != "n"
468
+
469
+
470
+ def install_skills_filtered(skill_names: List[str], targets: List[Path], copy_mode: bool = False) -> None:
471
+ """Install only specified skills to target directories."""
472
+ if not skill_names or not DIST_SKILLS_DIR.exists():
473
+ return
474
+
475
+ for target_dir in targets:
476
+ target_dir.mkdir(parents=True, exist_ok=True)
477
+
478
+ for skill_name in sorted(skill_names):
479
+ skill_dir = DIST_SKILLS_DIR / skill_name
480
+ if skill_dir.is_dir():
481
+ place_file(skill_dir, target_dir / skill_name, copy_mode)
482
+
483
+
484
+ def install_agents_filtered(clis: Set[str], scope: str, copy_mode: bool = False) -> None:
485
+ """Install agents for selected CLIs (filtered by the wizard)."""
486
+ from ..services import installer
487
+ from ..registries.cli_registry import load_registry
488
+
489
+ registry = load_registry()
490
+ for backend in registry.all():
491
+ if backend.name not in clis:
492
+ continue
493
+ src = installer.dist_source_for(backend, "agents")
494
+ if src is None:
495
+ continue
496
+ dst = installer.target_dir_for(backend, "agents", scope)
497
+ if dst is None:
498
+ continue
499
+
500
+ # Only install if there are files to install
501
+ files = list(src.glob("*.md"))
502
+ if not files:
503
+ continue
504
+
505
+
506
+ place_dir_contents(src, dst, "*.md", copy_mode)
507
+
508
+
509
+ def install_config_filtered(clis: Set[str], scope: str, copy_mode: bool = False) -> None:
510
+ """Install config + rules for selected CLIs."""
511
+ from ..services import installer
512
+ from ..registries.cli_registry import load_registry
513
+
514
+ registry = load_registry()
515
+
516
+ for backend in registry.all():
517
+ if backend.name not in clis:
518
+ continue
519
+
520
+ # Install config file (CLAUDE.md / AGENTS.md / copilot-instructions.md)
521
+ config_src = installer.dist_source_for(backend, "config")
522
+ config_dst = installer.target_dir_for(backend, "config", scope)
523
+ if config_src is not None and config_dst is not None:
524
+ filename = installer.config_filename_for(backend)
525
+ if filename:
526
+ src_file = config_src / filename
527
+ if src_file.exists():
528
+ place_file(src_file, config_dst / filename, copy_mode)
529
+
530
+ # Install rules (only backends that support it — currently just claude)
531
+ rules_src = installer.dist_source_for(backend, "rules")
532
+ rules_dst = installer.target_dir_for(backend, "rules", scope)
533
+ if rules_src is not None and rules_dst is not None:
534
+ files = list(rules_src.glob("*.md"))
535
+ if files:
536
+ place_dir_contents(rules_src, rules_dst, "*.md", copy_mode)
537
+
538
+
539
+
540
+ def interactive_install() -> None:
541
+ """Run the interactive install wizard."""
542
+ try:
543
+ _interactive_install()
544
+ except KeyboardInterrupt:
545
+ from ..config import Color
546
+ print(f"\n\n {Color.YELLOW}Cancelled.{Color.NC}")
547
+
548
+
549
+ def _interactive_install() -> None:
550
+ """Inner implementation — called by interactive_install() with KeyboardInterrupt guard."""
551
+ from ..services.ui import _clear_screen
552
+ version = get_version()
553
+ from ..registries.cli_registry import load_registry
554
+ registry = load_registry()
555
+
556
+ # Get total agent count across all backends that support agents
557
+ total_agents = 0
558
+ for backend in registry.all():
559
+ if backend.supports("agents"):
560
+ total_agents += count_agents(backend)
561
+
562
+ n_skills = count_skills()
563
+ n_rules = _count_rules()
564
+
565
+ TOTAL_STEPS = 7
566
+
567
+ _clear_screen()
568
+ print(f"\n {Color.BOLD}AgentNotes{Color.NC} {Color.CYAN}v{version}{Color.NC}")
569
+ print(f" {Color.DIM}AI agent configuration manager for Claude Code and OpenCode.{Color.NC}\n")
570
+ print(f" Includes {total_agents} agents, {n_skills} skills, and {n_rules} rules.\n")
571
+
572
+ # Step 1: CLI selection
573
+ clis = _select_cli(step=1, total=TOTAL_STEPS, version=version)
574
+
575
+ if not clis:
576
+ print("No CLI selected. Installation cancelled.")
577
+ return
578
+
579
+ # Step 2: Model selection per role (for CLIs that support agents)
580
+ role_models = _select_models_per_role(clis, step=2, total=TOTAL_STEPS, version=version)
581
+
582
+ # Step 3: Install scope
583
+ scope = _select_scope(clis=clis, step=3, total=TOTAL_STEPS, version=version)
584
+
585
+ # Step 4: Install mode (always shown)
586
+ copy_mode = _select_mode(step=4, total=TOTAL_STEPS, version=version)
587
+
588
+ # Step 5: Skill selection
589
+ selected_skills = _select_skills(step=5, total=TOTAL_STEPS, version=version)
590
+
591
+ # Step 6: Memory backend
592
+ memory_backend, memory_path = _select_memory(step=6, total=TOTAL_STEPS, version=version)
593
+
594
+ # Step 7: Confirmation
595
+ if not _confirm_install(clis, scope, copy_mode, selected_skills, role_models, version=version,
596
+ memory_backend=memory_backend, memory_path=memory_path):
597
+ print("Installation cancelled.")
598
+ return
599
+
600
+ # Build first
601
+ print("\nBuilding from source...")
602
+ try:
603
+ build()
604
+ except Exception as e:
605
+ print(f"{Color.RED}Build failed: {e}{Color.NC}")
606
+ return
607
+
608
+ # Execute installation
609
+ print(f"\nInstalling ({scope}, {'copy' if copy_mode else 'symlink'}) ...\n")
610
+
611
+ from ..services import fs as _fs
612
+ _fs.silent_file_ops = True
613
+
614
+ from ..registries.cli_registry import load_registry as _load_registry
615
+ from ..services import installer as _installer
616
+ _registry = _load_registry()
617
+
618
+ # Scripts (global only)
619
+ if scope == "global":
620
+ from ..services.installer import install_scripts_global
621
+ install_scripts_global()
622
+
623
+ # Skills
624
+ if selected_skills:
625
+ targets = []
626
+ for _b in _registry.all():
627
+ if _b.name in clis and _b.supports("skills"):
628
+ _t = _installer.target_dir_for(_b, "skills", scope)
629
+ if _t is not None:
630
+ targets.append(_t)
631
+ if scope == "global":
632
+ targets.append(AGENTS_HOME / "skills")
633
+ install_skills_filtered(selected_skills, targets, copy_mode)
634
+ _skill_groups = _get_skill_groups()
635
+ _group_parts = []
636
+ for _gn, _gs in _skill_groups.items():
637
+ _cnt = sum(1 for s in selected_skills if s in _gs)
638
+ if _cnt:
639
+ _group_parts.append(f"{_gn} ({_cnt})")
640
+ _all_grouped = {s for gs in _skill_groups.values() for s in gs}
641
+ _ungrouped = sum(1 for s in selected_skills if s not in _all_grouped)
642
+ if _ungrouped:
643
+ _group_parts.append(f"Other ({_ungrouped})")
644
+ print(f" {Color.GREEN}✓{Color.NC} Skills {', '.join(_group_parts) if _group_parts else str(len(selected_skills)) + ' skills'}")
645
+
646
+ # Agents
647
+ install_agents_filtered(clis, scope, copy_mode)
648
+ _agent_parts = []
649
+ for _b in _registry.all():
650
+ if _b.name in clis and _b.supports("agents"):
651
+ _cnt = count_agents(_b)
652
+ if _cnt:
653
+ _agent_parts.append(f"{_b.label} ({_cnt})")
654
+ if _agent_parts:
655
+ print(f" {Color.GREEN}✓{Color.NC} Agents {', '.join(_agent_parts)}")
656
+
657
+ # Config + Rules
658
+ install_config_filtered(clis, scope, copy_mode)
659
+ _rules_n = _count_rules()
660
+ _cfg_files = [_installer.config_filename_for(_b) for _b in _registry.all() if _b.name in clis and _installer.config_filename_for(_b)]
661
+ _cfg_desc = ", ".join(_cfg_files) if _cfg_files else "config"
662
+ _cfg_desc += f" + {_rules_n} rules" if _rules_n else ""
663
+ print(f" {Color.GREEN}✓{Color.NC} Config {_cfg_desc}")
664
+
665
+ # Commands
666
+ from ..services.installer import install_component_for_backend as _install_component
667
+ for _backend in _registry.all():
668
+ if _backend.name in clis:
669
+ _install_component(_backend, "commands", scope, copy_mode)
670
+ _cmd_names = [f.stem for f in (PKG_DIR / "dist" / "commands").glob("*.md")] if (PKG_DIR / "dist" / "commands").exists() else []
671
+ if _cmd_names:
672
+ print(f" {Color.GREEN}✓{Color.NC} Commands {', '.join(sorted(_cmd_names))}")
673
+
674
+ # SessionStart hook (Claude Code only)
675
+ from ..services.installer import _install_session_hook
676
+ try:
677
+ _claude = _registry.get("claude")
678
+ if _claude.name in clis:
679
+ _install_session_hook(_claude, scope)
680
+ except (KeyError, Exception):
681
+ pass
682
+
683
+ _fs.silent_file_ops = False
684
+
685
+ # Write state.json
686
+ from ..services.install_state_builder import build_install_state
687
+ from ..services.state_store import record_install_state
688
+ from ..domain.state import MemoryConfig
689
+ project_path = Path.cwd() if scope == "local" else None
690
+ try:
691
+ st = build_install_state(
692
+ mode="copy" if copy_mode else "symlink",
693
+ scope=scope,
694
+ repo_root=PKG_DIR.parent,
695
+ project_path=project_path,
696
+ role_models=role_models,
697
+ selected_clis=set(clis),
698
+ )
699
+ st.memory = MemoryConfig(backend=memory_backend, path=memory_path)
700
+ record_install_state(st)
701
+ except Exception as e:
702
+ print(f"{Color.YELLOW}Warning: failed to write state.json: {e}{Color.NC}")
703
+
704
+ # Initialize memory vault / directory on disk
705
+ if memory_backend != "none":
706
+ from ..config import memory_dir_for_backend
707
+ from ..services.memory_backend import obsidian_init, local_init
708
+ _mem_path = memory_dir_for_backend(memory_backend, memory_path)
709
+ try:
710
+ if memory_backend == "obsidian":
711
+ obsidian_init(_mem_path)
712
+ memory_label = f"Obsidian → {_mem_path}"
713
+ else:
714
+ local_init(_mem_path)
715
+ memory_label = f"Local markdown → {_mem_path}"
716
+ except Exception as e:
717
+ memory_label = f"(init failed: {e})"
718
+ print(f" {Color.GREEN}✓{Color.NC} Memory {memory_label}")
719
+
720
+ print(f"\n{Color.GREEN}Done.{Color.NC} Restart Claude Code / OpenCode to pick up changes.")