argus-mcp-cli 0.1.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.
argus_mcp/__init__.py ADDED
File without changes
argus_mcp/config.py ADDED
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Config:
11
+ project_root: Path
12
+ vault_path: Path
13
+ stack: str
14
+
15
+
16
+ def load() -> Config:
17
+ if root := os.getenv("ARGUS_PROJECT_ROOT"):
18
+ project_root = Path(root)
19
+ else:
20
+ project_root = _find_project_root()
21
+
22
+ if vault := os.getenv("ARGUS_VAULT_PATH"):
23
+ vault_path = Path(vault)
24
+ else:
25
+ vault_path = project_root / "vault"
26
+
27
+ return Config(
28
+ project_root=project_root,
29
+ vault_path=vault_path,
30
+ stack=_read_stack(project_root),
31
+ )
32
+
33
+
34
+ def _find_project_root() -> Path:
35
+ for directory in [Path.cwd(), *Path.cwd().parents]:
36
+ if (directory / ".argus").exists() or (directory / "CLAUDE.md").exists():
37
+ return directory
38
+ raise RuntimeError("project root não encontrado — rode argus init primeiro")
39
+
40
+
41
+ def _read_stack(root: Path) -> str:
42
+ config_file = root / ".argus" / "config.json"
43
+ if config_file.exists():
44
+ data = json.loads(config_file.read_text(encoding="utf-8"))
45
+ return str(data.get("stack", "unknown"))
46
+ return "unknown"
argus_mcp/server.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Callable, Coroutine
5
+ from typing import Any
6
+
7
+ import mcp.types as types
8
+ from mcp.server import Server
9
+ from mcp.server.stdio import stdio_server
10
+
11
+ from argus_mcp.tools import context, memory, scaffold, validate
12
+
13
+ server = Server("argus")
14
+
15
+ _tools: list[types.Tool] = [
16
+ *context.TOOLS,
17
+ *memory.TOOLS,
18
+ *scaffold.TOOLS,
19
+ *validate.TOOLS,
20
+ ]
21
+
22
+ Handler = Callable[[dict[str, Any]], Coroutine[Any, Any, str]]
23
+
24
+ _handlers: dict[str, Handler] = {
25
+ **context.HANDLERS,
26
+ **memory.HANDLERS,
27
+ **scaffold.HANDLERS,
28
+ **validate.HANDLERS,
29
+ }
30
+
31
+
32
+ @server.list_tools() # type: ignore[attr-defined, untyped-decorator]
33
+ async def list_tools() -> list[types.Tool]:
34
+ return _tools
35
+
36
+
37
+ @server.call_tool() # type: ignore[attr-defined, untyped-decorator]
38
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
39
+ handler = _handlers.get(name)
40
+ if handler is None:
41
+ text = f"[ERRO] tool desconhecida: {name}"
42
+ else:
43
+ text = await handler(arguments or {})
44
+ return [types.TextContent(type="text", text=text)]
45
+
46
+
47
+ def main() -> None:
48
+ async def _run() -> None:
49
+ async with stdio_server() as (read, write):
50
+ await server.run(read, write, server.create_initialization_options())
51
+
52
+ asyncio.run(_run())
File without changes
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import mcp.types as types
8
+
9
+ from argus_mcp.config import load
10
+
11
+ TOOLS: list[types.Tool] = [
12
+ types.Tool(
13
+ name="read_project_context",
14
+ description="Retorna contexto consolidado do projeto (stack, backlog em andamento, últimas decisões, instruções do agente).",
15
+ input_schema={"type": "object", "properties": {}},
16
+ ),
17
+ types.Tool(
18
+ name="read_spec",
19
+ description="Lê um arquivo de spec do vault por ID ou path.",
20
+ input_schema={
21
+ "type": "object",
22
+ "properties": {
23
+ "id": {"type": "string", "description": "ID do item (ex: ARGUS-015)"},
24
+ "path": {"type": "string", "description": "Path relativo ao vault"},
25
+ },
26
+ },
27
+ ),
28
+ ]
29
+
30
+
31
+ async def _read_project_context(args: dict[str, object]) -> str:
32
+ try:
33
+ cfg = load()
34
+ except RuntimeError as e:
35
+ return f"[ERRO] {e}"
36
+
37
+ parts: list[str] = ["# Contexto do projeto"]
38
+
39
+ parts.append(f"\n## Stack\n{cfg.stack}")
40
+
41
+ backlog_file = cfg.vault_path / "03-specifications" / "backlog.md"
42
+ if backlog_file.exists():
43
+ in_progress = _extract_in_progress(backlog_file.read_text(encoding="utf-8"))
44
+ parts.append(f"\n## Backlog (em andamento)\n{in_progress}")
45
+
46
+ decision_file = cfg.vault_path / "00-project-charter" / "decision-log.md"
47
+ if decision_file.exists():
48
+ decisions = _extract_last_decisions(
49
+ decision_file.read_text(encoding="utf-8"), n=5
50
+ )
51
+ parts.append(f"\n## Últimas decisões\n{decisions}")
52
+
53
+ instrucao_file = cfg.vault_path / "11-ai-context" / "instrucao-ia.md"
54
+ if instrucao_file.exists():
55
+ parts.append(
56
+ f"\n## Instruções do agente\n{instrucao_file.read_text(encoding='utf-8')}"
57
+ )
58
+
59
+ return "\n".join(parts)
60
+
61
+
62
+ async def _read_spec(args: dict[str, object]) -> str:
63
+ spec_id = args.get("id")
64
+ spec_path = args.get("path")
65
+
66
+ if not spec_id and not spec_path:
67
+ return "[ERRO] forneça id ou path"
68
+
69
+ try:
70
+ cfg = load()
71
+ except RuntimeError as e:
72
+ return f"[ERRO] {e}"
73
+
74
+ target: Path | None
75
+ if spec_path:
76
+ target = cfg.vault_path / str(spec_path)
77
+ else:
78
+ target = _find_by_id(cfg.vault_path, str(spec_id))
79
+
80
+ if target is None or not target.exists():
81
+ return f"[ERRO] spec não encontrada: {spec_id or spec_path}"
82
+
83
+ return target.read_text(encoding="utf-8")
84
+
85
+
86
+ def _extract_in_progress(backlog: str) -> str:
87
+ match = re.search(r"## Em andamento\n(.*?)(?=\n---|\Z)", backlog, re.DOTALL)
88
+ if not match:
89
+ return "nenhum item em andamento"
90
+ block = match.group(1).strip()
91
+ rows = [
92
+ row
93
+ for row in block.splitlines()
94
+ if row.startswith("|") and "---" not in row and "ID" not in row
95
+ ]
96
+ return "\n".join(rows) if rows else "nenhum item em andamento"
97
+
98
+
99
+ def _extract_last_decisions(log: str, n: int) -> str:
100
+ entries = re.split(r"\n(?=## \[)", log)
101
+ decision_entries = [e.strip() for e in entries if e.strip().startswith("## [")]
102
+ last = decision_entries[-n:] if len(decision_entries) >= n else decision_entries
103
+ return "\n\n".join(last)
104
+
105
+
106
+ def _find_by_id(vault: Path, spec_id: str) -> Path | None:
107
+ for md in vault.rglob("*.md"):
108
+ if spec_id.lower() in md.stem.lower():
109
+ return md
110
+ try:
111
+ if (
112
+ spec_id.lower()
113
+ in md.read_text(encoding="utf-8", errors="ignore").lower()[:200]
114
+ ):
115
+ return md
116
+ except OSError:
117
+ continue
118
+ return None
119
+
120
+
121
+ HANDLERS: dict[str, Any] = {
122
+ "read_project_context": _read_project_context,
123
+ "read_spec": _read_spec,
124
+ }
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import date
5
+ from typing import Any
6
+
7
+ import mcp.types as types
8
+
9
+ from argus_mcp.config import load
10
+
11
+ TOOLS: list[types.Tool] = [
12
+ types.Tool(
13
+ name="save_decision",
14
+ description="Acrescenta uma entrada formatada no decision-log.md do vault.",
15
+ input_schema={
16
+ "type": "object",
17
+ "properties": {
18
+ "title": {"type": "string"},
19
+ "context": {"type": "string"},
20
+ "decision": {"type": "string"},
21
+ "reason": {"type": "string"},
22
+ "discarded": {"type": "string"},
23
+ },
24
+ "required": ["title", "context", "decision", "reason"],
25
+ },
26
+ ),
27
+ types.Tool(
28
+ name="update_backlog",
29
+ description="Move um item do backlog (start | complete | block).",
30
+ input_schema={
31
+ "type": "object",
32
+ "properties": {
33
+ "id": {"type": "string"},
34
+ "action": {"type": "string", "enum": ["start", "complete", "block"]},
35
+ "note": {"type": "string"},
36
+ },
37
+ "required": ["id", "action"],
38
+ },
39
+ ),
40
+ ]
41
+
42
+
43
+ async def _save_decision(args: dict[str, object]) -> str:
44
+ try:
45
+ cfg = load()
46
+ except RuntimeError as e:
47
+ return f"[ERRO] {e}"
48
+
49
+ decision_file = cfg.vault_path / "00-project-charter" / "decision-log.md"
50
+ if not decision_file.exists():
51
+ return f"[ERRO] decision-log.md não encontrado em {decision_file}"
52
+
53
+ today = date.today().isoformat()
54
+ title = args.get("title", "")
55
+ entry_lines = [
56
+ f"\n## [{today}] {title}",
57
+ f"\n**Contexto:** {args.get('context', '')}",
58
+ f"**Decisão:** {args.get('decision', '')}",
59
+ f"**Motivo:** {args.get('reason', '')}",
60
+ ]
61
+ if discarded := args.get("discarded"):
62
+ entry_lines.append(f"**Alternativas descartadas:** {discarded}")
63
+
64
+ entry = "\n".join(entry_lines)
65
+ current = decision_file.read_text(encoding="utf-8")
66
+ decision_file.write_text(current.rstrip() + "\n" + entry + "\n", encoding="utf-8")
67
+
68
+ return f"Decisão salva: {title}"
69
+
70
+
71
+ async def _update_backlog(args: dict[str, object]) -> str:
72
+ try:
73
+ cfg = load()
74
+ except RuntimeError as e:
75
+ return f"[ERRO] {e}"
76
+
77
+ backlog_file = cfg.vault_path / "03-specifications" / "backlog.md"
78
+ if not backlog_file.exists():
79
+ return f"[ERRO] backlog.md não encontrado em {backlog_file}"
80
+
81
+ item_id = str(args.get("id", ""))
82
+ action = str(args.get("action", ""))
83
+ note = str(args.get("note", ""))
84
+
85
+ if action not in ("start", "complete", "block"):
86
+ return f"[ERRO] action inválida: {action}"
87
+
88
+ content = backlog_file.read_text(encoding="utf-8")
89
+
90
+ if action == "start":
91
+ content = _move_to_in_progress(content, item_id)
92
+ elif action == "complete":
93
+ content = _move_to_complete(content, item_id)
94
+ elif action == "block":
95
+ content = _add_block_note(content, item_id, note)
96
+
97
+ if content is None:
98
+ return f"[ERRO] item não encontrado: {item_id}"
99
+
100
+ backlog_file.write_text(content, encoding="utf-8")
101
+ return f"Backlog atualizado: {item_id} → {action}"
102
+
103
+
104
+ def _find_item_row(content: str, item_id: str) -> re.Match[str] | None:
105
+ pattern = rf"(\| {re.escape(item_id)} \|[^\n]+)"
106
+ return re.search(pattern, content)
107
+
108
+
109
+ def _move_to_in_progress(content: str, item_id: str) -> str:
110
+ match = _find_item_row(content, item_id)
111
+ if not match:
112
+ return content
113
+
114
+ row = match.group(1)
115
+ content = content.replace(row, "", 1)
116
+
117
+ in_progress_header = (
118
+ "## Em andamento\n\n| ID | Item | Responsável |\n|----|------|-------------|"
119
+ )
120
+ insert_row = f"| {item_id} | {_extract_item_text(row)} | — |"
121
+ return content.replace(
122
+ in_progress_header,
123
+ f"{in_progress_header}\n{insert_row}",
124
+ 1,
125
+ )
126
+
127
+
128
+ def _move_to_complete(content: str, item_id: str) -> str:
129
+ match = _find_item_row(content, item_id)
130
+ if not match:
131
+ return content
132
+
133
+ row = match.group(1)
134
+ item_text = _extract_item_text(row)
135
+ today = date.today().isoformat()
136
+ content = content.replace(row, "", 1)
137
+
138
+ complete_row = f"| ~~{item_id}~~ | ~~{item_text}~~ | {today} |"
139
+ concluido_marker = "## Concluído\n\n| ID | Item | Data |\n|----|------|------|"
140
+ return content.replace(
141
+ concluido_marker,
142
+ f"{concluido_marker}\n{complete_row}",
143
+ 1,
144
+ )
145
+
146
+
147
+ def _add_block_note(content: str, item_id: str, note: str) -> str:
148
+ match = _find_item_row(content, item_id)
149
+ if not match:
150
+ return content
151
+
152
+ row = match.group(1)
153
+ suffix = f" ⚠️ {note}" if note else " ⚠️ bloqueado"
154
+ return content.replace(row, row.rstrip() + suffix, 1)
155
+
156
+
157
+ def _extract_item_text(row: str) -> str:
158
+ parts = [p.strip() for p in row.split("|") if p.strip()]
159
+ return parts[1] if len(parts) > 1 else row
160
+
161
+
162
+ HANDLERS: dict[str, Any] = {
163
+ "save_decision": _save_decision,
164
+ "update_backlog": _update_backlog,
165
+ }
@@ -0,0 +1,251 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import date
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import mcp.types as types
9
+
10
+ from argus_mcp.config import load
11
+
12
+ TOOLS: list[types.Tool] = [
13
+ types.Tool(
14
+ name="create_spec",
15
+ description="Cria um novo arquivo de spec no vault a partir do template.",
16
+ input_schema={
17
+ "type": "object",
18
+ "properties": {
19
+ "id": {"type": "string"},
20
+ "title": {"type": "string"},
21
+ },
22
+ "required": ["id", "title"],
23
+ },
24
+ ),
25
+ types.Tool(
26
+ name="create_feature",
27
+ description="Cria estrutura VSA de uma nova feature no projeto.",
28
+ input_schema={
29
+ "type": "object",
30
+ "properties": {
31
+ "name": {"type": "string", "description": "Nome em kebab-case"},
32
+ },
33
+ "required": ["name"],
34
+ },
35
+ ),
36
+ types.Tool(
37
+ name="create_endpoint",
38
+ description="Cria arquivos de endpoint dentro de uma feature existente.",
39
+ input_schema={
40
+ "type": "object",
41
+ "properties": {
42
+ "feature": {"type": "string"},
43
+ "method": {
44
+ "type": "string",
45
+ "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
46
+ },
47
+ "path": {"type": "string"},
48
+ },
49
+ "required": ["feature", "method", "path"],
50
+ },
51
+ ),
52
+ types.Tool(
53
+ name="create_migration",
54
+ description="Cria arquivo de migração numerado sequencialmente.",
55
+ input_schema={
56
+ "type": "object",
57
+ "properties": {
58
+ "description": {"type": "string", "description": "Em snake_case"},
59
+ },
60
+ "required": ["description"],
61
+ },
62
+ ),
63
+ ]
64
+
65
+
66
+ async def _create_spec(args: dict[str, object]) -> str:
67
+ try:
68
+ cfg = load()
69
+ except RuntimeError as e:
70
+ return f"[ERRO] {e}"
71
+
72
+ spec_id = str(args.get("id", ""))
73
+ title = str(args.get("title", ""))
74
+ slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
75
+ target = cfg.vault_path / "03-specifications" / f"{spec_id}-{slug}.md"
76
+
77
+ if target.exists():
78
+ return f"[ERRO] spec já existe: {target.relative_to(cfg.vault_path)}"
79
+
80
+ template_file = cfg.vault_path / "99-templates" / "spec.md"
81
+ if template_file.exists():
82
+ content = template_file.read_text(encoding="utf-8")
83
+ else:
84
+ content = _default_spec_template()
85
+
86
+ today = date.today().isoformat()
87
+ content = (
88
+ content.replace("PROJ-SPEC-XXX", spec_id)
89
+ .replace("[título]", title)
90
+ .replace("{{DATE}}", today)
91
+ )
92
+
93
+ target.parent.mkdir(parents=True, exist_ok=True)
94
+ target.write_text(content, encoding="utf-8")
95
+ return f"Spec criada: vault/03-specifications/{target.name}"
96
+
97
+
98
+ async def _create_feature(args: dict[str, object]) -> str:
99
+ try:
100
+ cfg = load()
101
+ except RuntimeError as e:
102
+ return f"[ERRO] {e}"
103
+
104
+ name = str(args.get("name", "")).strip()
105
+ feature_dir = cfg.project_root / "src" / "features" / name
106
+
107
+ if feature_dir.exists():
108
+ return f"[ERRO] feature já existe: src/features/{name}"
109
+
110
+ files = _feature_files(cfg.stack, name)
111
+ for rel_path, content in files.items():
112
+ full = feature_dir / rel_path
113
+ full.parent.mkdir(parents=True, exist_ok=True)
114
+ full.write_text(content, encoding="utf-8")
115
+
116
+ created = "\n".join(f" + {p}" for p in files)
117
+ return f"Feature criada: src/features/{name}/\n{created}"
118
+
119
+
120
+ async def _create_endpoint(args: dict[str, object]) -> str:
121
+ try:
122
+ cfg = load()
123
+ except RuntimeError as e:
124
+ return f"[ERRO] {e}"
125
+
126
+ feature = str(args.get("feature", ""))
127
+ method = str(args.get("method", "GET")).upper()
128
+ path = str(args.get("path", ""))
129
+ feature_dir = cfg.project_root / "src" / "features" / feature
130
+
131
+ if not feature_dir.exists():
132
+ return f"[ERRO] feature não encontrada: src/features/{feature}"
133
+
134
+ note = _endpoint_note(cfg.stack, method, path)
135
+ return f"Endpoint adicionado: {method} {path} em src/features/{feature}/\n{note}"
136
+
137
+
138
+ async def _create_migration(args: dict[str, object]) -> str:
139
+ try:
140
+ cfg = load()
141
+ except RuntimeError as e:
142
+ return f"[ERRO] {e}"
143
+
144
+ description = str(args.get("description", "")).strip()
145
+ migrations_dir = cfg.project_root / "migrations"
146
+ migrations_dir.mkdir(exist_ok=True)
147
+
148
+ next_num = _next_migration_number(migrations_dir)
149
+ prefix = f"{next_num:04d}_{description}"
150
+
151
+ up_file = migrations_dir / f"{prefix}.up.sql"
152
+ down_file = migrations_dir / f"{prefix}.down.sql"
153
+
154
+ up_file.write_text(f"-- Migration: {description}\n\n", encoding="utf-8")
155
+ down_file.write_text(f"-- Rollback: {description}\n\n", encoding="utf-8")
156
+
157
+ return (
158
+ f"Migration criada:\n"
159
+ f" + migrations/{up_file.name}\n"
160
+ f" + migrations/{down_file.name}"
161
+ )
162
+
163
+
164
+ # --- helpers ---
165
+
166
+
167
+ def _next_migration_number(migrations_dir: Path) -> int:
168
+ existing = [
169
+ int(m.name[:4]) for m in migrations_dir.glob("*.sql") if m.name[:4].isdigit()
170
+ ]
171
+ return max(existing, default=0) + 1
172
+
173
+
174
+ def _feature_files(stack: str, name: str) -> dict[str, str]:
175
+ snake = name.replace("-", "_")
176
+ if "fastapi" in stack or "python" in stack:
177
+ return {
178
+ "schemas.py": f"from pydantic import BaseModel\n\n\nclass {_pascal(name)}Response(BaseModel):\n pass\n",
179
+ "repository.py": f'from supabase import AsyncClient\n\n\nasync def find_all(db: AsyncClient) -> list[dict]:\n result = await db.table("{snake}s").select("*").execute()\n return result.data\n',
180
+ "service.py": "from supabase import AsyncClient\nfrom . import repository\n\n\nasync def list_all(db: AsyncClient) -> list[dict]:\n return await repository.find_all(db)\n",
181
+ "router.py": f'from fastapi import APIRouter, Depends\nfrom supabase import AsyncClient\nfrom app.deps import get_db\nfrom . import service\n\nrouter = APIRouter(prefix="/{snake}s", tags=["{snake}s"])\n\n\n@router.get("/")\nasync def list_{snake}s(db: AsyncClient = Depends(get_db)):\n return await service.list_all(db)\n',
182
+ f"tests/test_{snake}.py": f'import pytest\nfrom httpx import AsyncClient\n\n\n@pytest.mark.asyncio\nasync def test_list_{snake}s(client: AsyncClient):\n response = await client.get("/{snake}s/")\n assert response.status_code == 200\n',
183
+ }
184
+ if "nextjs" in stack or "next" in stack:
185
+ return {
186
+ "page.tsx": f"export default async function {_pascal(name)}Page() {{\n return <main><h1>{_pascal(name)}</h1></main>;\n}}\n",
187
+ "actions.ts": f'"use server";\n\nexport async function list{_pascal(name)}() {{\n // TODO\n return [];\n}}\n',
188
+ "repository.ts": f'import {{ prisma }} from "@/lib/prisma";\n\nexport async function findAll() {{\n return prisma.{snake}.findMany();\n}}\n',
189
+ "schemas.ts": f'import {{ z }} from "zod";\n\nexport const {snake}Schema = z.object({{\n id: z.string(),\n}});\n',
190
+ f"tests/{snake}.test.ts": f'import {{ describe, it, expect }} from "vitest";\n\ndescribe("{name}", () => {{\n it("placeholder", () => expect(true).toBe(true));\n}});\n',
191
+ }
192
+ if "go" in stack:
193
+ pkg = snake
194
+ return {
195
+ "model.go": f'package {pkg}\n\ntype {_pascal(name)} struct {{\n\tID string `json:"id"`\n}}\n',
196
+ "repository.go": f"package {pkg}\n\ntype Repository struct{{}}\n",
197
+ "service.go": f"package {pkg}\n\ntype Service struct {{\n\trepo *Repository\n}}\n\nfunc NewService(r *Repository) *Service {{ return &Service{{repo: r}} }}\n",
198
+ "handler.go": f'package {pkg}\n\nimport "net/http"\n\ntype Handler struct {{ svc *Service }}\n\nfunc NewHandler(s *Service) *Handler {{ return &Handler{{svc: s}} }}\n\nfunc (h *Handler) List(w http.ResponseWriter, r *http.Request) {{}}\n',
199
+ "handler_test.go": f'package {pkg}_test\n\nimport "testing"\n\nfunc TestList(t *testing.T) {{ t.Skip("implement") }}\n',
200
+ }
201
+ return {
202
+ "README.md": f"# {name}\n\nFeature criada pelo Argus. Stack não reconhecida: {stack}\n"
203
+ }
204
+
205
+
206
+ def _endpoint_note(stack: str, method: str, path: str) -> str:
207
+ if "fastapi" in stack:
208
+ return f' Adicione @router.{method.lower()}("{path}") em router.py'
209
+ if "nextjs" in stack:
210
+ return f" Adicione export async function {method.lower()}() em route.ts"
211
+ if "go" in stack:
212
+ return f' Adicione r.{method}("{path}", h.handler) em handler.go'
213
+ return " Adicione o handler conforme a stack do projeto"
214
+
215
+
216
+ def _pascal(name: str) -> str:
217
+ return "".join(w.capitalize() for w in re.split(r"[-_]", name))
218
+
219
+
220
+ def _default_spec_template() -> str:
221
+ return """---
222
+ id: {{ID}}
223
+ title: Spec — {{TITLE}}
224
+ type: spec
225
+ status: draft
226
+ created_at: {{DATE}}
227
+ updated_at: {{DATE}}
228
+ ---
229
+
230
+ # Spec — {{TITLE}}
231
+
232
+ ## Contexto
233
+
234
+ ## Comportamento esperado
235
+
236
+ ## Critérios de aceitação
237
+
238
+ - [ ] ...
239
+
240
+ ## Fora de escopo
241
+
242
+ - ...
243
+ """
244
+
245
+
246
+ HANDLERS: dict[str, Any] = {
247
+ "create_spec": _create_spec,
248
+ "create_feature": _create_feature,
249
+ "create_endpoint": _create_endpoint,
250
+ "create_migration": _create_migration,
251
+ }
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import mcp.types as types
9
+
10
+ from argus_mcp.config import load
11
+
12
+ TOOLS: list[types.Tool] = [
13
+ types.Tool(
14
+ name="validate_architecture",
15
+ description="Verifica VSA: sem imports cruzados entre features.",
16
+ input_schema={"type": "object", "properties": {}},
17
+ ),
18
+ types.Tool(
19
+ name="check_quality",
20
+ description="Executa lint, typecheck e testes.",
21
+ input_schema={
22
+ "type": "object",
23
+ "properties": {
24
+ "fix": {"type": "boolean", "description": "Corrige automaticamente"},
25
+ },
26
+ },
27
+ ),
28
+ types.Tool(
29
+ name="review_endpoint",
30
+ description="Verifica checklist de endpoint completo.",
31
+ input_schema={
32
+ "type": "object",
33
+ "properties": {
34
+ "feature": {"type": "string"},
35
+ "path": {"type": "string"},
36
+ },
37
+ "required": ["feature", "path"],
38
+ },
39
+ ),
40
+ ]
41
+
42
+
43
+ async def _validate_architecture(args: dict[str, object]) -> str:
44
+ try:
45
+ cfg = load()
46
+ except RuntimeError as e:
47
+ return f"[ERRO] {e}"
48
+
49
+ features_dir = cfg.project_root / "src" / "features"
50
+ if not features_dir.exists():
51
+ return "[ERRO] src/features/ não encontrado — rode create_feature primeiro"
52
+
53
+ features = [d.name for d in features_dir.iterdir() if d.is_dir()]
54
+ violations = _find_cross_imports(features_dir, features)
55
+
56
+ if not violations:
57
+ return (
58
+ f"Arquitetura válida.\n"
59
+ f"Analisadas: {len(features)} features\n"
60
+ f"Imports cruzados: nenhum"
61
+ )
62
+
63
+ lines = ["[AVISO] Imports cruzados detectados:"]
64
+ lines.extend(f" {src} → {dst}" for src, dst in violations)
65
+ return "\n".join(lines)
66
+
67
+
68
+ async def _check_quality(args: dict[str, object]) -> str:
69
+ try:
70
+ cfg = load()
71
+ except RuntimeError as e:
72
+ return f"[ERRO] {e}"
73
+
74
+ fix = bool(args.get("fix", False))
75
+ stack = cfg.stack
76
+ commands = _quality_commands(stack, fix, cfg.project_root)
77
+
78
+ results: list[tuple[str, bool, str]] = []
79
+ for label, cmd in commands:
80
+ ok, output = await _run(cmd, cwd=cfg.project_root)
81
+ results.append((label, ok, output))
82
+
83
+ all_passed = all(ok for _, ok, _ in results)
84
+ header = "Qualidade OK" if all_passed else "[ERRO] Qualidade falhou"
85
+ lines = [header]
86
+ for label, ok, output in results:
87
+ status = "PASSOU" if ok else "FALHOU"
88
+ lines.append(f" {label:<12} {status}")
89
+ if not ok:
90
+ lines.extend(f" {line}" for line in output.strip().splitlines()[:10])
91
+
92
+ return "\n".join(lines)
93
+
94
+
95
+ async def _review_endpoint(args: dict[str, object]) -> str:
96
+ try:
97
+ cfg = load()
98
+ except RuntimeError as e:
99
+ return f"[ERRO] {e}"
100
+
101
+ feature = str(args.get("feature", ""))
102
+ path = str(args.get("path", ""))
103
+ feature_dir = cfg.project_root / "src" / "features" / feature
104
+
105
+ if not feature_dir.exists():
106
+ return f"[ERRO] feature não encontrada: src/features/{feature}"
107
+
108
+ checklist = _build_checklist(feature_dir, cfg.stack)
109
+ all_passed = all(ok for _, ok, _ in checklist)
110
+
111
+ status = "aprovado" if all_passed else "incompleto"
112
+ header = f"Endpoint {status}: {path} em {feature}"
113
+ lines = [header]
114
+ for item, ok, note in checklist:
115
+ mark = "✓" if ok else "✗"
116
+ line = f" {mark} {item}"
117
+ if note:
118
+ line += f" — {note}"
119
+ lines.append(line)
120
+
121
+ return "\n".join(lines)
122
+
123
+
124
+ # --- helpers ---
125
+
126
+
127
+ def _find_cross_imports(
128
+ features_dir: Path, features: list[str]
129
+ ) -> list[tuple[str, str]]:
130
+ violations: list[tuple[str, str]] = []
131
+ for feature in features:
132
+ feature_path = features_dir / feature
133
+ for src_file in feature_path.rglob("*"):
134
+ if src_file.suffix not in (".py", ".ts", ".tsx", ".go"):
135
+ continue
136
+ try:
137
+ content = src_file.read_text(encoding="utf-8", errors="ignore")
138
+ except OSError:
139
+ continue
140
+ for other in features:
141
+ if other == feature:
142
+ continue
143
+ if _imports_feature(content, other, src_file.suffix):
144
+ rel = src_file.relative_to(features_dir.parent.parent)
145
+ violations.append((str(rel), f"features/{other}"))
146
+ return violations
147
+
148
+
149
+ def _imports_feature(content: str, other_feature: str, suffix: str) -> bool:
150
+ snake = other_feature.replace("-", "_")
151
+ if suffix == ".py":
152
+ return bool(
153
+ re.search(rf"from features\.{snake}\.|import features\.{snake}", content)
154
+ )
155
+ if suffix in (".ts", ".tsx"):
156
+ return bool(re.search(rf"from ['\"].*features/{other_feature}", content))
157
+ if suffix == ".go":
158
+ return bool(re.search(rf"\".*features/{other_feature}\"", content))
159
+ return False
160
+
161
+
162
+ def _quality_commands(stack: str, fix: bool, root: Path) -> list[tuple[str, list[str]]]:
163
+ if "fastapi" in stack or "python" in stack:
164
+ lint_cmd = ["ruff", "check", ".", "--fix"] if fix else ["ruff", "check", "."]
165
+ return [
166
+ ("lint", lint_cmd),
167
+ ("typecheck", ["mypy", "src/"]),
168
+ ("testes", ["pytest", "--tb=short", "-q"]),
169
+ ]
170
+ if "nextjs" in stack or "next" in stack:
171
+ lint_cmd = ["pnpm", "lint", "--fix"] if fix else ["pnpm", "lint"]
172
+ return [
173
+ ("lint", lint_cmd),
174
+ ("typecheck", ["pnpm", "type-check"]),
175
+ ("testes", ["pnpm", "test", "--run"]),
176
+ ]
177
+ if "go" in stack:
178
+ return [
179
+ ("vet", ["go", "vet", "./..."]),
180
+ ("lint", ["golangci-lint", "run", "./..."]),
181
+ ("testes", ["go", "test", "./..."]),
182
+ ]
183
+ return [("testes", ["echo", "stack não reconhecida"])]
184
+
185
+
186
+ async def _run(cmd: list[str], cwd: Path) -> tuple[bool, str]:
187
+ try:
188
+ proc = await asyncio.create_subprocess_exec(
189
+ *cmd,
190
+ cwd=cwd,
191
+ stdout=asyncio.subprocess.PIPE,
192
+ stderr=asyncio.subprocess.STDOUT,
193
+ )
194
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=60)
195
+ output = stdout.decode(errors="replace")
196
+ return proc.returncode == 0, output
197
+ except FileNotFoundError:
198
+ return False, f"[comando não encontrado: {cmd[0]}]"
199
+ except TimeoutError:
200
+ return False, "[timeout após 60s]"
201
+
202
+
203
+ def _build_checklist(feature_dir: Path, stack: str) -> list[tuple[str, bool, str]]:
204
+ files = {f.name for f in feature_dir.rglob("*") if f.is_file()}
205
+ has_test = any("test" in f.lower() for f in files)
206
+
207
+ if "fastapi" in stack or "python" in stack:
208
+ return [
209
+ ("schema Pydantic definido", "schemas.py" in files, ""),
210
+ ("rota em router.py", "router.py" in files, ""),
211
+ ("lógica no service.py", "service.py" in files, ""),
212
+ ("acesso ao banco via repository.py", "repository.py" in files, ""),
213
+ ("teste presente", has_test, "crie em tests/"),
214
+ ]
215
+ if "nextjs" in stack or "next" in stack:
216
+ return [
217
+ ("schema Zod definido", "schemas.ts" in files, ""),
218
+ ("server action em actions.ts", "actions.ts" in files, ""),
219
+ ("acesso ao banco via repository.ts", "repository.ts" in files, ""),
220
+ ("page.tsx presente", "page.tsx" in files, ""),
221
+ ("teste presente", has_test, "crie em tests/"),
222
+ ]
223
+ if "go" in stack:
224
+ return [
225
+ ("handler.go presente", "handler.go" in files, ""),
226
+ ("service.go presente", "service.go" in files, ""),
227
+ ("repository.go presente", "repository.go" in files, ""),
228
+ ("teste presente", has_test, "crie handler_test.go"),
229
+ ]
230
+ return [("estrutura de feature", feature_dir.exists(), "")]
231
+
232
+
233
+ HANDLERS: dict[str, Any] = {
234
+ "validate_architecture": _validate_architecture,
235
+ "check_quality": _check_quality,
236
+ "review_endpoint": _review_endpoint,
237
+ }
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.5
2
+ Name: argus-mcp-cli
3
+ Version: 0.1.0
4
+ Summary: Argus MCP server — ferramentas de engenharia para agentes de IA
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: mcp>=1.0
@@ -0,0 +1,12 @@
1
+ argus_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ argus_mcp/config.py,sha256=SC2jiaCBpTlB_R6tZxpelMDAE6geaZGE3BETUCroy7g,1185
3
+ argus_mcp/server.py,sha256=UQUwVV5kYndbUDQ1_e_cCesF4gslVsk6ttkw_Wy1Aew,1345
4
+ argus_mcp/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ argus_mcp/tools/context.py,sha256=I1pIZy2sPrRZSYtEdoIaebZ-lSn7r7CTQ-5pqKfxl04,3763
6
+ argus_mcp/tools/memory.py,sha256=V6INVLZlpAggHyMFDrYD4jmRWILtA2oAmBYYlm8Rjtk,5021
7
+ argus_mcp/tools/scaffold.py,sha256=6BYeD4yRkqKX6J0SN1vj9xOZgwE_bY2dh7MT0XkW3_w,9234
8
+ argus_mcp/tools/validate.py,sha256=5m6GaI_rSVnsb3UvlZxec1-Pp_K_Vn1OTzerbPLDsY8,8046
9
+ argus_mcp_cli-0.1.0.dist-info/METADATA,sha256=-KXfYzdmoCZVWkjSk2Wdix0whja06D50MZunWWQtGQQ,180
10
+ argus_mcp_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ argus_mcp_cli-0.1.0.dist-info/entry_points.txt,sha256=v0Rrcmt2pnSqBxzzYFdt-5iPQw5eAcE054JbxsHnkIs,52
12
+ argus_mcp_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ argus-mcp = argus_mcp.server:main