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,195 @@
1
+ """Filesystem primitives."""
2
+
3
+ import shutil
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ # Local color/print helpers to avoid circular import
10
+ class _Color:
11
+ RED = "\033[0;31m"
12
+ GREEN = "\033[0;32m"
13
+ YELLOW = "\033[0;33m"
14
+ BLUE = "\033[0;34m"
15
+ MAGENTA = "\033[0;35m"
16
+ CYAN = "\033[0;36m"
17
+ WHITE = "\033[0;37m"
18
+ BOLD = "\033[1m"
19
+ DIM = "\033[2m"
20
+ NC = "\033[0m" # No color
21
+
22
+ @staticmethod
23
+ def disable():
24
+ """Disable colors (for non-TTY output)."""
25
+ for attr in ("RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE", "BOLD", "DIM", "NC"):
26
+ setattr(_Color, attr, "")
27
+
28
+
29
+ # Disable colors if not a TTY
30
+ if not sys.stdout.isatty():
31
+ _Color.disable()
32
+
33
+ # Set to True to suppress per-file LINKED/COPIED/SKIP output (e.g. during wizard)
34
+ silent_file_ops = False
35
+
36
+
37
+ def _info(msg: str) -> None:
38
+ if not silent_file_ops:
39
+ print(f" {_Color.GREEN}✓{_Color.NC} {msg}")
40
+
41
+
42
+ def _skipped(path: str, reason: str = "not a symlink — remove manually") -> None:
43
+ if not silent_file_ops:
44
+ print(f" {_Color.YELLOW}SKIP{_Color.NC} {path} ({reason})")
45
+
46
+
47
+ def _linked(path: str) -> None:
48
+ if not silent_file_ops:
49
+ print(f" {_Color.GREEN}LINKED{_Color.NC} {path}")
50
+
51
+
52
+ def _removed(path: str) -> None:
53
+ print(f" {_Color.GREEN}REMOVED{_Color.NC} {path}")
54
+
55
+
56
+ def files_identical(a: Path, b: Path) -> bool:
57
+ """Check if two files or directories have identical content."""
58
+ try:
59
+ if a.is_dir() and b.is_dir():
60
+ # Compare directory contents recursively
61
+ a_files = {f.relative_to(a): f.read_bytes() for f in a.rglob("*") if f.is_file()}
62
+ b_files = {f.relative_to(b): f.read_bytes() for f in b.rglob("*") if f.is_file()}
63
+ return a_files == b_files
64
+ elif a.is_file() and b.is_file():
65
+ return a.read_bytes() == b.read_bytes()
66
+ return False
67
+ except OSError:
68
+ return False
69
+
70
+
71
+ def handle_existing(src: Path, dst: Path) -> bool:
72
+ """Handle an existing non-symlink destination file.
73
+
74
+ Returns True if install should proceed, False to skip.
75
+ """
76
+ if files_identical(src, dst):
77
+ _skipped(str(dst), "exists, identical content")
78
+ return False
79
+
80
+ print(f"\n {_Color.YELLOW}CONFLICT{_Color.NC} {dst}")
81
+ print(f" File exists and differs from source.")
82
+ response = input(" (b)ackup and replace, (s)kip? [b/s] ").strip().lower()
83
+
84
+ if response == 'b':
85
+ backup_path = Path(str(dst) + ".bak")
86
+ if dst.is_dir():
87
+ if backup_path.exists():
88
+ shutil.rmtree(backup_path)
89
+ shutil.copytree(dst, backup_path)
90
+ shutil.rmtree(dst)
91
+ else:
92
+ if backup_path.exists():
93
+ backup_path.unlink()
94
+ dst.rename(backup_path)
95
+ print(f" {_Color.CYAN}BACKUP{_Color.NC} {backup_path}")
96
+ return True
97
+ else:
98
+ _skipped(str(dst), "user skipped")
99
+ return False
100
+
101
+
102
+ def place_file(src: Path, dst: Path, copy_mode: bool = False) -> None:
103
+ """Place file as symlink or copy, handling existing files."""
104
+ dst.parent.mkdir(parents=True, exist_ok=True)
105
+
106
+ if copy_mode:
107
+ if dst.exists() and not dst.is_symlink():
108
+ if not handle_existing(src, dst):
109
+ return
110
+ if dst.is_symlink():
111
+ dst.unlink()
112
+ if src.is_dir():
113
+ shutil.copytree(src, dst, dirs_exist_ok=True)
114
+ else:
115
+ shutil.copy2(src, dst)
116
+ _info(f"COPIED {dst}")
117
+ else:
118
+ if dst.exists() and not dst.is_symlink():
119
+ if not handle_existing(src, dst):
120
+ return
121
+ if dst.is_symlink():
122
+ dst.unlink()
123
+ dst.symlink_to(src)
124
+ _linked(str(dst))
125
+
126
+
127
+ def place_dir_contents(src_dir: Path, dst_dir: Path, pattern: str, copy_mode: bool = False) -> None:
128
+ """Place all files matching pattern from src_dir to dst_dir."""
129
+ dst_dir.mkdir(parents=True, exist_ok=True)
130
+ for src_file in src_dir.glob(pattern):
131
+ if src_file.exists():
132
+ dst_file = dst_dir / src_file.name
133
+ place_file(src_file, dst_file, copy_mode)
134
+
135
+
136
+ def remove_symlink(target: Path) -> None:
137
+ """Remove symlink if it exists, skip non-symlinks."""
138
+ if target.is_symlink():
139
+ target.unlink()
140
+ _removed(str(target))
141
+ elif target.exists():
142
+ _skipped(str(target))
143
+
144
+
145
+ def remove_all_symlinks_in_dir(dir_path: Path) -> None:
146
+ """Remove all symlinks in a directory (files and dirs)."""
147
+ if not dir_path.exists():
148
+ return
149
+ for item in dir_path.iterdir():
150
+ if item.is_symlink():
151
+ item.unlink()
152
+ _removed(str(item))
153
+ elif item.exists():
154
+ _skipped(str(item))
155
+
156
+
157
+ def remove_dir_if_empty(dir_path: Path) -> None:
158
+ """Remove directory if it exists and is empty."""
159
+ try:
160
+ if dir_path.exists() and not any(dir_path.iterdir()):
161
+ dir_path.rmdir()
162
+ except OSError:
163
+ pass
164
+
165
+
166
+ def resolve_symlink(path: Path) -> Optional[Path]:
167
+ """Get symlink target if path is a symlink."""
168
+ if path.is_symlink():
169
+ try:
170
+ return path.readlink()
171
+ except OSError:
172
+ return None
173
+ return None
174
+
175
+
176
+ def symlink_target_exists(path: Path) -> bool:
177
+ """Check if symlink target exists."""
178
+ if not path.is_symlink():
179
+ return False
180
+ try:
181
+ target = path.readlink()
182
+ # Handle relative targets
183
+ if not target.is_absolute():
184
+ target = path.parent / target
185
+ return target.exists()
186
+ except OSError:
187
+ return False
188
+
189
+
190
+ def files_differ(file1: Path, file2: Path) -> bool:
191
+ """Compare file contents."""
192
+ try:
193
+ return file1.read_bytes() != file2.read_bytes()
194
+ except (OSError, FileNotFoundError):
195
+ return True
@@ -0,0 +1,210 @@
1
+ """Build install state during install/uninstall flows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from ..domain.state import State, ScopeState, BackendState, InstalledItem
10
+ from ..services.state_store import load_state, now_iso, sha256_of, set_scope
11
+
12
+
13
+ def git_head_short(repo_root: Path) -> str:
14
+ """Return short git HEAD sha, or '' on error."""
15
+ try:
16
+ result = subprocess.run(
17
+ ["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"],
18
+ capture_output=True,
19
+ text=True,
20
+ timeout=10
21
+ )
22
+ if result.returncode == 0:
23
+ return result.stdout.strip()
24
+ except Exception:
25
+ pass
26
+ return ""
27
+
28
+
29
+ def build_install_state(
30
+ mode: str,
31
+ scope: str, # "global" or "local"
32
+ repo_root: Path,
33
+ project_path: Optional[Path] = None, # required when scope == "local"
34
+ role_models: Optional[dict[str, dict[str, str]]] = None,
35
+ # ^^ {cli_name: {role_name: model_id}}, optional — empty dict fine for now
36
+ selected_clis: Optional[set[str]] = None,
37
+ # ^^ If given, only these backends are recorded as installed. None = all
38
+ # backends with shipped content (legacy behavior, used by the plain
39
+ # `agent-notes install` non-wizard path).
40
+ ) -> State:
41
+ """Build a complete State snapshot.
42
+
43
+ - Loads current state.json if present, so we can ADD the new scope without
44
+ clobbering other installs. E.g. installing globally doesn't erase existing
45
+ local installs.
46
+ - Constructs a ScopeState for the requested (scope, project_path).
47
+ - Scans dist/ for each backend; fills BackendState.installed.
48
+ - BackendState.role_models comes from the `role_models` arg if provided,
49
+ else empty dict (will be populated by wizard in Phase E).
50
+ - Returns the updated full State. Caller must call save().
51
+ """
52
+ # Load existing state or create fresh one
53
+ current_state = load_state()
54
+ if current_state is None:
55
+ state = State()
56
+ else:
57
+ state = current_state
58
+
59
+ # Update top-level metadata
60
+ state.source_path = str(repo_root.resolve())
61
+ state.source_commit = git_head_short(repo_root)
62
+
63
+ # Import here to avoid circular import
64
+ from ..registries.cli_registry import load_registry
65
+ from ..config import PKG_DIR, DIST_SKILLS_DIR, DIST_RULES_DIR
66
+
67
+ # Build scope-specific install
68
+ try:
69
+ registry = load_registry()
70
+ except Exception:
71
+ # Fallback to empty registry if CLI backend loading fails
72
+ from ..registries.cli_registry import CLIRegistry
73
+ registry = CLIRegistry([])
74
+
75
+ timestamp = now_iso()
76
+
77
+ # Build CLIs dict for this scope
78
+ clis = {}
79
+
80
+ for backend in registry.all():
81
+ # Skip backends the user opted out of during wizard selection.
82
+ if selected_clis is not None and backend.name not in selected_clis:
83
+ continue
84
+ backend_state = BackendState()
85
+ backend_has_content = False
86
+
87
+ # Set role_models from arg (empty dict for now)
88
+ if role_models and backend.name in role_models:
89
+ backend_state.role_models = role_models[backend.name].copy()
90
+
91
+ # Check agents
92
+ if backend.supports("agents"):
93
+ agents_dir = PKG_DIR / "dist" / backend.name / "agents"
94
+ if agents_dir.exists():
95
+ for agent_file in agents_dir.glob("*.md"):
96
+ try:
97
+ sha = sha256_of(agent_file)
98
+ target = _get_target_path(agent_file, backend, "agents", scope, project_path)
99
+ if "agents" not in backend_state.installed:
100
+ backend_state.installed["agents"] = {}
101
+ backend_state.installed["agents"][agent_file.name] = InstalledItem(
102
+ sha=sha, target=str(target), mode=mode
103
+ )
104
+ backend_has_content = True
105
+ except Exception:
106
+ # Skip files we can't process
107
+ continue
108
+
109
+ # Check skills
110
+ if backend.supports("skills"):
111
+ # Skills are in dist/skills/, not dist/<backend>/skills/
112
+ skills_dir = DIST_SKILLS_DIR
113
+ if skills_dir.exists():
114
+ for skill_dir in skills_dir.iterdir():
115
+ if skill_dir.is_dir():
116
+ try:
117
+ # Use SKILL.md as the file to hash for consistency
118
+ skill_md = skill_dir / "SKILL.md"
119
+ if skill_md.exists():
120
+ sha = sha256_of(skill_md)
121
+ else:
122
+ # If no SKILL.md, use empty string sha
123
+ sha = ""
124
+ target = _get_target_path(skill_dir, backend, "skills", scope, project_path)
125
+ if "skills" not in backend_state.installed:
126
+ backend_state.installed["skills"] = {}
127
+ backend_state.installed["skills"][skill_dir.name] = InstalledItem(
128
+ sha=sha, target=str(target), mode=mode
129
+ )
130
+ backend_has_content = True
131
+ except Exception:
132
+ continue
133
+
134
+ # Check rules
135
+ if backend.supports("rules"):
136
+ # Rules come from dist/rules/
137
+ rules_dir = DIST_RULES_DIR
138
+ if rules_dir.exists():
139
+ for rule_file in rules_dir.glob("*.md"):
140
+ try:
141
+ sha = sha256_of(rule_file)
142
+ target = _get_target_path(rule_file, backend, "rules", scope, project_path)
143
+ if "rules" not in backend_state.installed:
144
+ backend_state.installed["rules"] = {}
145
+ backend_state.installed["rules"][rule_file.name] = InstalledItem(
146
+ sha=sha, target=str(target), mode=mode
147
+ )
148
+ backend_has_content = True
149
+ except Exception:
150
+ continue
151
+
152
+ # Check config files
153
+ config_file = PKG_DIR / "dist" / backend.name / backend.layout.get("config", "")
154
+ if config_file.exists():
155
+ try:
156
+ sha = sha256_of(config_file)
157
+ target = _get_target_path(config_file, backend, "config", scope, project_path)
158
+ if "config" not in backend_state.installed:
159
+ backend_state.installed["config"] = {}
160
+ backend_state.installed["config"][config_file.name] = InstalledItem(
161
+ sha=sha, target=str(target), mode=mode
162
+ )
163
+ backend_has_content = True
164
+ except Exception:
165
+ pass
166
+
167
+ # Check commands (future enhancement)
168
+ # Check settings (future enhancement)
169
+
170
+ if backend_has_content:
171
+ clis[backend.name] = backend_state
172
+
173
+ # Create the new scope state
174
+ new_scope_state = ScopeState(
175
+ installed_at=timestamp,
176
+ updated_at=timestamp,
177
+ mode=mode,
178
+ clis=clis,
179
+ )
180
+
181
+ # Set the appropriate scope
182
+ set_scope(state, scope, new_scope_state, project_path)
183
+
184
+ return state
185
+
186
+
187
+ def _get_target_path(source_path: Path, backend, component_type: str, scope: str, project_path: Optional[Path] = None) -> Path:
188
+ """Get the target installation path for a source file/directory."""
189
+ if scope == "global":
190
+ base_dir = backend.global_home
191
+ else:
192
+ # Local scope - relative to the provided project path or current working directory
193
+ if project_path:
194
+ base_dir = project_path / backend.local_dir
195
+ else:
196
+ base_dir = Path.cwd() / backend.local_dir
197
+
198
+ if component_type == "config":
199
+ # Config files go to the root of the backend directory
200
+ return base_dir / source_path.name
201
+ elif component_type in ["agents", "rules", "skills"]:
202
+ # These go into subdirectories according to backend layout
203
+ if component_type in backend.layout:
204
+ subdir = backend.layout[component_type].rstrip("/")
205
+ return base_dir / subdir / source_path.name
206
+ else:
207
+ # Fallback
208
+ return base_dir / component_type / source_path.name
209
+ else:
210
+ return base_dir / source_path.name
@@ -0,0 +1,293 @@
1
+ """Generic install/uninstall engine driven by the CLI backend registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from ..domain.cli_backend import CLIBackend
9
+ from ..registries.cli_registry import CLIRegistry, load_registry
10
+ from .. import config
11
+ from .fs import (
12
+ place_file, place_dir_contents,
13
+ remove_symlink, remove_all_symlinks_in_dir, remove_dir_if_empty,
14
+ )
15
+
16
+ # Re-import the atomic helpers from install (they stay in install.py):
17
+ # We intentionally avoid circular import by lazy-importing inside functions.
18
+
19
+ COMPONENT_TYPES = ("agents", "skills", "rules", "commands", "config")
20
+ # Note: "scripts" is handled separately, not per-backend.
21
+
22
+
23
+ def dist_source_for(backend: CLIBackend, component: str) -> Optional[Path]:
24
+ """Return dist source path for a (backend, component) pair, or None if N/A.
25
+
26
+ For "config" returns the directory containing the config file (the file
27
+ itself is named per backend.layout["config"]).
28
+ """
29
+ if component == "agents":
30
+ p = config.DIST_DIR / backend.name / "agents"
31
+ return p if p.exists() else None
32
+ if component == "config":
33
+ # The config FILE lives directly under DIST_DIR / backend.name / <filename>
34
+ # Caller resolves the filename via backend.layout["config"].
35
+ p = config.DIST_DIR / backend.name
36
+ return p if p.exists() else None
37
+ if component == "rules":
38
+ dist_rules_dir = config.DIST_RULES_DIR
39
+ return dist_rules_dir if dist_rules_dir.exists() else None
40
+ if component == "skills":
41
+ dist_skills_dir = config.DIST_SKILLS_DIR
42
+ return dist_skills_dir if dist_skills_dir.exists() else None
43
+ if component == "commands":
44
+ p = config.DIST_DIR / backend.name / "commands"
45
+ return p if p.exists() else None
46
+ return None
47
+
48
+
49
+ def target_dir_for(backend: CLIBackend, component: str, scope: str) -> Optional[Path]:
50
+ """Return destination directory for a (backend, component, scope).
51
+
52
+ scope: "global" or "local"
53
+ Returns None if backend doesn't support this component.
54
+ """
55
+ # Special case for config: check layout instead of features
56
+ if component == "config":
57
+ if not backend.layout.get("config"):
58
+ return None
59
+ else:
60
+ if not backend.supports(component):
61
+ return None
62
+
63
+ layout_value = backend.layout.get(component)
64
+ if not layout_value:
65
+ return None
66
+ home = backend.global_home if scope == "global" else Path(backend.local_dir)
67
+ if component == "config":
68
+ # config layout is a filename like "CLAUDE.md"; target is the home dir
69
+ return home
70
+ # All others are subdirectories
71
+ return home / layout_value.rstrip("/")
72
+
73
+
74
+ def config_filename_for(backend: CLIBackend) -> Optional[str]:
75
+ """Return e.g. 'CLAUDE.md', 'AGENTS.md', 'copilot-instructions.md'."""
76
+ return backend.layout.get("config")
77
+
78
+
79
+ def install_component_for_backend(
80
+ backend: CLIBackend,
81
+ component: str,
82
+ scope: str,
83
+ copy_mode: bool,
84
+ ) -> None:
85
+ """Install one component for one backend. No-op if unsupported or no source."""
86
+ src = dist_source_for(backend, component)
87
+ if src is None:
88
+ return
89
+ dst = target_dir_for(backend, component, scope)
90
+ if dst is None:
91
+ return
92
+
93
+ if component == "config":
94
+ filename = config_filename_for(backend)
95
+ if not filename:
96
+ return
97
+ src_file = src / filename
98
+ if not src_file.exists():
99
+ return
100
+ print(f"Installing {backend.label} config to {dst} ...")
101
+ place_file(src_file, dst / filename, copy_mode)
102
+ elif component in ("agents", "rules", "commands"):
103
+ # Directory of *.md files — flat copy
104
+ # Only print if there are files to install
105
+ files = list(src.glob("*.md"))
106
+ if not files:
107
+ return
108
+ print(f"Installing {backend.label} {component} to {dst} ...")
109
+ place_dir_contents(src, dst, "*.md", copy_mode)
110
+ elif component == "skills":
111
+ # Each top-level subdir of src is a skill — install each as a directory
112
+ # Only print if there are skills to install
113
+ skill_dirs = [d for d in src.iterdir() if d.is_dir()]
114
+ if not skill_dirs:
115
+ return
116
+ print(f"Installing {backend.label} skills to {dst} ...")
117
+ for skill_dir in sorted(skill_dirs):
118
+ place_file(skill_dir, dst / skill_dir.name, copy_mode)
119
+
120
+
121
+ def uninstall_component_for_backend(
122
+ backend: CLIBackend,
123
+ component: str,
124
+ scope: str
125
+ ) -> None:
126
+ """Uninstall one component for one backend."""
127
+ dst = target_dir_for(backend, component, scope)
128
+ if dst is None or not dst.exists():
129
+ return
130
+
131
+ if component == "config":
132
+ filename = config_filename_for(backend)
133
+ if filename:
134
+ config_file = dst / filename
135
+ print(f"Removing {backend.label} config from {dst} ...")
136
+ remove_symlink(config_file)
137
+ else:
138
+ # Only print if directory exists and has content
139
+ if any(dst.iterdir()):
140
+ print(f"Removing {backend.label} {component} from {dst} ...")
141
+ remove_all_symlinks_in_dir(dst)
142
+ remove_dir_if_empty(dst)
143
+
144
+
145
+ def install_all(scope: str, copy_mode: bool, registry: Optional[CLIRegistry] = None) -> None:
146
+ """Top-level: install scripts (global only) + every (backend, component) combo."""
147
+ if registry is None:
148
+ registry = load_registry()
149
+
150
+ if scope == "global":
151
+ install_scripts_global()
152
+
153
+ for backend in registry.all():
154
+ for component in COMPONENT_TYPES:
155
+ install_component_for_backend(backend, component, scope, copy_mode)
156
+
157
+ # Universal skills mirror: keep existing behavior — also install skills
158
+ # to ~/.agents/skills/ for any backend that supports skills, only for global scope.
159
+ if scope == "global":
160
+ _install_universal_skills(copy_mode, registry)
161
+
162
+ # SessionStart hook for Claude Code only
163
+ try:
164
+ claude_backend = registry.get("claude")
165
+ _install_session_hook(claude_backend, scope)
166
+ except KeyError:
167
+ pass
168
+
169
+
170
+ def _install_universal_skills(copy_mode: bool, registry: CLIRegistry) -> None:
171
+ """Mirror skills to ~/.agents/skills/ for backwards compatibility."""
172
+
173
+ dist_skills_dir = config.DIST_SKILLS_DIR
174
+ if not dist_skills_dir.exists():
175
+ return
176
+
177
+ print(f"Installing universal skills ...")
178
+ target = config.AGENTS_HOME / "skills"
179
+ target.mkdir(parents=True, exist_ok=True)
180
+ skill_dirs = [d for d in dist_skills_dir.iterdir() if d.is_dir()]
181
+ if not skill_dirs:
182
+ return
183
+ print(f"Installing universal skills to {target} ...")
184
+ for skill_dir in sorted(skill_dirs):
185
+ place_file(skill_dir, target / skill_dir.name, copy_mode)
186
+
187
+
188
+ def uninstall_all(scope: str, registry: Optional[CLIRegistry] = None) -> None:
189
+ """Top-level uninstall."""
190
+ if registry is None:
191
+ registry = load_registry()
192
+
193
+ if scope == "global":
194
+ uninstall_scripts_global()
195
+
196
+ for backend in registry.all():
197
+ for component in COMPONENT_TYPES:
198
+ uninstall_component_for_backend(backend, component, scope)
199
+
200
+ if scope == "global":
201
+ _uninstall_universal_skills()
202
+
203
+ # Remove SessionStart hook for Claude Code only
204
+ try:
205
+ claude_backend = registry.get("claude")
206
+ _uninstall_session_hook(claude_backend, scope)
207
+ except KeyError:
208
+ pass
209
+
210
+
211
+ def _uninstall_universal_skills() -> None:
212
+
213
+ target = config.AGENTS_HOME / "skills"
214
+ if target.exists() and any(target.iterdir()):
215
+ print(f"Removing universal skills from {target} ...")
216
+ remove_all_symlinks_in_dir(target)
217
+ remove_dir_if_empty(target)
218
+
219
+
220
+ def _session_hook_paths(backend, scope: str):
221
+ """Return (settings_path, context_file, hook_command) for the given scope."""
222
+ home = backend.global_home if scope == "global" else Path(backend.local_dir)
223
+ if scope == "global":
224
+ settings_path = home / "settings.json"
225
+ context_file = home / "agent-notes-context.md"
226
+ hook_command = "cat ~/.claude/agent-notes-context.md 2>/dev/null || true"
227
+ else:
228
+ settings_path = home / "settings.json"
229
+ context_file = home / "agent-notes-context.md"
230
+ hook_command = "cat .claude/agent-notes-context.md 2>/dev/null || true"
231
+ return settings_path, context_file, hook_command
232
+
233
+
234
+ def _install_session_hook(backend, scope: str) -> None:
235
+ """Install the SessionStart hook and write the context file for Claude Code."""
236
+ from .settings_writer import install_hook
237
+ from .session_context import write_context
238
+ from .. import config
239
+
240
+ settings_path, context_file, hook_command = _session_hook_paths(backend, scope)
241
+
242
+ # Gather installed agent names from dist directory
243
+ agents: list[str] = []
244
+ agents_dist = config.DIST_DIR / backend.name / backend.layout.get("agents", "agents")
245
+ if agents_dist.exists():
246
+ agents = sorted(p.stem for p in agents_dist.glob("*.md"))
247
+
248
+ version = config.get_version()
249
+ print(f"Installing Claude Code SessionStart hook ...")
250
+ write_context(context_file, agents, version)
251
+ install_hook(settings_path, "SessionStart", hook_command)
252
+
253
+
254
+ def _uninstall_session_hook(backend, scope: str) -> None:
255
+ """Remove the SessionStart hook and context file for Claude Code."""
256
+ from .settings_writer import remove_hook
257
+
258
+ settings_path, context_file, hook_command = _session_hook_paths(backend, scope)
259
+ print(f"Removing Claude Code SessionStart hook ...")
260
+ context_file.unlink(missing_ok=True)
261
+ remove_hook(settings_path, "SessionStart", hook_command)
262
+
263
+
264
+ def install_scripts_global() -> None:
265
+ """Install scripts to ~/.local/bin/."""
266
+ from .fs import place_file
267
+
268
+ dist_scripts_dir = config.DIST_DIR / "scripts"
269
+ bin_home = Path.home() / ".local" / "bin"
270
+
271
+ if not dist_scripts_dir.exists():
272
+ return
273
+ print(f"Installing scripts to {bin_home} ...")
274
+ bin_home.mkdir(parents=True, exist_ok=True)
275
+ for script in sorted(dist_scripts_dir.iterdir()):
276
+ if script.is_file():
277
+ place_file(script, bin_home / script.name)
278
+ (bin_home / script.name).chmod(0o755)
279
+
280
+
281
+ def uninstall_scripts_global() -> None:
282
+ """Uninstall scripts from ~/.local/bin/."""
283
+ from .fs import remove_symlink
284
+
285
+ dist_scripts_dir = config.DIST_DIR / "scripts"
286
+ bin_home = Path.home() / ".local" / "bin"
287
+
288
+ if not dist_scripts_dir.exists():
289
+ return
290
+ print(f"Removing scripts from {bin_home} ...")
291
+ for script in sorted(dist_scripts_dir.iterdir()):
292
+ if script.is_file():
293
+ remove_symlink(bin_home / script.name)