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.
Files changed (101) hide show
  1. package/.aws-setup.sh +25 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.mcp.json +12 -0
  4. package/AGENTS.md +93 -0
  5. package/CLAUDE.md +99 -0
  6. package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
  7. package/LICENSE +21 -0
  8. package/README.md +146 -0
  9. package/assets/tylor_logo.png +0 -0
  10. package/assets/tylor_threads_concept.png +0 -0
  11. package/bin/tylor.js +23 -0
  12. package/hooks/kill-thread-trigger.sh +7 -0
  13. package/hooks/post-tool-use-code-index.sh +7 -0
  14. package/hooks/session-checkpoint.sh +7 -0
  15. package/hooks/session-start.sh +7 -0
  16. package/install.py +401 -0
  17. package/install.sh +260 -0
  18. package/package.json +24 -0
  19. package/pytest.ini +2 -0
  20. package/registry.json +26 -0
  21. package/server/.env.example +24 -0
  22. package/server/__init__.py +0 -0
  23. package/server/config.py +89 -0
  24. package/server/main.py +93 -0
  25. package/server/personas/analyst.md +15 -0
  26. package/server/personas/ceo.md +14 -0
  27. package/server/personas/code_agent.md +15 -0
  28. package/server/personas/cto.md +14 -0
  29. package/server/provision.py +260 -0
  30. package/server/provision_opensearch.py +154 -0
  31. package/server/requirements.txt +26 -0
  32. package/server/storage/__init__.py +0 -0
  33. package/server/storage/dynamo.py +399 -0
  34. package/server/storage/json_store.py +359 -0
  35. package/server/storage/opensearch.py +194 -0
  36. package/server/storage/s3.py +96 -0
  37. package/server/storage/tests/__init__.py +0 -0
  38. package/server/storage/tests/test_dynamo.py +452 -0
  39. package/server/storage/tests/test_json_store.py +226 -0
  40. package/server/storage/tests/test_opensearch.py +270 -0
  41. package/server/storage/tests/test_s3.py +125 -0
  42. package/server/tests/__init__.py +0 -0
  43. package/server/tests/test_install.py +606 -0
  44. package/server/tests/test_isolation.py +90 -0
  45. package/server/tests/test_ui_server.py +385 -0
  46. package/server/tests/test_ui_shader_background.py +52 -0
  47. package/server/tests/test_ui_story_6_3.py +105 -0
  48. package/server/tools/__init__.py +0 -0
  49. package/server/tools/_mcp.py +4 -0
  50. package/server/tools/agents.py +160 -0
  51. package/server/tools/ecc/__init__.py +1 -0
  52. package/server/tools/ecc/data.py +35 -0
  53. package/server/tools/ecc/diagrams.py +23 -0
  54. package/server/tools/ecc/pipeline.py +24 -0
  55. package/server/tools/ecc/presentation.py +24 -0
  56. package/server/tools/ecc/web.py +23 -0
  57. package/server/tools/executor.py +880 -0
  58. package/server/tools/harness.py +330 -0
  59. package/server/tools/help.py +162 -0
  60. package/server/tools/hooks.py +357 -0
  61. package/server/tools/personas.py +110 -0
  62. package/server/tools/registry.py +195 -0
  63. package/server/tools/router.py +117 -0
  64. package/server/tools/skill_installer.py +230 -0
  65. package/server/tools/summarizer.py +168 -0
  66. package/server/tools/tests/__init__.py +0 -0
  67. package/server/tools/tests/test_agents.py +246 -0
  68. package/server/tools/tests/test_code_index.py +108 -0
  69. package/server/tools/tests/test_ecc_tools.py +51 -0
  70. package/server/tools/tests/test_executor.py +584 -0
  71. package/server/tools/tests/test_help_agent101.py +149 -0
  72. package/server/tools/tests/test_hooks.py +124 -0
  73. package/server/tools/tests/test_kill_thread.py +125 -0
  74. package/server/tools/tests/test_new_thread_list_threads.py +293 -0
  75. package/server/tools/tests/test_personas.py +52 -0
  76. package/server/tools/tests/test_recall_memory.py +55 -0
  77. package/server/tools/tests/test_registry_client.py +308 -0
  78. package/server/tools/tests/test_router.py +263 -0
  79. package/server/tools/tests/test_skill_installer.py +174 -0
  80. package/server/tools/tests/test_switch_thread.py +163 -0
  81. package/server/tools/tests/test_thread_command_skills.py +54 -0
  82. package/server/tools/tests/test_thread_resolver.py +165 -0
  83. package/server/tools/tests/test_tier1_schema.py +296 -0
  84. package/server/tools/thread_resolver.py +75 -0
  85. package/server/tools/tylor.py +374 -0
  86. package/server/tools/ui.py +38 -0
  87. package/server/ui_server.py +292 -0
  88. package/server/validate.py +237 -0
  89. package/skills/add-skill/SKILL.md +37 -0
  90. package/skills/afk-status/SKILL.md +20 -0
  91. package/skills/bmad/SKILL.md +14 -0
  92. package/skills/help-agent101/SKILL.md +48 -0
  93. package/skills/kill-thread/SKILL.md +35 -0
  94. package/skills/list-threads/SKILL.md +35 -0
  95. package/skills/new-thread/SKILL.md +35 -0
  96. package/skills/recall/SKILL.md +39 -0
  97. package/skills/run/SKILL.md +33 -0
  98. package/skills/set-sandbox/SKILL.md +38 -0
  99. package/skills/switch-thread/SKILL.md +38 -0
  100. package/ui/claude-logo.png +0 -0
  101. 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
+ ```