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,272 @@
1
+ """Diagnostic check functions for agent-notes installation."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ from ...domain.diagnostics import Issue, FixAction
8
+
9
+
10
+ def check_stale_files(scope: str, issues: List[Issue], fix_actions: List[FixAction]):
11
+ """Check for installed files without matching source - DELEGATED to doctor_checks."""
12
+ # This function is kept for backwards compatibility but delegates to the new module
13
+ from ...cli_backend import load_registry
14
+ from ... import install_state, doctor_checks
15
+ from ...state import get_scope
16
+
17
+ registry = load_registry()
18
+ state = install_state.load_current_state()
19
+ if state is None:
20
+ scope_state = None
21
+ else:
22
+ project_path = Path.cwd() if scope == "local" else None
23
+ scope_state = get_scope(state, scope, project_path)
24
+ doctor_checks.check_stale(scope, scope_state, registry, issues, fix_actions)
25
+
26
+
27
+ def _find_dist_source(symlink: Path, scope: str) -> Optional[Path]:
28
+ """Map an installed path back to its dist source for relinking.
29
+
30
+ Iterates all registered backends; returns first dist source whose
31
+ component and filename match the given symlink.
32
+ """
33
+ from ...cli_backend import load_registry
34
+ from ... import installer
35
+ registry = load_registry()
36
+
37
+ symlink = symlink.resolve() if symlink.exists() else Path(os.path.abspath(symlink))
38
+ name = symlink.name
39
+ parent_name = symlink.parent.name # e.g. "agents", "skills", "rules"
40
+
41
+ # Try each backend's dist source for this component
42
+ for backend in registry.all():
43
+ src_dir = installer.dist_source_for(backend, parent_name)
44
+ if src_dir is None:
45
+ continue
46
+ candidate = src_dir / name
47
+ if candidate.exists():
48
+ return candidate
49
+
50
+ # Config files (global only): check each backend's config_filename
51
+ if scope == "global":
52
+ for backend in registry.all():
53
+ config_fn = installer.config_filename_for(backend)
54
+ if config_fn == name:
55
+ src = installer.dist_source_for(backend, "config")
56
+ if src is not None:
57
+ candidate = src / config_fn
58
+ if candidate.exists():
59
+ return candidate
60
+
61
+ # Scripts
62
+ def _get_bin_home():
63
+ from ...config import BIN_HOME
64
+ return BIN_HOME
65
+
66
+ def _get_dist_scripts_dir():
67
+ from ...config import DIST_SCRIPTS_DIR
68
+ return DIST_SCRIPTS_DIR
69
+
70
+ bin_home = _get_bin_home()
71
+ dist_scripts_dir = _get_dist_scripts_dir()
72
+
73
+ if bin_home and str(symlink).startswith(str(bin_home)):
74
+ if dist_scripts_dir:
75
+ source = dist_scripts_dir / name
76
+ if source.exists():
77
+ return source
78
+
79
+ # Universal skills
80
+ def _get_dist_skills_dir():
81
+ from ...config import DIST_SKILLS_DIR
82
+ return DIST_SKILLS_DIR
83
+
84
+ if parent_name == "skills":
85
+ dist_skills_dir = _get_dist_skills_dir()
86
+ if dist_skills_dir:
87
+ source = dist_skills_dir / name
88
+ if source.exists():
89
+ return source
90
+
91
+ return None
92
+
93
+
94
+ def check_broken_symlinks(scope: str, issues: List[Issue], fix_actions: List[FixAction]):
95
+ """Check for symlinks with non-existent targets - DELEGATED to doctor_checks."""
96
+ # This function is kept for backwards compatibility but delegates to the new module
97
+ from ...cli_backend import load_registry
98
+ from ... import install_state, doctor_checks
99
+ from ...state import get_scope
100
+
101
+ registry = load_registry()
102
+ state = install_state.load_current_state()
103
+ if state is None:
104
+ scope_state = None
105
+ else:
106
+ project_path = Path.cwd() if scope == "local" else None
107
+ scope_state = get_scope(state, scope, project_path)
108
+ doctor_checks.check_broken(scope, registry, issues, fix_actions, scope_state)
109
+
110
+
111
+ def check_shadowed_files(scope: str, issues: List[Issue], fix_actions: List[FixAction]):
112
+ """Check for regular files where symlinks are expected - TARGETED check only."""
113
+ from ...cli_backend import load_registry
114
+ from ... import install_state, doctor_checks
115
+ from ...state import get_scope
116
+
117
+ # Get expected paths and check each one individually
118
+ registry = load_registry()
119
+ state = install_state.load_current_state()
120
+ if state is None:
121
+ scope_state = None
122
+ else:
123
+ project_path = Path.cwd() if scope == "local" else None
124
+ scope_state = get_scope(state, scope, project_path)
125
+
126
+ # Only check paths we know should exist
127
+ for src, dst, backend_name, component in doctor_checks.expected_paths_for_install(registry, scope):
128
+ if dst.exists() and not dst.is_symlink():
129
+ # This is a regular file where we expected a symlink (or copy in copy mode)
130
+ # If we're in symlink mode, this is shadowed
131
+ if scope_state is None or scope_state.mode == "symlink":
132
+ issues.append(Issue("shadowed", str(dst),
133
+ "Regular file instead of symlink. Won't receive updates."))
134
+ fix_actions.append(FixAction("RELINK", str(dst),
135
+ f"replace copy with symlink to {src}"))
136
+
137
+
138
+ def check_missing_files(scope: str, issues: List[Issue], fix_actions: List[FixAction]):
139
+ """Check for source files that aren't installed - DELEGATED to doctor_checks."""
140
+ # This function is kept for backwards compatibility but delegates to the new module
141
+ from ...cli_backend import load_registry
142
+ from ... import doctor_checks, install_state
143
+ from ...state import get_scope
144
+
145
+ registry = load_registry()
146
+ # Pass scope state so opted-out backends aren't flagged as "missing".
147
+ state = install_state.load_current_state()
148
+ scope_state = None
149
+ if state is not None:
150
+ try:
151
+ project_path = Path.cwd().resolve() if scope == "local" else None
152
+ scope_state = get_scope(state, scope, project_path)
153
+ except (ValueError, KeyError):
154
+ scope_state = None
155
+ # Call via kwargs only when we actually have scope_state — tests that
156
+ # replace doctor_checks.check_missing with a narrower signature still work.
157
+ if scope_state is not None:
158
+ doctor_checks.check_missing(scope, registry, issues, fix_actions, scope_state=scope_state)
159
+ else:
160
+ doctor_checks.check_missing(scope, registry, issues, fix_actions)
161
+
162
+
163
+ def check_content_drift(scope: str, issues: List[Issue], fix_actions: List[FixAction]):
164
+ """Check for copied files that differ from source - DELEGATED to doctor_checks."""
165
+ # This function is kept for backwards compatibility but delegates to the new module
166
+ from ...cli_backend import load_registry
167
+ from ... import install_state, doctor_checks
168
+ from ...state import get_scope
169
+
170
+ registry = load_registry()
171
+ state = install_state.load_current_state()
172
+ if state is None:
173
+ scope_state = None
174
+ else:
175
+ project_path = Path.cwd() if scope == "local" else None
176
+ scope_state = get_scope(state, scope, project_path)
177
+ doctor_checks.check_drift(scope, registry, issues, fix_actions, scope_state)
178
+
179
+
180
+ def check_build_freshness(issues: List[Issue], fix_actions: List[FixAction]):
181
+ """Check if source files are newer than generated files."""
182
+ from ...config import AGENTS_YAML, AGENTS_DIR, SCRIPTS_DIR, DIST_SCRIPTS_DIR
183
+ agents_yaml = AGENTS_YAML
184
+
185
+ # Check agents.yaml vs generated agents
186
+ if agents_yaml.exists():
187
+ source_time = agents_yaml.stat().st_mtime
188
+ from ...cli_backend import load_registry
189
+ from ...config import dist_dir_for
190
+
191
+ registry = load_registry()
192
+ for backend in registry.with_feature("agents"):
193
+ agents_dir = dist_dir_for(backend) / backend.layout.get("agents", "agents")
194
+ if agents_dir.exists():
195
+ for f in agents_dir.glob("*.md"):
196
+ gen_time = f.stat().st_mtime
197
+ if source_time > gen_time:
198
+ issues.append(Issue("build_stale", str(f), "agents.yaml is newer than generated files"))
199
+ fix_actions.append(FixAction("BUILD", f"agents-{backend.name}/", "regenerate from source"))
200
+ break
201
+
202
+ # Check individual source agents
203
+ source_agents_dir = AGENTS_DIR
204
+ if source_agents_dir.exists():
205
+ from ...cli_backend import load_registry
206
+ from ...config import dist_dir_for
207
+
208
+ registry = load_registry()
209
+ for src_file in source_agents_dir.glob("*.md"):
210
+ source_time = src_file.stat().st_mtime
211
+
212
+ # Check corresponding generated files across all backends with agents
213
+ for backend in registry.with_feature("agents"):
214
+ gen_file = dist_dir_for(backend) / backend.layout.get("agents", "agents") / src_file.name
215
+ if gen_file.exists():
216
+ gen_time = gen_file.stat().st_mtime
217
+ if source_time > gen_time:
218
+ issues.append(Issue("build_stale", str(gen_file),
219
+ f"{src_file} is newer than generated file"))
220
+ fix_actions.append(FixAction("BUILD", str(gen_file), "regenerate from source"))
221
+
222
+ # Check scripts source vs dist
223
+ if SCRIPTS_DIR.exists() and DIST_SCRIPTS_DIR.exists():
224
+ for src_file in SCRIPTS_DIR.iterdir():
225
+ if src_file.is_file():
226
+ dist_file = DIST_SCRIPTS_DIR / src_file.name
227
+ if dist_file.exists() and src_file.stat().st_mtime > dist_file.stat().st_mtime:
228
+ issues.append(Issue("build_stale", str(dist_file),
229
+ f"{src_file} is newer than generated file"))
230
+ fix_actions.append(FixAction("BUILD", str(dist_file), "regenerate from source"))
231
+
232
+ # Check global source files
233
+ from ...cli_backend import load_registry
234
+ from ...config import global_template_path, global_output_path
235
+
236
+ registry = load_registry()
237
+ for backend in registry.all():
238
+ src = global_template_path(backend)
239
+ gen = global_output_path(backend)
240
+
241
+ if src and gen and src.exists() and gen.exists():
242
+ src_time = src.stat().st_mtime
243
+ gen_time = gen.stat().st_mtime
244
+
245
+ if src_time > gen_time:
246
+ issues.append(Issue("build_stale", str(gen), f"{src} is newer than generated file"))
247
+ fix_actions.append(FixAction("BUILD", str(gen), "regenerate from source"))
248
+
249
+ # Check plugin agents are up-to-date with dist agents
250
+ from ...config import ROOT, DIST_DIR
251
+ plugin_agents_dir = ROOT / ".claude-plugin" / "agents"
252
+ dist_claude_agents = DIST_DIR / "claude" / "agents"
253
+ if plugin_agents_dir.exists() and dist_claude_agents.exists():
254
+ for dist_file in dist_claude_agents.glob("*.md"):
255
+ plugin_file = plugin_agents_dir / dist_file.name
256
+ if plugin_file.exists() and dist_file.stat().st_mtime > plugin_file.stat().st_mtime:
257
+ issues.append(Issue("build_stale", str(plugin_file),
258
+ "dist agent is newer than plugin agent — run scripts/build-plugin.sh"))
259
+ fix_actions.append(FixAction("BUILD", ".claude-plugin/agents/", "run scripts/build-plugin.sh"))
260
+ break # one warning is enough
261
+
262
+ # Check user config freshness against dist agents
263
+ from ...services.user_config import config_path
264
+ user_cfg = config_path()
265
+ if user_cfg.exists() and dist_claude_agents.exists():
266
+ cfg_time = user_cfg.stat().st_mtime
267
+ for dist_file in dist_claude_agents.glob("*.md"):
268
+ if cfg_time > dist_file.stat().st_mtime:
269
+ issues.append(Issue("build_stale", str(user_cfg),
270
+ "user config is newer than dist agents — run: agent-notes build"))
271
+ fix_actions.append(FixAction("BUILD", "dist/", "regenerate from source"))
272
+ break
@@ -0,0 +1,346 @@
1
+ """Display and summary functions for agent-notes diagnostics."""
2
+
3
+ from pathlib import Path
4
+ from typing import List, Dict
5
+
6
+ from ...domain.diagnostics import Issue
7
+
8
+
9
+ def _cli_base_dir(backend, scope: str) -> Path:
10
+ """Get base directory for a CLI backend."""
11
+ if scope == "global":
12
+ return backend.global_home
13
+ else:
14
+ return Path(backend.local_dir)
15
+
16
+
17
+ def _count_agents(backend, scope: str) -> tuple:
18
+ """Count (installed, expected) agents for a CLI backend."""
19
+ from ... import installer
20
+
21
+ if not backend.supports("agents"):
22
+ return 0, 0
23
+
24
+ # Count installed
25
+ agents_dir = installer.target_dir_for(backend, "agents", scope)
26
+ installed = len(list(agents_dir.glob("*.md"))) if agents_dir and agents_dir.exists() else 0
27
+
28
+ # Count expected
29
+ src_dir = installer.dist_source_for(backend, "agents")
30
+ expected = len(list(src_dir.glob("*.md"))) if src_dir and src_dir.exists() else 0
31
+
32
+ return installed, expected
33
+
34
+
35
+ def _count_skills(backend, scope: str) -> tuple:
36
+ """Count (installed, expected) skills for a CLI backend. Excludes broken symlinks."""
37
+ from ... import installer
38
+
39
+ # Helper to get DIST_SKILLS_DIR
40
+ def _get_dist_skills_dir():
41
+ from ...config import DIST_SKILLS_DIR
42
+ return DIST_SKILLS_DIR
43
+
44
+ if not backend.supports("skills"):
45
+ return 0, 0
46
+
47
+ # Count installed
48
+ skills_dir = installer.target_dir_for(backend, "skills", scope)
49
+ if skills_dir and skills_dir.exists():
50
+ installed = len([d for d in skills_dir.iterdir() if d.is_dir() and d.exists()])
51
+ else:
52
+ installed = 0
53
+
54
+ # Count expected (universal skills)
55
+ dist_skills_dir = _get_dist_skills_dir()
56
+ expected = len([d for d in dist_skills_dir.iterdir() if d.is_dir()]) if dist_skills_dir and dist_skills_dir.exists() else 0
57
+ return installed, expected
58
+
59
+
60
+ def _count_scripts() -> tuple:
61
+ """Count (installed, expected) scripts in ~/.local/bin/."""
62
+ # Helper functions to get config values
63
+ def _get_bin_home():
64
+ from ...config import BIN_HOME
65
+ return BIN_HOME
66
+
67
+ def _get_dist_scripts_dir():
68
+ from ...config import DIST_SCRIPTS_DIR
69
+ return DIST_SCRIPTS_DIR
70
+
71
+ bin_home = _get_bin_home()
72
+ dist_scripts_dir = _get_dist_scripts_dir()
73
+
74
+ installed = len([f for f in bin_home.iterdir() if f.is_file() and (dist_scripts_dir / f.name).exists()]) if bin_home and bin_home.exists() else 0
75
+ expected = len([f for f in dist_scripts_dir.iterdir() if f.is_file()]) if dist_scripts_dir and dist_scripts_dir.exists() else 0
76
+ return installed, expected
77
+
78
+
79
+ def _count_rules(backend, scope: str) -> tuple:
80
+ """Count (installed, expected) rules for a CLI backend."""
81
+ from ... import installer
82
+
83
+ # Helper to get DIST_RULES_DIR
84
+ def _get_dist_rules_dir():
85
+ from ...config import DIST_RULES_DIR
86
+ return DIST_RULES_DIR
87
+
88
+ if not backend.supports("rules"):
89
+ return 0, 0
90
+
91
+ # Count installed
92
+ rules_dir = installer.target_dir_for(backend, "rules", scope)
93
+ installed = len(list(rules_dir.glob("*.md"))) if rules_dir and rules_dir.exists() else 0
94
+
95
+ # Count expected
96
+ dist_rules_dir = _get_dist_rules_dir()
97
+ expected = len(list(dist_rules_dir.glob("*.md"))) if dist_rules_dir and dist_rules_dir.exists() else 0
98
+ return installed, expected
99
+
100
+
101
+ def _check_config(backend, scope: str) -> tuple:
102
+ """Check config files for a CLI backend. Returns (all_installed: bool, description: str, missing: list)."""
103
+ from ... import installer
104
+
105
+ config_file = installer.config_filename_for(backend)
106
+ if not config_file:
107
+ return True, "no config file", []
108
+
109
+ config_dir = installer.target_dir_for(backend, "config", scope)
110
+ if not config_dir:
111
+ return False, "not supported", [config_file]
112
+
113
+ config_path = config_dir / config_file
114
+ if config_path.exists():
115
+ return True, config_file, []
116
+ else:
117
+ return False, "not installed", [config_file]
118
+
119
+
120
+ def _check_role_models(state):
121
+ """Display role→model assignments and check compatibility."""
122
+ from ...model_registry import load_model_registry
123
+ from ...cli_backend import load_registry
124
+ from ...role_registry import load_role_registry
125
+ from ...config import Color
126
+
127
+ model_registry = load_model_registry()
128
+ cli_registry = load_registry()
129
+ role_registry = load_role_registry()
130
+ issues = []
131
+
132
+ print(f"\n{Color.CYAN}Role→Model Assignments:{Color.NC}\n")
133
+
134
+ # Global
135
+ if state.global_install:
136
+ print("Global:")
137
+ for cli_name, backend_state in state.global_install.clis.items():
138
+ try:
139
+ backend = cli_registry.get(cli_name)
140
+ except KeyError:
141
+ print(f" {cli_name} (CLI not found in registry):")
142
+ continue
143
+
144
+ print(f" {backend.label}:")
145
+ if not backend_state.role_models:
146
+ print(f" {Color.YELLOW}(no role assignments){Color.NC}")
147
+ else:
148
+ for role_id, model_id in backend_state.role_models.items():
149
+ try:
150
+ role = role_registry.get(role_id)
151
+ model = model_registry.get(model_id)
152
+
153
+ # Check compatibility: first_alias_for returns (provider, alias) | None
154
+ resolved = backend.first_alias_for(model.aliases)
155
+ if resolved is not None:
156
+ _provider, alias_str = resolved
157
+ model_display = f"{model.label} (as {alias_str})"
158
+ print(f" {role.label} → {model_display}")
159
+ else:
160
+ model_display = f"{model.label} (INCOMPATIBLE - {backend.label} doesn't support this provider)"
161
+ print(f" {role.label} → {Color.RED}{model_display}{Color.NC}")
162
+ issues.append(f"Global {backend.label}: {role.label} assigned to incompatible model {model.label}")
163
+ except KeyError as e:
164
+ print(f" {role_id} → {Color.RED}{model_id} (not found: {e}){Color.NC}")
165
+ issues.append(f"Global {backend.label}: Invalid assignment {role_id} → {model_id}")
166
+ print()
167
+
168
+ # Local
169
+ for project_path, local_state in state.local_installs.items():
170
+ print(f"Local ({project_path}):")
171
+ for cli_name, backend_state in local_state.clis.items():
172
+ try:
173
+ backend = cli_registry.get(cli_name)
174
+ except KeyError:
175
+ print(f" {cli_name} (CLI not found in registry):")
176
+ continue
177
+
178
+ print(f" {backend.label}:")
179
+ if not backend_state.role_models:
180
+ print(f" {Color.YELLOW}(no role assignments){Color.NC}")
181
+ else:
182
+ for role_id, model_id in backend_state.role_models.items():
183
+ try:
184
+ role = role_registry.get(role_id)
185
+ model = model_registry.get(model_id)
186
+
187
+ resolved = backend.first_alias_for(model.aliases)
188
+ if resolved is not None:
189
+ _provider, alias_str = resolved
190
+ model_display = f"{model.label} (as {alias_str})"
191
+ print(f" {role.label} → {model_display}")
192
+ else:
193
+ model_display = f"{model.label} (INCOMPATIBLE - {backend.label} doesn't support this provider)"
194
+ print(f" {role.label} → {Color.RED}{model_display}{Color.NC}")
195
+ issues.append(f"Local {project_path} {backend.label}: {role.label} assigned to incompatible model {model.label}")
196
+ except KeyError as e:
197
+ print(f" {role_id} → {Color.RED}{model_id} (not found: {e}){Color.NC}")
198
+ issues.append(f"Local {project_path} {backend.label}: Invalid assignment {role_id} → {model_id}")
199
+ print()
200
+
201
+ if issues:
202
+ print(f"{Color.RED}Issues found:{Color.NC}")
203
+ for issue in issues:
204
+ print(f" • {issue}")
205
+ print(f"\nRun '{Color.CYAN}agent-notes set role{Color.NC}' to fix assignments.")
206
+ else:
207
+ print(f"{Color.GREEN}All role assignments look good.{Color.NC}")
208
+
209
+
210
+ def count_stale(issues: List[Issue], item_type: str) -> int:
211
+ """Count stale issues of a specific type."""
212
+ count = 0
213
+ for issue in issues:
214
+ if issue.type == "stale" and item_type in issue.file:
215
+ count += 1
216
+ return count
217
+
218
+
219
+ def _print_status(label: str, installed: int, expected: int):
220
+ """Print OK/WARN status for a component."""
221
+ from ...config import ok, warn
222
+ if installed == 0 and expected == 0:
223
+ ok(f"{label} (none available)", indent=4)
224
+ elif installed == 0:
225
+ warn(f"{label} (not installed, {expected} available)", indent=4)
226
+ elif installed >= expected:
227
+ ok(f"{label} ({installed} installed)", indent=4)
228
+ else:
229
+ missing = expected - installed
230
+ warn(f"{label} ({installed} installed, {missing} missing)", indent=4)
231
+
232
+
233
+ def print_summary(scope: str):
234
+ """Print installation summary grouped by CLI."""
235
+ label = "global" if scope == "global" else "local"
236
+ print(f"Checking AgentNotes {label} installation:")
237
+ print("")
238
+
239
+ from ...cli_backend import load_registry
240
+ registry = load_registry()
241
+ for backend in registry.all():
242
+ if not backend.supports("agents"):
243
+ continue
244
+ cli = backend.name
245
+ cli_name = backend.label
246
+ base = _cli_base_dir(backend, scope)
247
+ print(f" {cli_name} ({base})")
248
+
249
+ # Agents
250
+ installed, expected = _count_agents(backend, scope)
251
+ _print_status("agents", installed, expected)
252
+
253
+ # Skills
254
+ installed, expected = _count_skills(backend, scope)
255
+ _print_status("skills", installed, expected)
256
+
257
+ # Config
258
+ from ...config import ok, warn
259
+ all_ok, desc, missing = _check_config(backend, scope)
260
+ if all_ok:
261
+ ok(f"config ({desc})", indent=4)
262
+ elif desc == "not installed":
263
+ warn("config (not installed)", indent=4)
264
+ else:
265
+ missing_str = ", ".join(missing)
266
+ warn(f"config ({desc}) — missing: {missing_str}", indent=4)
267
+
268
+ # Rules (only for backends that support rules)
269
+ if backend.supports("rules"):
270
+ installed, expected = _count_rules(backend, scope)
271
+ _print_status("rules", installed, expected)
272
+
273
+ # Scripts (global only, not per-CLI)
274
+ if scope == "global":
275
+ from ...config import BIN_HOME
276
+ installed, expected = _count_scripts()
277
+ _print_status(f"scripts ({BIN_HOME})", installed, expected)
278
+
279
+
280
+ def print_issues(issues: List[Issue]) -> bool:
281
+ """Print found issues. Returns True if no issues."""
282
+ from ...config import Color
283
+
284
+ if not issues:
285
+ print("")
286
+ print(f"{Color.GREEN}No issues found.{Color.NC}")
287
+ return True
288
+
289
+ # Check if fully not installed
290
+ non_build_issues = [i for i in issues if i.type != "build_stale"]
291
+ if non_build_issues and all(i.type == "missing_group" for i in non_build_issues):
292
+ print(f"\nNot installed. Run '{Color.CYAN}agent-notes install{Color.NC}' to set up.")
293
+ return False
294
+
295
+ print("")
296
+
297
+ # Group broken symlinks by directory for cleaner output
298
+ broken_by_dir: Dict[str, int] = {}
299
+ other_issues: List[Issue] = []
300
+ for iss in issues:
301
+ if iss.type == "broken":
302
+ parent = str(Path(iss.file).parent)
303
+ broken_by_dir[parent] = broken_by_dir.get(parent, 0) + 1
304
+ elif iss.type == "missing_group":
305
+ continue # Already shown in summary
306
+ else:
307
+ other_issues.append(iss)
308
+
309
+ display_count = len(broken_by_dir) + len(other_issues)
310
+ print(f"{Color.YELLOW}Warning: {display_count} issue(s) found{Color.NC}")
311
+ print("")
312
+
313
+ # Print grouped broken symlinks
314
+ for dir_path, count in broken_by_dir.items():
315
+ print(f" {Color.RED}✗ Broken symlinks: {Color.NC}{dir_path}/ ({count} broken)")
316
+ print(f" Fix: run '{Color.CYAN}agent-notes install{Color.NC}' to recreate")
317
+ print("")
318
+
319
+ # Print other issues
320
+ for iss in other_issues:
321
+ if iss.type == "stale":
322
+ print(f" {Color.RED}✗ Stale: {Color.NC}{iss.file}")
323
+ print(f" {iss.message}")
324
+ print(f" Fix: run '{Color.CYAN}agent-notes doctor --fix{Color.NC}' to remove")
325
+ elif iss.type == "shadowed":
326
+ print(f" {Color.YELLOW}✗ Shadowed: {Color.NC}{iss.file}")
327
+ print(f" {iss.message}")
328
+ print(f" Fix: run '{Color.CYAN}agent-notes doctor --fix{Color.NC}' to replace with symlink")
329
+ elif iss.type == "missing":
330
+ print(f" {Color.YELLOW}✗ Missing: {Color.NC}{iss.file}")
331
+ print(f" {iss.message}")
332
+ print(f" Fix: run '{Color.CYAN}agent-notes doctor --fix{Color.NC}' or '{Color.CYAN}agent-notes install{Color.NC}'")
333
+ elif iss.type == "drift":
334
+ print(f" {Color.CYAN}✗ Content drift: {Color.NC}{iss.file}")
335
+ print(f" {iss.message}")
336
+ elif iss.type == "build_stale":
337
+ print(f" {Color.YELLOW}✗ Build stale: {Color.NC}{iss.file}")
338
+ print(f" {iss.message}")
339
+ print(f" Fix: run '{Color.CYAN}agent-notes build{Color.NC}'")
340
+ else:
341
+ continue
342
+
343
+ print("")
344
+
345
+ print(f"Run '{Color.CYAN}agent-notes doctor --fix{Color.NC}' to resolve these issues.")
346
+ return False