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.
- agentsmd/__init__.py +2 -0
- agentsmd/__main__.py +6 -0
- agentsmd/api_client.py +187 -0
- agentsmd/auth/__init__.py +12 -0
- agentsmd/auth/commands.py +40 -0
- agentsmd/cli.py +250 -0
- agentsmd/config.py +58 -0
- agentsmd/converter/__init__.py +12 -0
- agentsmd/converter/canonical.py +61 -0
- agentsmd/converter/detector.py +24 -0
- agentsmd/converter/migrate.py +87 -0
- agentsmd/converter/parsers/__init__.py +13 -0
- agentsmd/converter/parsers/agents_md.py +67 -0
- agentsmd/converter/parsers/base.py +31 -0
- agentsmd/converter/parsers/claude_md.py +139 -0
- agentsmd/converter/parsers/copilot.py +185 -0
- agentsmd/converter/parsers/cursor.py +155 -0
- agentsmd/converter/parsers/markdown_utils.py +73 -0
- agentsmd/converter/parsers/windsurf.py +148 -0
- agentsmd/converter/reconcile.py +195 -0
- agentsmd/converter/registry.py +73 -0
- agentsmd/converter/renderers/__init__.py +13 -0
- agentsmd/converter/renderers/agents_md.py +49 -0
- agentsmd/converter/renderers/base.py +25 -0
- agentsmd/converter/renderers/claude_md.py +63 -0
- agentsmd/converter/renderers/copilot.py +38 -0
- agentsmd/converter/renderers/cursor.py +56 -0
- agentsmd/converter/renderers/windsurf.py +45 -0
- agentsmd/init/__init__.py +24 -0
- agentsmd/init/commands.py +31 -0
- agentsmd/init/deriver.py +43 -0
- agentsmd/init/generator.py +203 -0
- agentsmd/init/interview.py +257 -0
- agentsmd/machine.py +49 -0
- agentsmd/sync/__init__.py +5 -0
- agentsmd/sync/commands.py +121 -0
- agentsmd/templates/agents_md.j2 +64 -0
- agentsmd/templates/claude_md.j2 +36 -0
- agentsmd/templates/cursorrules.j2 +34 -0
- agentsmd/templates/windsurfrules.j2 +34 -0
- agentsmd-1.0.0.dist-info/METADATA +21 -0
- agentsmd-1.0.0.dist-info/RECORD +44 -0
- agentsmd-1.0.0.dist-info/WHEEL +4 -0
- agentsmd-1.0.0.dist-info/entry_points.txt +3 -0
agentsmd/__init__.py
ADDED
agentsmd/__main__.py
ADDED
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,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
|