agentsmd 1.0.0__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 (44) hide show
  1. agentsmd/__init__.py +2 -0
  2. agentsmd/__main__.py +6 -0
  3. agentsmd/api_client.py +187 -0
  4. agentsmd/auth/__init__.py +12 -0
  5. agentsmd/auth/commands.py +40 -0
  6. agentsmd/cli.py +250 -0
  7. agentsmd/config.py +58 -0
  8. agentsmd/converter/__init__.py +12 -0
  9. agentsmd/converter/canonical.py +61 -0
  10. agentsmd/converter/detector.py +24 -0
  11. agentsmd/converter/migrate.py +87 -0
  12. agentsmd/converter/parsers/__init__.py +13 -0
  13. agentsmd/converter/parsers/agents_md.py +67 -0
  14. agentsmd/converter/parsers/base.py +31 -0
  15. agentsmd/converter/parsers/claude_md.py +139 -0
  16. agentsmd/converter/parsers/copilot.py +185 -0
  17. agentsmd/converter/parsers/cursor.py +155 -0
  18. agentsmd/converter/parsers/markdown_utils.py +73 -0
  19. agentsmd/converter/parsers/windsurf.py +148 -0
  20. agentsmd/converter/reconcile.py +195 -0
  21. agentsmd/converter/registry.py +73 -0
  22. agentsmd/converter/renderers/__init__.py +13 -0
  23. agentsmd/converter/renderers/agents_md.py +49 -0
  24. agentsmd/converter/renderers/base.py +25 -0
  25. agentsmd/converter/renderers/claude_md.py +63 -0
  26. agentsmd/converter/renderers/copilot.py +38 -0
  27. agentsmd/converter/renderers/cursor.py +56 -0
  28. agentsmd/converter/renderers/windsurf.py +45 -0
  29. agentsmd/init/__init__.py +24 -0
  30. agentsmd/init/commands.py +31 -0
  31. agentsmd/init/deriver.py +43 -0
  32. agentsmd/init/generator.py +203 -0
  33. agentsmd/init/interview.py +257 -0
  34. agentsmd/machine.py +49 -0
  35. agentsmd/sync/__init__.py +5 -0
  36. agentsmd/sync/commands.py +121 -0
  37. agentsmd/templates/agents_md.j2 +64 -0
  38. agentsmd/templates/claude_md.j2 +36 -0
  39. agentsmd/templates/cursorrules.j2 +34 -0
  40. agentsmd/templates/windsurfrules.j2 +34 -0
  41. agentsmd-1.0.0.dist-info/METADATA +21 -0
  42. agentsmd-1.0.0.dist-info/RECORD +44 -0
  43. agentsmd-1.0.0.dist-info/WHEEL +4 -0
  44. agentsmd-1.0.0.dist-info/entry_points.txt +3 -0
agentsmd/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """AgentsMD - Intelligent workspace scaffolding and file synchronization."""
2
+ __version__ = "1.0.0"
agentsmd/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for AgentsMD CLI."""
2
+ import sys
3
+ from agentsmd.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(app())
agentsmd/api_client.py ADDED
@@ -0,0 +1,187 @@
1
+ """Thin HTTP client for the AgentsMD backend API."""
2
+
3
+ import base64
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import httpx
9
+
10
+
11
+ class APIClient:
12
+ """HTTP client that talks to the AgentsMD backend.
13
+
14
+ All sync intelligence lives on the server. This client just
15
+ sends files and receives results.
16
+ """
17
+
18
+ def __init__(self, token: str, base_url: Optional[str] = None):
19
+ self.base_url = base_url or os.environ.get(
20
+ "AGENTSMD_API_URL", "https://api.agentsmd.com"
21
+ )
22
+ self._client = httpx.AsyncClient(
23
+ base_url=self.base_url,
24
+ headers={"Authorization": f"Bearer {token}"},
25
+ timeout=60.0,
26
+ )
27
+
28
+ async def validate_token(self) -> dict:
29
+ """Validate the API token against the backend."""
30
+ resp = await self._client.post("/api/v1/auth/validate-token")
31
+ resp.raise_for_status()
32
+ return resp.json()
33
+
34
+ async def sync(self, workspace_id: str, machine_id: str, workspace_path: str) -> dict:
35
+ """Send all local files to the backend for sync.
36
+
37
+ The backend handles hashing, diffing, vector clocks, and R2 storage.
38
+ Returns sync results including files to download.
39
+ """
40
+ files = _collect_files(workspace_path)
41
+
42
+ upload_files = []
43
+ for name, content in files.items():
44
+ upload_files.append(("files", (name, content, "application/octet-stream")))
45
+
46
+ resp = await self._client.post(
47
+ "/api/v1/sync",
48
+ data={"workspace_id": workspace_id, "machine_id": machine_id},
49
+ files=upload_files,
50
+ )
51
+ resp.raise_for_status()
52
+ return resp.json()
53
+
54
+ async def get_status(self, workspace_id: str) -> dict:
55
+ """Get workspace sync status from the backend."""
56
+ resp = await self._client.get(
57
+ "/api/v1/sync/status",
58
+ params={"workspace_id": workspace_id},
59
+ )
60
+ resp.raise_for_status()
61
+ return resp.json()
62
+
63
+
64
+ def _load_gitignore_patterns(workspace: Path) -> set[str]:
65
+ """Load patterns from .gitignore if it exists."""
66
+ gitignore = workspace / ".gitignore"
67
+ if not gitignore.exists():
68
+ return set()
69
+
70
+ patterns = set()
71
+ for line in gitignore.read_text(encoding="utf-8").splitlines():
72
+ line = line.strip()
73
+ if line and not line.startswith("#"):
74
+ patterns.add(line)
75
+ return patterns
76
+
77
+
78
+ def _is_ignored(relative_path: str, gitignore_patterns: set[str]) -> bool:
79
+ """Check if a relative path matches any .gitignore pattern."""
80
+ for pattern in gitignore_patterns:
81
+ if pattern.endswith("/"):
82
+ # Directory pattern — match if path starts with it
83
+ if relative_path.startswith(pattern) or relative_path.startswith(pattern.rstrip("/")):
84
+ return True
85
+ elif pattern.startswith("*"):
86
+ # Glob pattern like *.log
87
+ import fnmatch
88
+ if fnmatch.fnmatch(relative_path, pattern) or fnmatch.fnmatch(Path(relative_path).name, pattern):
89
+ return True
90
+ else:
91
+ # Exact match or prefix
92
+ if relative_path == pattern or relative_path.startswith(pattern + "/"):
93
+ return True
94
+ return False
95
+
96
+
97
+ def _collect_files(workspace_path: str) -> dict[str, bytes]:
98
+ """Read AgentsMD workspace files into a dict.
99
+
100
+ Syncs only the files that agentsmd manages:
101
+ - AGENTS.md, CLAUDE.md (and other agent config files)
102
+ - memory/ directory
103
+ - prompts/ directory
104
+ - .agentsmd/ directory (including config.yml)
105
+
106
+ Additionally respects .gitignore patterns.
107
+ """
108
+ workspace = Path(workspace_path)
109
+ files = {}
110
+ gitignore = _load_gitignore_patterns(workspace)
111
+
112
+ # Root-level agent config files
113
+ agent_files = ["AGENTS.md", "CLAUDE.md", ".cursorrules", ".windsurfrules"]
114
+ for name in agent_files:
115
+ path = workspace / name
116
+ if path.exists() and not _is_ignored(name, gitignore):
117
+ files[name] = path.read_bytes()
118
+
119
+ # Tool directories
120
+ tool_dirs = [".cursor/rules", ".windsurf/rules", ".github/instructions"]
121
+ for dir_path in tool_dirs:
122
+ full_dir = workspace / dir_path
123
+ if full_dir.is_dir():
124
+ for file_path in full_dir.rglob("*"):
125
+ if file_path.is_file():
126
+ try:
127
+ relative = str(file_path.relative_to(workspace))
128
+ if not _is_ignored(relative, gitignore):
129
+ files[relative] = file_path.read_bytes()
130
+ except (IOError, PermissionError):
131
+ pass
132
+
133
+ # GitHub Copilot instructions file
134
+ copilot_file = workspace / ".github" / "copilot-instructions.md"
135
+ if copilot_file.exists():
136
+ relative = ".github/copilot-instructions.md"
137
+ if not _is_ignored(relative, gitignore):
138
+ files[relative] = copilot_file.read_bytes()
139
+
140
+ # .agentsmd/ directory
141
+ agentsmd_dir = workspace / ".agentsmd"
142
+ if agentsmd_dir.is_dir():
143
+ for file_path in agentsmd_dir.rglob("*"):
144
+ if file_path.is_file():
145
+ try:
146
+ relative = str(file_path.relative_to(workspace))
147
+ if not _is_ignored(relative, gitignore):
148
+ files[relative] = file_path.read_bytes()
149
+ except (IOError, PermissionError):
150
+ pass
151
+
152
+ # memory/ directory
153
+ memory_dir = workspace / "memory"
154
+ if memory_dir.exists():
155
+ for file_path in memory_dir.rglob("*"):
156
+ if file_path.is_file():
157
+ try:
158
+ relative = str(file_path.relative_to(workspace))
159
+ if not _is_ignored(relative, gitignore):
160
+ files[relative] = file_path.read_bytes()
161
+ except (IOError, PermissionError):
162
+ pass
163
+
164
+ # prompts/ directory
165
+ prompts_dir = workspace / "prompts"
166
+ if prompts_dir.exists():
167
+ for file_path in prompts_dir.rglob("*"):
168
+ if file_path.is_file():
169
+ try:
170
+ relative = str(file_path.relative_to(workspace))
171
+ if not _is_ignored(relative, gitignore):
172
+ files[relative] = file_path.read_bytes()
173
+ except (IOError, PermissionError):
174
+ pass
175
+
176
+ return files
177
+
178
+
179
+ def write_downloaded_files(workspace_path: str, downloaded: list[dict]) -> int:
180
+ """Write downloaded files from sync result to disk."""
181
+ count = 0
182
+ for item in downloaded:
183
+ file_path = Path(workspace_path) / item["path"]
184
+ file_path.parent.mkdir(parents=True, exist_ok=True)
185
+ file_path.write_bytes(base64.b64decode(item["content"]))
186
+ count += 1
187
+ return count
@@ -0,0 +1,12 @@
1
+ """Authentication module for AgentsMD.
2
+
3
+ Handles API token management and credential storage.
4
+ """
5
+
6
+ from agentsmd.auth.commands import run_login, run_logout, require_token
7
+
8
+ __all__ = [
9
+ "run_login",
10
+ "run_logout",
11
+ "require_token",
12
+ ]
@@ -0,0 +1,40 @@
1
+ """Authentication command implementations."""
2
+
3
+ import sys
4
+ from typing import Optional
5
+ from agentsmd.config import get_token, save_token, clear_credentials
6
+
7
+ def require_token(cli_token: Optional[str] = None) -> str:
8
+ """Ensure a token is present, otherwise exit with instructions."""
9
+ token = get_token(cli_token)
10
+ if token:
11
+ return token
12
+
13
+ print("Error: Authentication required.")
14
+ print("Please generate an API token at https://app.agentsmd.com/settings/tokens")
15
+ print("and provide it via the --token flag, or the AGENTSMD_API_TOKEN environment variable.")
16
+ print("You can also save it globally using: agentsmd login --token <TOKEN>")
17
+ sys.exit(1)
18
+
19
+ async def run_login(token: Optional[str] = None) -> None:
20
+ """Run authentication token saving flow."""
21
+ if not token:
22
+ print("Error: No token provided.")
23
+ print("Please generate an API token at https://app.agentsmd.com/settings/tokens")
24
+ print("and run: agentsmd login --token <TOKEN>")
25
+ sys.exit(1)
26
+
27
+ save_token(token)
28
+ print("Token saved successfully to ~/.agentsmd/credentials.json")
29
+ print("You are now authenticated!")
30
+
31
+ async def run_logout() -> None:
32
+ """Clear local credentials and logout."""
33
+ token = get_token()
34
+ if not token:
35
+ print("Not logged in.")
36
+ return
37
+
38
+ clear_credentials()
39
+ print("Credentials cleared from ~/.agentsmd/")
40
+ print("You are now logged out.")
agentsmd/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ """AgentsMD CLI - Command-line interface.
2
+
3
+ Orchestrates workspace initialization, synchronization, and authentication.
4
+ """
5
+
6
+ import sys
7
+ import logging
8
+ import typer
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
13
+ logger = logging.getLogger(__name__)
14
+
15
+ app = typer.Typer(
16
+ name="agentsmd",
17
+ help="AgentsMD - Intelligent workspace scaffolding and file synchronization",
18
+ add_completion=False,
19
+ no_args_is_help=True
20
+ )
21
+
22
+
23
+ @app.command(name="init")
24
+ def init_cmd():
25
+ """Initialize a new workspace."""
26
+ try:
27
+ from agentsmd.init.commands import run as run_init
28
+ import asyncio
29
+ asyncio.run(run_init())
30
+ except KeyboardInterrupt:
31
+ print("\n\nInitialization cancelled.")
32
+ sys.exit(0)
33
+ except Exception as e:
34
+ logger.error(f"Initialization failed: {e}")
35
+ raise typer.Exit(code=1)
36
+
37
+
38
+ @app.command(name="sync")
39
+ def sync_cmd(
40
+ workspace_path: Optional[Path] = typer.Argument(
41
+ None,
42
+ help="Path to workspace directory"
43
+ )
44
+ ):
45
+ """Synchronize workspace to/from Cloudflare R2."""
46
+ try:
47
+ from agentsmd.sync.commands import run as run_sync
48
+ import asyncio
49
+ asyncio.run(run_sync(workspace_path=str(workspace_path) if workspace_path else None))
50
+ except Exception as e:
51
+ logger.error(f"Sync failed: {e}")
52
+ raise typer.Exit(code=1)
53
+
54
+
55
+ @app.command(name="status")
56
+ def status_cmd(
57
+ workspace_path: Optional[Path] = typer.Argument(
58
+ None,
59
+ help="Path to workspace directory"
60
+ )
61
+ ):
62
+ """Show workspace synchronization status."""
63
+ try:
64
+ from agentsmd.sync.commands import run_status
65
+ import asyncio
66
+ asyncio.run(run_status(workspace_path=str(workspace_path) if workspace_path else None))
67
+ except Exception as e:
68
+ logger.error(f"Status check failed: {e}")
69
+ raise typer.Exit(code=1)
70
+
71
+
72
+ @app.command(name="login")
73
+ def login_cmd(
74
+ token: Optional[str] = typer.Option(
75
+ None,
76
+ "--token",
77
+ "-t",
78
+ help="API token to save globally"
79
+ )
80
+ ):
81
+ """Authenticate via API token."""
82
+ try:
83
+ from agentsmd.auth.commands import run_login
84
+ import asyncio
85
+ asyncio.run(run_login(token))
86
+ except Exception as e:
87
+ logger.error(f"Login failed: {e}")
88
+ raise typer.Exit(code=1)
89
+
90
+
91
+ @app.command(name="logout")
92
+ def logout_cmd():
93
+ """Clear local credentials."""
94
+ try:
95
+ from agentsmd.auth.commands import run_logout
96
+ import asyncio
97
+ asyncio.run(run_logout())
98
+ except Exception as e:
99
+ logger.error(f"Logout failed: {e}")
100
+ raise typer.Exit(code=1)
101
+
102
+
103
+ @app.command(name="migrate")
104
+ def migrate_cmd(
105
+ source: str = typer.Option(..., "--from", help="Source format (agents, claude, cursor, windsurf, copilot)"),
106
+ target: Optional[str] = typer.Option(None, "--to", help="Target format (default: agents + re-derive all)"),
107
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print output without writing files"),
108
+ ):
109
+ """Convert between AI tool config formats."""
110
+ from agentsmd.converter.migrate import run_migrate
111
+ result = run_migrate(Path.cwd(), source_format=source, target_format=target, dry_run=dry_run)
112
+
113
+ if not result.success:
114
+ print(f"Error: {result.error}")
115
+ raise typer.Exit(code=1)
116
+
117
+ if dry_run and result.output:
118
+ print(result.output)
119
+ else:
120
+ for f in result.files_written:
121
+ print(f" Written: {f}")
122
+ print(f"\nMigration complete. {len(result.files_written)} file(s) written.")
123
+
124
+
125
+ @app.command(name="reconcile")
126
+ def reconcile_cmd(
127
+ source: Optional[str] = typer.Option(None, "--from", help="Force source format (default: auto-detect)"),
128
+ ):
129
+ """Re-derive all tool config formats from the source of truth."""
130
+ from agentsmd.converter.reconcile import reconcile
131
+ result = reconcile(Path.cwd(), source_format=source)
132
+
133
+ if not result.success:
134
+ print(f"Error: {result.error}")
135
+ raise typer.Exit(code=1)
136
+
137
+ if result.changed_files:
138
+ print(f"Changed files: {result.changed_files}")
139
+ if result.files_written:
140
+ for f in result.files_written:
141
+ print(f" Written: {f}")
142
+ print(f"\nReconcile complete. {len(result.files_written)} file(s) updated.")
143
+ else:
144
+ print("All formats are in sync. No changes needed.")
145
+
146
+
147
+ @app.command(name="tool-list")
148
+ def tool_list_cmd():
149
+ """Show detected AI tools in this workspace."""
150
+ from agentsmd.converter.detector import detect_tools
151
+ from agentsmd.converter.registry import FORMATS
152
+
153
+ workspace = Path.cwd()
154
+ detected = detect_tools(workspace)
155
+
156
+ if not detected:
157
+ print("No AI tool config files detected.")
158
+ print("Default: agents (AGENTS.md)")
159
+ return
160
+
161
+ print("Detected tools:")
162
+ for fmt in detected:
163
+ info = FORMATS.get(fmt, {})
164
+ files = info.get("files", [])
165
+ dirs = info.get("dirs", [])
166
+ locations = files + dirs
167
+ location_str = ", ".join(locations)
168
+
169
+ count = ""
170
+ for d in dirs:
171
+ full_dir = workspace / d
172
+ if full_dir.is_dir():
173
+ rule_files = list(full_dir.glob("*"))
174
+ if rule_files:
175
+ count = f" — {len(rule_files)} rule(s)"
176
+
177
+ print(f" ✓ {fmt:10s} ({location_str}{count})")
178
+
179
+ print(f"\nDefault: agents (AGENTS.md)")
180
+
181
+
182
+ @app.command(name="tool-add")
183
+ def tool_add_cmd():
184
+ """Register a custom AI tool interactively."""
185
+ print("Add Custom Tool")
186
+ print("=" * 40)
187
+ print()
188
+
189
+ name = input("Tool name: ").strip()
190
+ if not name:
191
+ print("Tool name is required.")
192
+ raise typer.Exit(code=1)
193
+
194
+ filename = input("Config filename: ").strip()
195
+ if not filename:
196
+ print("Filename is required.")
197
+ raise typer.Exit(code=1)
198
+
199
+ is_dir = input("Is directory-based? (y/n): ").strip().lower() == "y"
200
+
201
+ print()
202
+ print("Map markdown headings to canonical sections.")
203
+ print("Press Enter to skip a mapping.")
204
+ print()
205
+
206
+ section_mappings = {}
207
+ canonical_sections = ["overview", "rules", "constraints", "context", "workflow", "testing"]
208
+ for section in canonical_sections:
209
+ heading = input(f" Which heading maps to '{section}'? ").strip()
210
+ if heading:
211
+ section_mappings[heading.lower()] = section
212
+
213
+ config_path = Path.cwd() / ".agentsmd" / "config.yml"
214
+ config_path.parent.mkdir(parents=True, exist_ok=True)
215
+
216
+ import yaml
217
+
218
+ config = {}
219
+ if config_path.exists():
220
+ try:
221
+ config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
222
+ except Exception:
223
+ config = {}
224
+
225
+ custom_tools = config.get("tools", [])
226
+ custom_tools.append({
227
+ "name": name,
228
+ "filename": filename,
229
+ "is_directory": is_dir,
230
+ "heading_mappings": section_mappings,
231
+ })
232
+ config["tools"] = custom_tools
233
+
234
+ config_path.write_text(yaml.dump(config, default_flow_style=False), encoding="utf-8")
235
+ print(f"\n✓ {name} registered in .agentsmd/config.yml")
236
+ print("✓ Run 'agentsmd sync' to reconcile.")
237
+
238
+
239
+ @app.callback()
240
+ def main(ctx: typer.Context) -> None:
241
+ """Main entry point."""
242
+ if ctx.resilient_parsing:
243
+ return
244
+
245
+ import agentsmd
246
+ typer.echo(f"AgentsMD v{agentsmd.__version__}")
247
+
248
+
249
+ if __name__ == "__main__":
250
+ app()
agentsmd/config.py ADDED
@@ -0,0 +1,58 @@
1
+ """Configuration and credential management for AgentsMD."""
2
+
3
+ import os
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ CONFIG_DIR = Path.home() / ".agentsmd"
9
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
10
+
11
+ def _ensure_config_dir() -> None:
12
+ """Ensure ~/.agentsmd directory exists."""
13
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
14
+
15
+ def get_token(cli_token: Optional[str] = None) -> Optional[str]:
16
+ """Get stored credentials from local storage, env var, or CLI flag.
17
+
18
+ Order of precedence:
19
+ 1. CLI Flag
20
+ 2. AGENTSMD_API_TOKEN environment variable
21
+ 3. credentials.json file
22
+ """
23
+ if cli_token:
24
+ return cli_token
25
+
26
+ if "AGENTSMD_API_TOKEN" in os.environ:
27
+ return os.environ["AGENTSMD_API_TOKEN"]
28
+
29
+ if not CREDENTIALS_FILE.exists():
30
+ return None
31
+
32
+ try:
33
+ with open(CREDENTIALS_FILE, "r") as f:
34
+ data = json.load(f)
35
+ return data.get("api_token")
36
+ except (json.JSONDecodeError, KeyError):
37
+ return None
38
+
39
+ def save_token(token: str) -> None:
40
+ """Save token to local storage."""
41
+ _ensure_config_dir()
42
+
43
+ data = {
44
+ "api_token": token,
45
+ }
46
+
47
+ with open(CREDENTIALS_FILE, "w") as f:
48
+ json.dump(data, f, indent=2)
49
+
50
+ def clear_credentials() -> None:
51
+ """Remove stored credentials."""
52
+ if CREDENTIALS_FILE.exists():
53
+ CREDENTIALS_FILE.unlink()
54
+
55
+
56
+ def get_api_url() -> str:
57
+ """Get the backend API base URL."""
58
+ return os.environ.get("AGENTSMD_API_URL", "https://api.agentsmd.com")
@@ -0,0 +1,12 @@
1
+ from agentsmd.converter.canonical import AgentSection, CanonicalModel
2
+ from agentsmd.converter.registry import get_parser, get_renderer, list_formats
3
+ from agentsmd.converter.detector import detect_tools
4
+
5
+ __all__ = [
6
+ "AgentSection",
7
+ "CanonicalModel",
8
+ "get_parser",
9
+ "get_renderer",
10
+ "list_formats",
11
+ "detect_tools",
12
+ ]
@@ -0,0 +1,61 @@
1
+ """Canonical model for agent configuration sections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+ from pydantic import BaseModel
8
+
9
+ SECTION_FIELDS: List[str] = [
10
+ "overview",
11
+ "rules",
12
+ "constraints",
13
+ "context",
14
+ "workflow",
15
+ "testing",
16
+ "file_structure",
17
+ "documentation",
18
+ "environment",
19
+ ]
20
+
21
+
22
+ class AgentSection(BaseModel):
23
+ """A single section within an agent configuration."""
24
+
25
+ heading: str
26
+ content: str
27
+ trigger: Optional[str] = None
28
+ globs: Optional[List[str]] = None
29
+ description: Optional[str] = None
30
+
31
+
32
+ class CanonicalModel(BaseModel):
33
+ """Canonical representation of all agent configuration sections."""
34
+
35
+ overview: Optional[AgentSection] = None
36
+ rules: Optional[AgentSection] = None
37
+ constraints: Optional[AgentSection] = None
38
+ context: Optional[AgentSection] = None
39
+ workflow: Optional[AgentSection] = None
40
+ testing: Optional[AgentSection] = None
41
+ file_structure: Optional[AgentSection] = None
42
+ documentation: Optional[AgentSection] = None
43
+ environment: Optional[AgentSection] = None
44
+ extras: List[AgentSection] = []
45
+
46
+ def all_sections(self) -> List[AgentSection]:
47
+ """Return all non-None named sections plus extras."""
48
+ sections: List[AgentSection] = []
49
+ for field_name in SECTION_FIELDS:
50
+ section = getattr(self, field_name)
51
+ if section is not None:
52
+ sections.append(section)
53
+ sections.extend(self.extras)
54
+ return sections
55
+
56
+ def set_section(self, name: str, section: AgentSection) -> None:
57
+ """Set a named field if it matches a known field, otherwise append to extras."""
58
+ if name in SECTION_FIELDS:
59
+ setattr(self, name, section)
60
+ else:
61
+ self.extras.append(section)
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from agentsmd.converter.registry import FORMATS
6
+
7
+
8
+ def detect_tools(workspace_path: Path) -> list[str]:
9
+ """Auto-detect active tools from workspace file/directory presence."""
10
+ detected: list[str] = []
11
+ for format_name, info in FORMATS.items():
12
+ found = False
13
+ for file_pattern in info.get("files", []):
14
+ if (workspace_path / file_pattern).exists():
15
+ found = True
16
+ break
17
+ if not found:
18
+ for dir_pattern in info.get("dirs", []):
19
+ if (workspace_path / dir_pattern).is_dir():
20
+ found = True
21
+ break
22
+ if found:
23
+ detected.append(format_name)
24
+ return detected