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.
- agent_notes/VERSION +1 -0
- agent_notes/__init__.py +1 -0
- agent_notes/__main__.py +4 -0
- agent_notes/cli.py +348 -0
- agent_notes/commands/__init__.py +27 -0
- agent_notes/commands/_install_helpers.py +262 -0
- agent_notes/commands/build.py +170 -0
- agent_notes/commands/doctor.py +112 -0
- agent_notes/commands/info.py +95 -0
- agent_notes/commands/install.py +99 -0
- agent_notes/commands/list.py +169 -0
- agent_notes/commands/memory.py +430 -0
- agent_notes/commands/regenerate.py +152 -0
- agent_notes/commands/set_role.py +143 -0
- agent_notes/commands/uninstall.py +26 -0
- agent_notes/commands/update.py +169 -0
- agent_notes/commands/validate.py +199 -0
- agent_notes/commands/wizard.py +720 -0
- agent_notes/config.py +154 -0
- agent_notes/data/agents/agents.yaml +352 -0
- agent_notes/data/agents/analyst.md +45 -0
- agent_notes/data/agents/api-reviewer.md +47 -0
- agent_notes/data/agents/architect.md +46 -0
- agent_notes/data/agents/coder.md +28 -0
- agent_notes/data/agents/database-specialist.md +45 -0
- agent_notes/data/agents/debugger.md +47 -0
- agent_notes/data/agents/devil.md +47 -0
- agent_notes/data/agents/devops.md +38 -0
- agent_notes/data/agents/explorer.md +23 -0
- agent_notes/data/agents/integrations.md +44 -0
- agent_notes/data/agents/lead.md +216 -0
- agent_notes/data/agents/performance-profiler.md +44 -0
- agent_notes/data/agents/refactorer.md +48 -0
- agent_notes/data/agents/reviewer.md +44 -0
- agent_notes/data/agents/security-auditor.md +44 -0
- agent_notes/data/agents/system-auditor.md +38 -0
- agent_notes/data/agents/tech-writer.md +32 -0
- agent_notes/data/agents/test-runner.md +36 -0
- agent_notes/data/agents/test-writer.md +39 -0
- agent_notes/data/cli/claude.yaml +25 -0
- agent_notes/data/cli/copilot.yaml +18 -0
- agent_notes/data/cli/opencode.yaml +22 -0
- agent_notes/data/commands/brainstorm.md +8 -0
- agent_notes/data/commands/debug.md +9 -0
- agent_notes/data/commands/review.md +10 -0
- agent_notes/data/global-claude.md +290 -0
- agent_notes/data/global-copilot.md +27 -0
- agent_notes/data/global-opencode.md +40 -0
- agent_notes/data/hooks/session-context.md.tpl +19 -0
- agent_notes/data/models/claude-haiku-4-5.yaml +15 -0
- agent_notes/data/models/claude-opus-4-1.yaml +16 -0
- agent_notes/data/models/claude-opus-4-5.yaml +16 -0
- agent_notes/data/models/claude-opus-4-6.yaml +16 -0
- agent_notes/data/models/claude-opus-4-7.yaml +15 -0
- agent_notes/data/models/claude-sonnet-4-5.yaml +16 -0
- agent_notes/data/models/claude-sonnet-4-6.yaml +15 -0
- agent_notes/data/models/claude-sonnet-4.yaml +16 -0
- agent_notes/data/pricing.yaml +33 -0
- agent_notes/data/roles/orchestrator.yaml +5 -0
- agent_notes/data/roles/reasoner.yaml +5 -0
- agent_notes/data/roles/scout.yaml +5 -0
- agent_notes/data/roles/worker.yaml +5 -0
- agent_notes/data/rules/code-quality.md +9 -0
- agent_notes/data/rules/safety.md +10 -0
- agent_notes/data/scripts/cost-report +211 -0
- agent_notes/data/skills/brainstorming/SKILL.md +57 -0
- agent_notes/data/skills/code-review/SKILL.md +64 -0
- agent_notes/data/skills/debugging-protocol/SKILL.md +51 -0
- agent_notes/data/skills/docker-compose/SKILL.md +318 -0
- agent_notes/data/skills/docker-compose-advanced/SKILL.md +575 -0
- agent_notes/data/skills/docker-dockerfile/SKILL.md +385 -0
- agent_notes/data/skills/docker-dockerfile-languages/SKILL.md +293 -0
- agent_notes/data/skills/git/SKILL.md +87 -0
- agent_notes/data/skills/rails-active-storage/SKILL.md +321 -0
- agent_notes/data/skills/rails-broadcasting/SKILL.md +374 -0
- agent_notes/data/skills/rails-concerns/SKILL.md +806 -0
- agent_notes/data/skills/rails-controllers/SKILL.md +510 -0
- agent_notes/data/skills/rails-controllers-advanced/SKILL.md +441 -0
- agent_notes/data/skills/rails-helpers/SKILL.md +677 -0
- agent_notes/data/skills/rails-initializers/SKILL.md +79 -0
- agent_notes/data/skills/rails-javascript/SKILL.md +567 -0
- agent_notes/data/skills/rails-jobs/SKILL.md +700 -0
- agent_notes/data/skills/rails-kamal/SKILL.md +483 -0
- agent_notes/data/skills/rails-lib/SKILL.md +101 -0
- agent_notes/data/skills/rails-mailers/SKILL.md +321 -0
- agent_notes/data/skills/rails-migrations/SKILL.md +268 -0
- agent_notes/data/skills/rails-models/SKILL.md +459 -0
- agent_notes/data/skills/rails-models-advanced/SKILL.md +398 -0
- agent_notes/data/skills/rails-routes/SKILL.md +804 -0
- agent_notes/data/skills/rails-style/SKILL.md +538 -0
- agent_notes/data/skills/rails-testing-controllers/SKILL.md +343 -0
- agent_notes/data/skills/rails-testing-models/SKILL.md +296 -0
- agent_notes/data/skills/rails-testing-system/SKILL.md +375 -0
- agent_notes/data/skills/rails-validations/SKILL.md +108 -0
- agent_notes/data/skills/rails-view-components/SKILL.md +511 -0
- agent_notes/data/skills/rails-view-components-advanced/SKILL.md +376 -0
- agent_notes/data/skills/rails-views/SKILL.md +413 -0
- agent_notes/data/skills/rails-views-advanced/SKILL.md +450 -0
- agent_notes/data/skills/refactoring-protocol/SKILL.md +64 -0
- agent_notes/data/skills/tdd/SKILL.md +57 -0
- agent_notes/data/templates/__init__.py +1 -0
- agent_notes/data/templates/__pycache__/__init__.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__init__.py +1 -0
- agent_notes/data/templates/frontmatter/__pycache__/__init__.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/claude.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/cursor.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/__pycache__/opencode.cpython-314.pyc +0 -0
- agent_notes/data/templates/frontmatter/claude.py +44 -0
- agent_notes/data/templates/frontmatter/opencode.py +104 -0
- agent_notes/doctor_checks.py +189 -0
- agent_notes/domain/__init__.py +17 -0
- agent_notes/domain/agent.py +34 -0
- agent_notes/domain/cli_backend.py +40 -0
- agent_notes/domain/diagnostics.py +29 -0
- agent_notes/domain/diff.py +44 -0
- agent_notes/domain/model.py +27 -0
- agent_notes/domain/role.py +13 -0
- agent_notes/domain/rule.py +13 -0
- agent_notes/domain/skill.py +15 -0
- agent_notes/domain/state.py +46 -0
- agent_notes/install_state.py +11 -0
- agent_notes/registries/__init__.py +16 -0
- agent_notes/registries/_base.py +46 -0
- agent_notes/registries/agent_registry.py +107 -0
- agent_notes/registries/cli_registry.py +89 -0
- agent_notes/registries/model_registry.py +85 -0
- agent_notes/registries/role_registry.py +64 -0
- agent_notes/registries/rule_registry.py +80 -0
- agent_notes/registries/skill_registry.py +141 -0
- agent_notes/services/__init__.py +8 -0
- agent_notes/services/diagnostics/__init__.py +47 -0
- agent_notes/services/diagnostics/_checks.py +272 -0
- agent_notes/services/diagnostics/_display.py +346 -0
- agent_notes/services/diagnostics/_fix.py +169 -0
- agent_notes/services/diff.py +349 -0
- agent_notes/services/fs.py +195 -0
- agent_notes/services/install_state_builder.py +210 -0
- agent_notes/services/installer.py +293 -0
- agent_notes/services/memory_backend.py +155 -0
- agent_notes/services/rendering.py +329 -0
- agent_notes/services/session_context.py +23 -0
- agent_notes/services/settings_writer.py +79 -0
- agent_notes/services/state_store.py +249 -0
- agent_notes/services/ui.py +419 -0
- agent_notes/services/user_config.py +62 -0
- agent_notes/services/validation.py +67 -0
- agent_notes/state.py +21 -0
- agent_notes-2.0.4.dist-info/METADATA +14 -0
- agent_notes-2.0.4.dist-info/RECORD +162 -0
- agent_notes-2.0.4.dist-info/WHEEL +5 -0
- agent_notes-2.0.4.dist-info/entry_points.txt +2 -0
- agent_notes-2.0.4.dist-info/licenses/LICENSE +21 -0
- agent_notes-2.0.4.dist-info/top_level.txt +2 -0
- tests/conftest.py +20 -0
- tests/functional/__init__.py +0 -0
- tests/functional/test_build_commands.py +88 -0
- tests/functional/test_registries.py +128 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_build_output.py +129 -0
- tests/plugins/__init__.py +0 -0
- tests/plugins/test_agents.py +93 -0
- 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)
|