tylor-mcp 1.0.0
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.
- package/.aws-setup.sh +25 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.mcp.json +12 -0
- package/AGENTS.md +93 -0
- package/CLAUDE.md +99 -0
- package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/assets/tylor_logo.png +0 -0
- package/assets/tylor_threads_concept.png +0 -0
- package/bin/tylor.js +23 -0
- package/hooks/kill-thread-trigger.sh +7 -0
- package/hooks/post-tool-use-code-index.sh +7 -0
- package/hooks/session-checkpoint.sh +7 -0
- package/hooks/session-start.sh +7 -0
- package/install.py +401 -0
- package/install.sh +260 -0
- package/package.json +24 -0
- package/pytest.ini +2 -0
- package/registry.json +26 -0
- package/server/.env.example +24 -0
- package/server/__init__.py +0 -0
- package/server/config.py +89 -0
- package/server/main.py +93 -0
- package/server/personas/analyst.md +15 -0
- package/server/personas/ceo.md +14 -0
- package/server/personas/code_agent.md +15 -0
- package/server/personas/cto.md +14 -0
- package/server/provision.py +260 -0
- package/server/provision_opensearch.py +154 -0
- package/server/requirements.txt +26 -0
- package/server/storage/__init__.py +0 -0
- package/server/storage/dynamo.py +399 -0
- package/server/storage/json_store.py +359 -0
- package/server/storage/opensearch.py +194 -0
- package/server/storage/s3.py +96 -0
- package/server/storage/tests/__init__.py +0 -0
- package/server/storage/tests/test_dynamo.py +452 -0
- package/server/storage/tests/test_json_store.py +226 -0
- package/server/storage/tests/test_opensearch.py +270 -0
- package/server/storage/tests/test_s3.py +125 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/test_install.py +606 -0
- package/server/tests/test_isolation.py +90 -0
- package/server/tests/test_ui_server.py +385 -0
- package/server/tests/test_ui_shader_background.py +52 -0
- package/server/tests/test_ui_story_6_3.py +105 -0
- package/server/tools/__init__.py +0 -0
- package/server/tools/_mcp.py +4 -0
- package/server/tools/agents.py +160 -0
- package/server/tools/ecc/__init__.py +1 -0
- package/server/tools/ecc/data.py +35 -0
- package/server/tools/ecc/diagrams.py +23 -0
- package/server/tools/ecc/pipeline.py +24 -0
- package/server/tools/ecc/presentation.py +24 -0
- package/server/tools/ecc/web.py +23 -0
- package/server/tools/executor.py +880 -0
- package/server/tools/harness.py +330 -0
- package/server/tools/help.py +162 -0
- package/server/tools/hooks.py +357 -0
- package/server/tools/personas.py +110 -0
- package/server/tools/registry.py +195 -0
- package/server/tools/router.py +117 -0
- package/server/tools/skill_installer.py +230 -0
- package/server/tools/summarizer.py +168 -0
- package/server/tools/tests/__init__.py +0 -0
- package/server/tools/tests/test_agents.py +246 -0
- package/server/tools/tests/test_code_index.py +108 -0
- package/server/tools/tests/test_ecc_tools.py +51 -0
- package/server/tools/tests/test_executor.py +584 -0
- package/server/tools/tests/test_help_agent101.py +149 -0
- package/server/tools/tests/test_hooks.py +124 -0
- package/server/tools/tests/test_kill_thread.py +125 -0
- package/server/tools/tests/test_new_thread_list_threads.py +293 -0
- package/server/tools/tests/test_personas.py +52 -0
- package/server/tools/tests/test_recall_memory.py +55 -0
- package/server/tools/tests/test_registry_client.py +308 -0
- package/server/tools/tests/test_router.py +263 -0
- package/server/tools/tests/test_skill_installer.py +174 -0
- package/server/tools/tests/test_switch_thread.py +163 -0
- package/server/tools/tests/test_thread_command_skills.py +54 -0
- package/server/tools/tests/test_thread_resolver.py +165 -0
- package/server/tools/tests/test_tier1_schema.py +296 -0
- package/server/tools/thread_resolver.py +75 -0
- package/server/tools/tylor.py +374 -0
- package/server/tools/ui.py +38 -0
- package/server/ui_server.py +292 -0
- package/server/validate.py +237 -0
- package/skills/add-skill/SKILL.md +37 -0
- package/skills/afk-status/SKILL.md +20 -0
- package/skills/bmad/SKILL.md +14 -0
- package/skills/help-agent101/SKILL.md +48 -0
- package/skills/kill-thread/SKILL.md +35 -0
- package/skills/list-threads/SKILL.md +35 -0
- package/skills/new-thread/SKILL.md +35 -0
- package/skills/recall/SKILL.md +39 -0
- package/skills/run/SKILL.md +33 -0
- package/skills/set-sandbox/SKILL.md +38 -0
- package/skills/switch-thread/SKILL.md +38 -0
- package/ui/claude-logo.png +0 -0
- package/ui/index.html +1314 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/ui_server.py — aiohttp web server for the Thread Visualizer UI.
|
|
3
|
+
|
|
4
|
+
Serves static UI files, REST endpoints for thread data, and a WebSocket
|
|
5
|
+
endpoint for real-time thread state updates.
|
|
6
|
+
|
|
7
|
+
Architecture: shares the asyncio event loop with FastMCP (started as a
|
|
8
|
+
concurrent task in server/main.py). Never blocks the MCP control plane.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import asyncio
|
|
12
|
+
import errno
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Set
|
|
18
|
+
|
|
19
|
+
from aiohttp import web, WSMsgType
|
|
20
|
+
|
|
21
|
+
_THREAD_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{3,64}$")
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
UI_DIR = Path(__file__).parent.parent / "ui"
|
|
26
|
+
PORT = 8765 # updated to the actual bound port on startup
|
|
27
|
+
PORT_RANGE = (8765, 8775) # try these ports in order
|
|
28
|
+
|
|
29
|
+
# ── Global state ────────────────────────────────────────────────────────────
|
|
30
|
+
# Shared across the process lifetime; safe because asyncio is single-threaded.
|
|
31
|
+
ui_available: bool = False
|
|
32
|
+
_seq: int = 0 # monotonic broadcast sequence counter
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── WebSocket manager ────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
class WsManager:
|
|
38
|
+
"""Registry of connected WebSocket clients with fan-out broadcast."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._clients: Set[web.WebSocketResponse] = set()
|
|
42
|
+
|
|
43
|
+
def connect(self, ws: web.WebSocketResponse) -> None:
|
|
44
|
+
self._clients.add(ws)
|
|
45
|
+
|
|
46
|
+
def disconnect(self, ws: web.WebSocketResponse) -> None:
|
|
47
|
+
self._clients.discard(ws)
|
|
48
|
+
|
|
49
|
+
async def broadcast(self, payload: dict) -> None:
|
|
50
|
+
"""Send payload to all connected clients. Slow/closed clients are dropped."""
|
|
51
|
+
global _seq
|
|
52
|
+
_seq += 1
|
|
53
|
+
# Copy to avoid mutating the caller's dict
|
|
54
|
+
outbound = {**payload, "seq": _seq}
|
|
55
|
+
text = json.dumps(outbound)
|
|
56
|
+
|
|
57
|
+
dead: list[web.WebSocketResponse] = []
|
|
58
|
+
for ws in list(self._clients):
|
|
59
|
+
if ws.closed:
|
|
60
|
+
dead.append(ws)
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
await asyncio.wait_for(ws.send_str(text), timeout=0.5)
|
|
64
|
+
except Exception:
|
|
65
|
+
dead.append(ws)
|
|
66
|
+
for ws in dead:
|
|
67
|
+
self._clients.discard(ws)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def count(self) -> int:
|
|
71
|
+
return len(self._clients)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Module-level singleton — imported by tools that need to broadcast.
|
|
75
|
+
ws_manager = WsManager()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def _fetch_threads() -> list[dict]:
|
|
81
|
+
"""Fetch current thread list from storage. Returns [] on any error."""
|
|
82
|
+
try:
|
|
83
|
+
from .tools.tylor import list_threads
|
|
84
|
+
result = list_threads()
|
|
85
|
+
raw = result.get("threads", [])
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
"id": t.get("thread_id", ""),
|
|
89
|
+
"title": t.get("name", ""),
|
|
90
|
+
"status": t.get("status", "idle"),
|
|
91
|
+
"created_at": t.get("last_activity", ""),
|
|
92
|
+
"message_count": t.get("message_count", 0),
|
|
93
|
+
"project": t.get("project", ""),
|
|
94
|
+
}
|
|
95
|
+
for t in raw
|
|
96
|
+
]
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
logger.warning("ui_server: could not fetch threads: %s", exc)
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _group_threads_by_project(threads: list[dict]) -> list[dict]:
|
|
103
|
+
"""Group flat thread list into project buckets for the UI."""
|
|
104
|
+
from collections import OrderedDict
|
|
105
|
+
buckets: OrderedDict = OrderedDict()
|
|
106
|
+
for t in threads:
|
|
107
|
+
proj = t.get("project") or "default"
|
|
108
|
+
if proj not in buckets:
|
|
109
|
+
buckets[proj] = []
|
|
110
|
+
buckets[proj].append(t)
|
|
111
|
+
return [
|
|
112
|
+
{"id": name, "name": name, "threads": ts}
|
|
113
|
+
for name, ts in buckets.items()
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _fetch_messages(thread_id: str, limit: int = 50, before: str | None = None) -> list[dict]:
|
|
118
|
+
"""Fetch the last `limit` messages for a thread, optionally before a timestamp.
|
|
119
|
+
|
|
120
|
+
`before` is an ISO timestamp string (CreatedAt of the oldest message already
|
|
121
|
+
shown in the UI). Only messages strictly older than `before` are returned,
|
|
122
|
+
enabling "load earlier" pagination without re-fetching the same items.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
from .tools.tylor import _get_db
|
|
126
|
+
db = _get_db()
|
|
127
|
+
prefix = f"THREAD#{thread_id}#MSG#"
|
|
128
|
+
items = db.query_all(prefix)
|
|
129
|
+
items.sort(key=lambda i: i.get("SK", ""))
|
|
130
|
+
if before:
|
|
131
|
+
items = [i for i in items if i.get("CreatedAt", "") < before]
|
|
132
|
+
tail = items[-limit:]
|
|
133
|
+
return [
|
|
134
|
+
{
|
|
135
|
+
"role": i.get("Role", "unknown"),
|
|
136
|
+
"content": i.get("Content", ""),
|
|
137
|
+
"timestamp": i.get("CreatedAt", ""),
|
|
138
|
+
}
|
|
139
|
+
for i in tail
|
|
140
|
+
]
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
logger.warning("ui_server: could not fetch messages for %s: %s", thread_id, exc)
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fetch_current_thread_id() -> str | None:
|
|
147
|
+
"""Return the ID of the currently active thread, or None."""
|
|
148
|
+
try:
|
|
149
|
+
from .tools.tylor import _get_db
|
|
150
|
+
db = _get_db()
|
|
151
|
+
marker = db.get_current_thread_marker()
|
|
152
|
+
return marker.get("CurrentThreadId") if marker else None
|
|
153
|
+
except Exception:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def thread_update_payload() -> dict:
|
|
158
|
+
"""Build the standard thread_update broadcast payload."""
|
|
159
|
+
threads = _fetch_threads()
|
|
160
|
+
current_id = _fetch_current_thread_id()
|
|
161
|
+
return {
|
|
162
|
+
"type": "thread_update",
|
|
163
|
+
"projects": _group_threads_by_project(threads),
|
|
164
|
+
"current_thread_id": current_id,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── Route handlers ───────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
async def handle_index(request: web.Request) -> web.Response:
|
|
171
|
+
index = UI_DIR / "index.html"
|
|
172
|
+
if not index.exists():
|
|
173
|
+
return web.Response(status=404, text="UI not built — run: cd ui && npm run build")
|
|
174
|
+
# Inject the actual bound port so API/WS URLs are always correct,
|
|
175
|
+
# even when the server falls back to a non-default port (e.g. 8766).
|
|
176
|
+
html = index.read_text(encoding="utf-8")
|
|
177
|
+
html = html.replace(
|
|
178
|
+
"const API = 'http://localhost:8765'",
|
|
179
|
+
f"const API = 'http://localhost:{PORT}'"
|
|
180
|
+
).replace(
|
|
181
|
+
"const WS = 'ws://localhost:8765/ws/threads'",
|
|
182
|
+
f"const WS = 'ws://localhost:{PORT}/ws/threads'"
|
|
183
|
+
)
|
|
184
|
+
return web.Response(text=html, content_type="text/html",
|
|
185
|
+
headers={"Cache-Control": "no-store"})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def handle_threads(request: web.Request) -> web.Response:
|
|
189
|
+
loop = asyncio.get_running_loop()
|
|
190
|
+
threads = await loop.run_in_executor(None, _fetch_threads)
|
|
191
|
+
projects = _group_threads_by_project(threads)
|
|
192
|
+
current_id = await loop.run_in_executor(None, _fetch_current_thread_id)
|
|
193
|
+
return web.json_response({"projects": projects, "current_thread_id": current_id})
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def handle_thread_messages(request: web.Request) -> web.Response:
|
|
197
|
+
thread_id = request.match_info["thread_id"]
|
|
198
|
+
if not _THREAD_ID_RE.match(thread_id):
|
|
199
|
+
return web.json_response({"error": "invalid thread_id"}, status=400)
|
|
200
|
+
before = request.rel_url.query.get("before") or None
|
|
201
|
+
loop = asyncio.get_running_loop()
|
|
202
|
+
messages = await loop.run_in_executor(None, _fetch_messages, thread_id, 50, before)
|
|
203
|
+
return web.json_response(messages)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def handle_websocket(request: web.Request) -> web.WebSocketResponse:
|
|
207
|
+
ws = web.WebSocketResponse(heartbeat=30)
|
|
208
|
+
await ws.prepare(request)
|
|
209
|
+
|
|
210
|
+
ws_manager.connect(ws)
|
|
211
|
+
logger.debug("ui_server: WS client connected (total=%d)", ws_manager.count)
|
|
212
|
+
|
|
213
|
+
# Send full thread state immediately on connect (seq=0 = initial snapshot)
|
|
214
|
+
loop = asyncio.get_running_loop()
|
|
215
|
+
threads_now = await loop.run_in_executor(None, _fetch_threads)
|
|
216
|
+
initial = {"type": "thread_update", "projects": _group_threads_by_project(threads_now), "seq": 0}
|
|
217
|
+
await ws.send_str(json.dumps(initial))
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
async for msg in ws:
|
|
221
|
+
if msg.type == WSMsgType.PING:
|
|
222
|
+
await ws.pong(msg.data)
|
|
223
|
+
elif msg.type in (WSMsgType.CLOSE, WSMsgType.ERROR):
|
|
224
|
+
break
|
|
225
|
+
finally:
|
|
226
|
+
ws_manager.disconnect(ws)
|
|
227
|
+
logger.debug("ui_server: WS client disconnected (total=%d)", ws_manager.count)
|
|
228
|
+
|
|
229
|
+
return ws
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ── App factory ──────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
def _make_app() -> web.Application:
|
|
235
|
+
app = web.Application()
|
|
236
|
+
app.router.add_get("/", handle_index)
|
|
237
|
+
app.router.add_get("/api/threads", handle_threads)
|
|
238
|
+
app.router.add_get("/api/threads/{thread_id}/messages", handle_thread_messages)
|
|
239
|
+
app.router.add_get("/ws/threads", handle_websocket)
|
|
240
|
+
|
|
241
|
+
# Serve static assets from ui/ (dist/ when built, raw files in dev)
|
|
242
|
+
if UI_DIR.exists():
|
|
243
|
+
app.router.add_static("/", UI_DIR, show_index=False, follow_symlinks=False)
|
|
244
|
+
|
|
245
|
+
return app
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ── Startup / shutdown ───────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
async def start_ui_server() -> web.AppRunner | None:
|
|
251
|
+
"""
|
|
252
|
+
Start the aiohttp UI server, trying PORT_RANGE in order until one binds.
|
|
253
|
+
Updates the module-level PORT to the actual bound port.
|
|
254
|
+
Returns the AppRunner on success, None if all ports are unavailable.
|
|
255
|
+
Sets module-level `ui_available` accordingly.
|
|
256
|
+
"""
|
|
257
|
+
global ui_available, PORT
|
|
258
|
+
|
|
259
|
+
app = _make_app()
|
|
260
|
+
runner = web.AppRunner(app, access_log=None)
|
|
261
|
+
await runner.setup()
|
|
262
|
+
|
|
263
|
+
for candidate in range(PORT_RANGE[0], PORT_RANGE[1]):
|
|
264
|
+
site = web.TCPSite(runner, "localhost", candidate)
|
|
265
|
+
try:
|
|
266
|
+
await site.start()
|
|
267
|
+
PORT = candidate
|
|
268
|
+
ui_available = True
|
|
269
|
+
logger.info("ui_server: Thread Visualizer running at http://localhost:%d", PORT)
|
|
270
|
+
return runner
|
|
271
|
+
except OSError as exc:
|
|
272
|
+
if exc.errno == errno.EADDRINUSE:
|
|
273
|
+
logger.debug("ui_server: port %d in use, trying next…", candidate)
|
|
274
|
+
continue
|
|
275
|
+
# Non-EADDRINUSE error — give up immediately
|
|
276
|
+
await runner.cleanup()
|
|
277
|
+
ui_available = False
|
|
278
|
+
logger.warning(
|
|
279
|
+
"ui_server: could not start on port %d (%s) — Thread Visualizer unavailable.",
|
|
280
|
+
candidate, exc,
|
|
281
|
+
)
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
# All ports exhausted
|
|
285
|
+
await runner.cleanup()
|
|
286
|
+
ui_available = False
|
|
287
|
+
logger.warning(
|
|
288
|
+
"ui_server: all ports %d–%d in use — Thread Visualizer unavailable. "
|
|
289
|
+
"MCP tools continue to work normally.",
|
|
290
|
+
PORT_RANGE[0], PORT_RANGE[1] - 1,
|
|
291
|
+
)
|
|
292
|
+
return None
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Story 1.2: AWS Connectivity & Credential Validation
|
|
3
|
+
Each check_*() returns (passed: bool, message: str).
|
|
4
|
+
run_all() prints results and returns the count of failed AWS checks.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Colour helpers (ANSI — safe on macOS/Linux terminals)
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
GREEN = "\033[0;32m"
|
|
20
|
+
RED = "\033[0;31m"
|
|
21
|
+
YELLOW = "\033[1;33m"
|
|
22
|
+
NC = "\033[0m"
|
|
23
|
+
|
|
24
|
+
def _ok(msg: str) -> str:
|
|
25
|
+
return f" {GREEN}✓{NC} {msg}"
|
|
26
|
+
|
|
27
|
+
def _fail(msg: str) -> str:
|
|
28
|
+
return f" {RED}✗{NC} {msg}"
|
|
29
|
+
|
|
30
|
+
def _warn(msg: str) -> str:
|
|
31
|
+
return f" {YELLOW}⚠{NC} {msg}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# IAM action extraction helper
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
_IAM_RE = re.compile(r"(dynamodb:\w+|s3:\w+|bedrock:\w+|iam:\w+|sts:\w+)")
|
|
38
|
+
|
|
39
|
+
def _extract_iam_action(error_message: str, fallback: str) -> str:
|
|
40
|
+
m = _IAM_RE.search(error_message)
|
|
41
|
+
return m.group(1) if m else fallback
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Individual service checks
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def check_dynamodb() -> tuple[bool, str]:
|
|
49
|
+
"""Test DynamoDB connectivity using list_tables."""
|
|
50
|
+
try:
|
|
51
|
+
import boto3 # noqa: PLC0415
|
|
52
|
+
from botocore.exceptions import ClientError, NoCredentialsError # noqa: PLC0415
|
|
53
|
+
|
|
54
|
+
boto3.client("dynamodb").list_tables(Limit=1)
|
|
55
|
+
return True, _ok("DynamoDB")
|
|
56
|
+
except ImportError:
|
|
57
|
+
return False, _fail("DynamoDB — boto3 not installed")
|
|
58
|
+
except Exception as exc: # noqa: BLE001
|
|
59
|
+
try:
|
|
60
|
+
from botocore.exceptions import ClientError, NoCredentialsError # noqa: PLC0415
|
|
61
|
+
|
|
62
|
+
if isinstance(exc, NoCredentialsError):
|
|
63
|
+
return False, _fail("DynamoDB — no AWS credentials found")
|
|
64
|
+
if isinstance(exc, ClientError):
|
|
65
|
+
code = exc.response["Error"]["Code"]
|
|
66
|
+
msg = exc.response["Error"]["Message"]
|
|
67
|
+
action = _extract_iam_action(msg, code)
|
|
68
|
+
return False, _fail(f"DynamoDB — missing permission: {action}")
|
|
69
|
+
except ImportError:
|
|
70
|
+
pass
|
|
71
|
+
return False, _fail(f"DynamoDB — {exc}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_s3() -> tuple[bool, str]:
|
|
75
|
+
"""Test S3 connectivity using list_buckets."""
|
|
76
|
+
try:
|
|
77
|
+
import boto3 # noqa: PLC0415
|
|
78
|
+
from botocore.exceptions import ClientError, NoCredentialsError # noqa: PLC0415
|
|
79
|
+
|
|
80
|
+
boto3.client("s3").list_buckets()
|
|
81
|
+
return True, _ok("S3")
|
|
82
|
+
except ImportError:
|
|
83
|
+
return False, _fail("S3 — boto3 not installed")
|
|
84
|
+
except Exception as exc: # noqa: BLE001
|
|
85
|
+
try:
|
|
86
|
+
from botocore.exceptions import ClientError, NoCredentialsError # noqa: PLC0415
|
|
87
|
+
|
|
88
|
+
if isinstance(exc, NoCredentialsError):
|
|
89
|
+
return False, _fail("S3 — no AWS credentials found")
|
|
90
|
+
if isinstance(exc, ClientError):
|
|
91
|
+
code = exc.response["Error"]["Code"]
|
|
92
|
+
msg = exc.response["Error"]["Message"]
|
|
93
|
+
action = _extract_iam_action(msg, code)
|
|
94
|
+
return False, _fail(f"S3 — missing permission: {action}")
|
|
95
|
+
except ImportError:
|
|
96
|
+
pass
|
|
97
|
+
return False, _fail(f"S3 — {exc}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def check_bedrock() -> tuple[bool, str]:
|
|
101
|
+
"""Test Bedrock connectivity using list_foundation_models (us-east-1 only)."""
|
|
102
|
+
try:
|
|
103
|
+
import boto3 # noqa: PLC0415
|
|
104
|
+
from botocore.exceptions import ClientError, NoCredentialsError # noqa: PLC0415
|
|
105
|
+
|
|
106
|
+
# Architecture mandates us-east-1 for Bedrock — hard-coded intentionally.
|
|
107
|
+
boto3.client("bedrock", region_name="us-east-1").list_foundation_models()
|
|
108
|
+
return True, _ok("Bedrock")
|
|
109
|
+
except ImportError:
|
|
110
|
+
return False, _fail("Bedrock — boto3 not installed")
|
|
111
|
+
except Exception as exc: # noqa: BLE001
|
|
112
|
+
try:
|
|
113
|
+
from botocore.exceptions import ClientError, NoCredentialsError # noqa: PLC0415
|
|
114
|
+
|
|
115
|
+
if isinstance(exc, NoCredentialsError):
|
|
116
|
+
return False, _fail("Bedrock — no AWS credentials found")
|
|
117
|
+
if isinstance(exc, ClientError):
|
|
118
|
+
code = exc.response["Error"]["Code"]
|
|
119
|
+
msg = exc.response["Error"]["Message"]
|
|
120
|
+
action = _extract_iam_action(msg, code)
|
|
121
|
+
return False, _fail(f"Bedrock — missing permission: {action}")
|
|
122
|
+
except ImportError:
|
|
123
|
+
pass
|
|
124
|
+
return False, _fail(f"Bedrock — {exc}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_opensearch(host: str = "", port: str = "9200") -> tuple[bool, str]:
|
|
128
|
+
"""
|
|
129
|
+
Test OpenSearch cluster health via HTTP GET.
|
|
130
|
+
Returns (True, advisory_warn_msg) when host is not configured — skipped is not an error.
|
|
131
|
+
"""
|
|
132
|
+
if not host:
|
|
133
|
+
return True, _warn("OpenSearch — not configured (skipped)")
|
|
134
|
+
|
|
135
|
+
url = f"http://{host}:{port}/_cluster/health"
|
|
136
|
+
try:
|
|
137
|
+
urllib.request.urlopen(url, timeout=5) # noqa: S310
|
|
138
|
+
return True, _ok("OpenSearch")
|
|
139
|
+
except Exception as exc: # noqa: BLE001
|
|
140
|
+
return False, _fail(f"OpenSearch — {exc}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def check_platform_key(plugin_dir: str | Path = "") -> tuple[bool, str]:
|
|
144
|
+
"""
|
|
145
|
+
Check for ANTHROPIC_PLATFORM_AWS_API_KEY — non-fatal, always returns True.
|
|
146
|
+
Resolution order: env var → plugin_dir/.env file.
|
|
147
|
+
"""
|
|
148
|
+
key = os.environ.get("ANTHROPIC_PLATFORM_AWS_API_KEY", "")
|
|
149
|
+
|
|
150
|
+
if not key and plugin_dir:
|
|
151
|
+
env_file = Path(plugin_dir) / ".env"
|
|
152
|
+
if env_file.exists():
|
|
153
|
+
for line in env_file.read_text().splitlines():
|
|
154
|
+
if line.startswith("ANTHROPIC_PLATFORM_AWS_API_KEY="):
|
|
155
|
+
key = line.split("=", 1)[1].strip()
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
if not key:
|
|
159
|
+
return True, _warn(
|
|
160
|
+
"ANTHROPIC_PLATFORM_AWS_API_KEY not set — token overflow fallback disabled.\n"
|
|
161
|
+
" Set it in ~/.agent101/config.json or .env to enable Claude Platform on AWS fallback."
|
|
162
|
+
)
|
|
163
|
+
return True, _ok("ANTHROPIC_PLATFORM_AWS_API_KEY present")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# OpenSearch host resolution
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def _resolve_opensearch_host() -> tuple[str, str]:
|
|
171
|
+
"""
|
|
172
|
+
Resolution order:
|
|
173
|
+
1. ~/.agent101/config.json → OPENSEARCH_HOST / OPENSEARCH_PORT
|
|
174
|
+
2. Environment variables OPENSEARCH_HOST / OPENSEARCH_PORT
|
|
175
|
+
3. Empty string → caller interprets as "not configured"
|
|
176
|
+
"""
|
|
177
|
+
config_path = Path.home() / ".agent101" / "config.json"
|
|
178
|
+
config: dict = {}
|
|
179
|
+
if config_path.exists():
|
|
180
|
+
try:
|
|
181
|
+
config = json.loads(config_path.read_text())
|
|
182
|
+
except (json.JSONDecodeError, OSError):
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
host = config.get("OPENSEARCH_HOST") or os.environ.get("OPENSEARCH_HOST", "")
|
|
186
|
+
port = config.get("OPENSEARCH_PORT") or os.environ.get("OPENSEARCH_PORT", "9200")
|
|
187
|
+
return host, str(port)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# run_all — called by install.sh
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def run_all(plugin_dir: str = "") -> int:
|
|
195
|
+
"""
|
|
196
|
+
Run all service checks, print results, return count of failed AWS checks.
|
|
197
|
+
OpenSearch skips and platform key warnings do NOT increment the error count.
|
|
198
|
+
"""
|
|
199
|
+
print(f"\n\033[1mValidating AWS connectivity\033[0m")
|
|
200
|
+
|
|
201
|
+
aws_errors = 0
|
|
202
|
+
|
|
203
|
+
for check_fn in (check_dynamodb, check_s3, check_bedrock):
|
|
204
|
+
passed, message = check_fn()
|
|
205
|
+
print(message)
|
|
206
|
+
if not passed:
|
|
207
|
+
aws_errors += 1
|
|
208
|
+
|
|
209
|
+
host, port = _resolve_opensearch_host()
|
|
210
|
+
passed, message = check_opensearch(host, port)
|
|
211
|
+
print(message)
|
|
212
|
+
if not passed:
|
|
213
|
+
aws_errors += 1
|
|
214
|
+
|
|
215
|
+
# Platform key — non-fatal, never increments aws_errors
|
|
216
|
+
_, message = check_platform_key(plugin_dir)
|
|
217
|
+
print(message)
|
|
218
|
+
|
|
219
|
+
if aws_errors > 0:
|
|
220
|
+
print(
|
|
221
|
+
f"\n {YELLOW}⚠{NC} AWS validation: {aws_errors} service(s) unreachable. "
|
|
222
|
+
"Personal mode features require working AWS credentials.\n"
|
|
223
|
+
" Fix credentials then re-run ./install.sh"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return aws_errors
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Entry point — called by install.sh as:
|
|
231
|
+
# python3 "$PLUGIN_DIR/server/validate.py" "$PLUGIN_DIR"
|
|
232
|
+
# Always exits 0 (advisory only).
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
plugin_dir = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
236
|
+
run_all(plugin_dir)
|
|
237
|
+
sys.exit(0)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-skill
|
|
3
|
+
description: Install an agent101 skill package by copying it into skills/ and generating a registry.json entry.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /add-skill
|
|
7
|
+
|
|
8
|
+
Use when the user invokes `/add-skill` or asks to install a skill package into agent101.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Ask for the source path if the user did not provide it.
|
|
13
|
+
2. Ask for the skill name if it cannot be inferred from the package `SKILL.md`.
|
|
14
|
+
3. If the skill already exists, prompt the user to confirm overwrite before proceeding.
|
|
15
|
+
4. Run the installer module:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
python3 -m server.tools.skill_installer <source_path> [--name <name>] [--overwrite]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
5. Confirm the generated `registry.json` entry and install location.
|
|
22
|
+
|
|
23
|
+
## Error Handling
|
|
24
|
+
|
|
25
|
+
Use this recovery format exactly.
|
|
26
|
+
|
|
27
|
+
If the installer fails, respond with:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
Failed operation: add_skill
|
|
31
|
+
Reason: <installer error>
|
|
32
|
+
Recovery steps:
|
|
33
|
+
- Verify the source path is a directory.
|
|
34
|
+
- Verify the source directory contains SKILL.md.
|
|
35
|
+
- If the skill already exists, rerun only after confirming overwrite.
|
|
36
|
+
- Check registry.json remains valid JSON.
|
|
37
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: afk-status
|
|
3
|
+
description: Report current AFK execution progress for the active thread.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /afk-status
|
|
7
|
+
|
|
8
|
+
Use when the developer invokes `/afk-status` or asks whether an AFK session is running.
|
|
9
|
+
|
|
10
|
+
Call the `afk_status` Tier 1 tool with the active thread unless the user supplies a specific thread id.
|
|
11
|
+
|
|
12
|
+
If no session is active, show the tool message exactly:
|
|
13
|
+
|
|
14
|
+
`No AFK session running`
|
|
15
|
+
|
|
16
|
+
If a session is active, report:
|
|
17
|
+
- current step
|
|
18
|
+
- steps completed / total
|
|
19
|
+
- elapsed time
|
|
20
|
+
- last command output
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bmad
|
|
3
|
+
description: Use when the user wants to create a PRD, review stories, or run BMAD workflows.
|
|
4
|
+
module: server.tools.ecc.web
|
|
5
|
+
tools: ["web_fetch", "web_scrape"]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# /bmad
|
|
9
|
+
|
|
10
|
+
Use when the user wants to create a PRD, review stories, or run BMAD workflows.
|
|
11
|
+
|
|
12
|
+
This skill package registers the BMAD workflow metadata for automatic detection and lazy-loaded ECC tool usage.
|
|
13
|
+
|
|
14
|
+
Call `load_skill_tools("bmad")` when this trigger matches.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: help-agent101
|
|
3
|
+
description: Show a current structured listing of agent101 commands, tools, skills, personas, and ECC categories.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /help-agent101
|
|
7
|
+
|
|
8
|
+
Use when the user invokes `/help-agent101` or asks what agent101 can do.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Call `help_agent101` to get the current capability index.
|
|
13
|
+
2. Present the result in these sections:
|
|
14
|
+
- Slash commands
|
|
15
|
+
- Tier 1 MCP tools
|
|
16
|
+
- Registered skill packages
|
|
17
|
+
- Available personas
|
|
18
|
+
- ECC tool categories
|
|
19
|
+
3. Keep the output concise. Include command/tool names and one-line descriptions only.
|
|
20
|
+
4. If the user asks for installed skills specifically, call `list_registry`.
|
|
21
|
+
5. If the user asks for personas specifically, call `list_personas`.
|
|
22
|
+
|
|
23
|
+
## Required Coverage
|
|
24
|
+
|
|
25
|
+
The slash-command section must include:
|
|
26
|
+
|
|
27
|
+
- `/new-thread`
|
|
28
|
+
- `/switch-thread`
|
|
29
|
+
- `/kill-thread`
|
|
30
|
+
- `/list-threads`
|
|
31
|
+
- `/recall`
|
|
32
|
+
- `/add-skill`
|
|
33
|
+
- `/open-threads-ui`
|
|
34
|
+
|
|
35
|
+
## Error Handling
|
|
36
|
+
|
|
37
|
+
Use this recovery format exactly.
|
|
38
|
+
|
|
39
|
+
If capability discovery fails, respond with:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
Failed operation: help_agent101
|
|
43
|
+
Reason: <tool error>
|
|
44
|
+
Recovery steps:
|
|
45
|
+
- Try `list_registry` to inspect installed skills.
|
|
46
|
+
- Try `list_personas` to inspect available personas.
|
|
47
|
+
- Check registry.json remains valid JSON.
|
|
48
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kill-thread
|
|
3
|
+
description: Close an agent101 thread with kill_thread and confirm that async summarization has started.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /kill-thread
|
|
7
|
+
|
|
8
|
+
Use when the user invokes `/kill-thread` or asks to close, kill, archive, or summarize a Tylor thread.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. If the user did not provide a thread ID, call `list_threads()` and ask which active thread to kill.
|
|
13
|
+
2. Call `kill_thread(thread_id: str)`.
|
|
14
|
+
3. Show a formatted confirmation:
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
Killing thread: <thread_id>
|
|
18
|
+
Status: killing
|
|
19
|
+
Message: Summarization in progress
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Error Handling
|
|
23
|
+
|
|
24
|
+
Use this recovery format exactly.
|
|
25
|
+
|
|
26
|
+
If the tool fails, respond with:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
Failed operation: kill_thread
|
|
30
|
+
Reason: <tool error>
|
|
31
|
+
Recovery steps:
|
|
32
|
+
- Run /list-threads and verify the thread ID.
|
|
33
|
+
- Retry /kill-thread with an active thread ID.
|
|
34
|
+
- If summarization fails later, the fallback summary will store raw last messages.
|
|
35
|
+
```
|