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
package/registry.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0",
|
|
3
|
+
"skills": [
|
|
4
|
+
{
|
|
5
|
+
"name": "bmad",
|
|
6
|
+
"trigger": "Use when the user wants to create a PRD, review stories, or run BMAD workflows.",
|
|
7
|
+
"trigger_description": "Use when the user wants to create a PRD, review stories, or run BMAD workflows.",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"bmad",
|
|
10
|
+
"create",
|
|
11
|
+
"prd",
|
|
12
|
+
"review",
|
|
13
|
+
"stories",
|
|
14
|
+
"workflows"
|
|
15
|
+
],
|
|
16
|
+
"tool_count": 1,
|
|
17
|
+
"installed_date": "2026-05-13",
|
|
18
|
+
"source_path": "skills/bmad",
|
|
19
|
+
"module": "server.tools.ecc.web",
|
|
20
|
+
"tools": [
|
|
21
|
+
"web_fetch",
|
|
22
|
+
"web_scrape"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copy this file to server/.env and fill in your values
|
|
2
|
+
# Required only for Personal (DynamoDB) storage mode
|
|
3
|
+
# Project JSON mode works with zero configuration
|
|
4
|
+
|
|
5
|
+
# AWS — required for DynamoDB/S3/OpenSearch/Bedrock
|
|
6
|
+
AWS_REGION=us-east-1
|
|
7
|
+
AWS_ACCESS_KEY_ID=
|
|
8
|
+
AWS_SECRET_ACCESS_KEY=
|
|
9
|
+
AWS_PROFILE= # optional: use named AWS profile instead of keys
|
|
10
|
+
|
|
11
|
+
# DynamoDB table names (created by install.sh)
|
|
12
|
+
DYNAMO_TABLE=agent101
|
|
13
|
+
|
|
14
|
+
# Bedrock model IDs
|
|
15
|
+
BEDROCK_REGION=us-east-1
|
|
16
|
+
BEDROCK_OPUS_MODEL=us.anthropic.claude-opus-4-7-20251101-v1:0
|
|
17
|
+
|
|
18
|
+
# OpenSearch (optional — enables semantic memory recall)
|
|
19
|
+
OPENSEARCH_HOST=
|
|
20
|
+
OPENSEARCH_PORT=9200
|
|
21
|
+
OPENSEARCH_INDEX=agent-memories
|
|
22
|
+
|
|
23
|
+
# Token overflow fallback (optional)
|
|
24
|
+
ANTHROPIC_PLATFORM_AWS_API_KEY=
|
|
File without changes
|
package/server/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/config.py — Agent101 server configuration.
|
|
3
|
+
Reads AWS profile, Bedrock region, and Platform on AWS key.
|
|
4
|
+
Resolution order: env var → ~/.agent101/config.json → .env file → defaults.
|
|
5
|
+
Warns on missing optional config; never crashes on startup.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
load_dotenv(Path(__file__).parent / ".env")
|
|
16
|
+
except ImportError:
|
|
17
|
+
pass # python-dotenv optional at import time; already in requirements.txt
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
CONFIG_PATH = Path.home() / ".agent101" / "config.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_file_config() -> dict:
|
|
25
|
+
if CONFIG_PATH.exists():
|
|
26
|
+
try:
|
|
27
|
+
with open(CONFIG_PATH) as f:
|
|
28
|
+
data = json.load(f)
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
logger.warning("~/.agent101/config.json is not a JSON object — ignoring")
|
|
31
|
+
return {}
|
|
32
|
+
return data
|
|
33
|
+
except (json.JSONDecodeError, OSError):
|
|
34
|
+
logger.warning("~/.agent101/config.json is malformed — ignoring")
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load() -> dict:
|
|
39
|
+
file_cfg = _load_file_config()
|
|
40
|
+
|
|
41
|
+
def _get(env_key: str, file_key: str | None = None, default: str | None = None) -> str | None:
|
|
42
|
+
env_val = os.environ.get(env_key)
|
|
43
|
+
if env_val is not None:
|
|
44
|
+
return env_val or None
|
|
45
|
+
return file_cfg.get(file_key or env_key) or default
|
|
46
|
+
|
|
47
|
+
def _get_any(env_keys: tuple[str, ...], default: str | None = None) -> str | None:
|
|
48
|
+
for env_key in env_keys:
|
|
49
|
+
env_val = os.environ.get(env_key)
|
|
50
|
+
if env_val is not None:
|
|
51
|
+
return env_val or None
|
|
52
|
+
file_val = file_cfg.get(env_key)
|
|
53
|
+
if file_val:
|
|
54
|
+
return file_val
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
cfg = {
|
|
58
|
+
"aws_profile": _get("AWS_PROFILE"),
|
|
59
|
+
"aws_access_key_id": _get("AWS_ACCESS_KEY_ID"),
|
|
60
|
+
"bedrock_region": _get("BEDROCK_REGION", default="us-east-1"),
|
|
61
|
+
"bedrock_opus_model": _get(
|
|
62
|
+
"BEDROCK_OPUS_MODEL",
|
|
63
|
+
default="us.anthropic.claude-opus-4-7-20251101-v1:0",
|
|
64
|
+
),
|
|
65
|
+
"platform_key": _get_any(("ANTHROPIC_PLATFORM_AWS_API_KEY", "ANTHROPIC_AWS_API_KEY")),
|
|
66
|
+
"platform_base_url": _get("ANTHROPIC_AWS_BASE_URL"),
|
|
67
|
+
"platform_workspace_id": _get("ANTHROPIC_AWS_WORKSPACE_ID"),
|
|
68
|
+
"dynamo_table": _get("DYNAMO_TABLE", default="agent101"),
|
|
69
|
+
"s3_bucket": _get("S3_BUCKET"),
|
|
70
|
+
"opensearch_host": _get("OPENSEARCH_HOST"),
|
|
71
|
+
"opensearch_port": _get("OPENSEARCH_PORT", default="9200"),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Warn on optional keys that affect runtime features
|
|
75
|
+
if not cfg["platform_key"]:
|
|
76
|
+
logger.warning(
|
|
77
|
+
"ANTHROPIC_PLATFORM_AWS_API_KEY/ANTHROPIC_AWS_API_KEY not set — "
|
|
78
|
+
"token overflow fallback to Claude Platform on AWS is disabled"
|
|
79
|
+
)
|
|
80
|
+
if not cfg["opensearch_host"]:
|
|
81
|
+
logger.warning(
|
|
82
|
+
"OPENSEARCH_HOST not set — "
|
|
83
|
+
"semantic memory recall (recall_memory) will be unavailable until configured"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return cfg
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
config = _load()
|
package/server/main.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/main.py — agent101 FastMCP entry point.
|
|
3
|
+
|
|
4
|
+
Starts both FastMCP (stdio transport) and the aiohttp Thread Visualizer
|
|
5
|
+
server concurrently on a single asyncio event loop.
|
|
6
|
+
|
|
7
|
+
Can be run as a script: python3 server/main.py (from the plugin root)
|
|
8
|
+
or as a module: python3 -m server.main
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
# Allow running as a standalone script OR as a module.
|
|
18
|
+
# When run as `python3 server/main.py`, __package__ is None and relative
|
|
19
|
+
# imports fail. This block ensures the plugin root is on sys.path so that
|
|
20
|
+
# `from server.tools...` absolute imports work in both modes.
|
|
21
|
+
if __package__ is None or __package__ == "":
|
|
22
|
+
_plugin_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
if _plugin_root not in sys.path:
|
|
24
|
+
sys.path.insert(0, _plugin_root)
|
|
25
|
+
|
|
26
|
+
from server.tools._mcp import mcp # noqa: F401
|
|
27
|
+
|
|
28
|
+
# Register @mcp.tool() decorators (side effects)
|
|
29
|
+
from server.tools import tylor # noqa: F401
|
|
30
|
+
from server.tools import agents # noqa: F401
|
|
31
|
+
from server.tools import registry # noqa: F401
|
|
32
|
+
from server.tools import skill_installer # noqa: F401
|
|
33
|
+
from server.tools import help # noqa: F401
|
|
34
|
+
from server.tools import executor # noqa: F401
|
|
35
|
+
from server.tools import ui # noqa: F401
|
|
36
|
+
from server.tools import harness # noqa: F401 — Agent SDK orchestration
|
|
37
|
+
from server import config # noqa: F401
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _main(ui_only: bool = False) -> None:
|
|
43
|
+
from server.ui_server import start_ui_server
|
|
44
|
+
|
|
45
|
+
runner = await start_ui_server()
|
|
46
|
+
|
|
47
|
+
from server.ui_server import ui_available
|
|
48
|
+
if not ui_available:
|
|
49
|
+
print(
|
|
50
|
+
"Thread UI could not start (port 8765 in use) — MCP tools unaffected",
|
|
51
|
+
file=sys.stderr,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
loop = asyncio.get_running_loop()
|
|
55
|
+
stop_event = asyncio.Event()
|
|
56
|
+
|
|
57
|
+
def _signal_handler() -> None:
|
|
58
|
+
stop_event.set()
|
|
59
|
+
|
|
60
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
61
|
+
try:
|
|
62
|
+
loop.add_signal_handler(sig, _signal_handler)
|
|
63
|
+
except NotImplementedError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
if ui_only:
|
|
67
|
+
logger.info("Running in UI-only mode — http://localhost:8765")
|
|
68
|
+
await stop_event.wait()
|
|
69
|
+
else:
|
|
70
|
+
mcp_task = asyncio.create_task(mcp.run_stdio_async())
|
|
71
|
+
stop_task = asyncio.create_task(stop_event.wait())
|
|
72
|
+
done, pending = await asyncio.wait(
|
|
73
|
+
[mcp_task, stop_task],
|
|
74
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
75
|
+
)
|
|
76
|
+
for task in pending:
|
|
77
|
+
task.cancel()
|
|
78
|
+
try:
|
|
79
|
+
await task
|
|
80
|
+
except (asyncio.CancelledError, Exception):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
if runner is not None:
|
|
84
|
+
await runner.cleanup()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
import argparse
|
|
89
|
+
parser = argparse.ArgumentParser()
|
|
90
|
+
parser.add_argument('--ui-only', action='store_true',
|
|
91
|
+
help='Run only the aiohttp visualizer server (no MCP stdio)')
|
|
92
|
+
args = parser.parse_args()
|
|
93
|
+
asyncio.run(_main(ui_only=args.ui_only))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Analyst
|
|
2
|
+
|
|
3
|
+
## Role Description
|
|
4
|
+
|
|
5
|
+
Research and analysis specialist focused on market context, competitive comparison, evidence synthesis, and decision support.
|
|
6
|
+
|
|
7
|
+
## Communication Style
|
|
8
|
+
|
|
9
|
+
Structured, evidence-oriented, and concise. Separates facts, assumptions, and recommendations.
|
|
10
|
+
|
|
11
|
+
## ECC Tool Categories
|
|
12
|
+
|
|
13
|
+
- `ecc/web`
|
|
14
|
+
- `ecc/data`
|
|
15
|
+
- `ecc/diagrams`
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# CEO
|
|
2
|
+
|
|
3
|
+
## Role Description
|
|
4
|
+
|
|
5
|
+
Strategic executive persona focused on business direction, prioritization, stakeholder framing, and high-leverage product decisions.
|
|
6
|
+
|
|
7
|
+
## Communication Style
|
|
8
|
+
|
|
9
|
+
Outcome-driven, decisive, and clear. Frames tradeoffs in terms of impact, risk, and sequencing.
|
|
10
|
+
|
|
11
|
+
## ECC Tool Categories
|
|
12
|
+
|
|
13
|
+
- `ecc/presentation`
|
|
14
|
+
- `ecc/web`
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Code Agent
|
|
2
|
+
|
|
3
|
+
## Role Description
|
|
4
|
+
|
|
5
|
+
Senior implementation engineer focused on shipping correct code, tests, and maintainable technical changes inside the active thread scope.
|
|
6
|
+
|
|
7
|
+
## Communication Style
|
|
8
|
+
|
|
9
|
+
Direct, concrete, and implementation-first. States assumptions, changed files, validation commands, and residual risks.
|
|
10
|
+
|
|
11
|
+
## ECC Tool Categories
|
|
12
|
+
|
|
13
|
+
- `ecc/web`
|
|
14
|
+
- `ecc/data`
|
|
15
|
+
- `ecc/pipeline`
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# CTO
|
|
2
|
+
|
|
3
|
+
## Role Description
|
|
4
|
+
|
|
5
|
+
Technical leadership persona focused on architecture, systems tradeoffs, delivery risk, platform strategy, and engineering standards.
|
|
6
|
+
|
|
7
|
+
## Communication Style
|
|
8
|
+
|
|
9
|
+
Pragmatic, precise, and systems-minded. Connects technical choices to constraints, failure modes, and maintainability.
|
|
10
|
+
|
|
11
|
+
## ECC Tool Categories
|
|
12
|
+
|
|
13
|
+
- `ecc/diagrams`
|
|
14
|
+
- `ecc/pipeline`
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Story 1.3: DynamoDB Table & S3 Bucket Provisioning
|
|
3
|
+
Each provision_*() returns (passed: bool, message: str).
|
|
4
|
+
run_all() prints results and returns the count of provisioning errors.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Colour helpers (same as validate.py)
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
GREEN = "\033[0;32m"
|
|
18
|
+
RED = "\033[0;31m"
|
|
19
|
+
YELLOW = "\033[1;33m"
|
|
20
|
+
NC = "\033[0m"
|
|
21
|
+
|
|
22
|
+
def _ok(msg: str) -> str:
|
|
23
|
+
return f" {GREEN}✓{NC} {msg}"
|
|
24
|
+
|
|
25
|
+
def _fail(msg: str) -> str:
|
|
26
|
+
return f" {RED}✗{NC} {msg}"
|
|
27
|
+
|
|
28
|
+
def _warn(msg: str) -> str:
|
|
29
|
+
return f" {YELLOW}⚠{NC} {msg}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Config resolution
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def _resolve_config(plugin_dir: str = "") -> dict:
|
|
37
|
+
"""
|
|
38
|
+
Resolve provisioning config.
|
|
39
|
+
Priority: ~/.agent101/config.json → env vars → defaults.
|
|
40
|
+
Default bucket name derived from AWS account ID (guarantees global uniqueness).
|
|
41
|
+
"""
|
|
42
|
+
config_path = Path.home() / ".agent101" / "config.json"
|
|
43
|
+
file_config: dict = {}
|
|
44
|
+
if config_path.exists():
|
|
45
|
+
try:
|
|
46
|
+
file_config = json.loads(config_path.read_text())
|
|
47
|
+
except (json.JSONDecodeError, OSError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
table_name = (
|
|
51
|
+
file_config.get("DYNAMO_TABLE")
|
|
52
|
+
or os.environ.get("DYNAMO_TABLE", "")
|
|
53
|
+
or "agent101"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
region = (
|
|
57
|
+
file_config.get("AWS_REGION")
|
|
58
|
+
or os.environ.get("AWS_REGION")
|
|
59
|
+
or os.environ.get("AWS_DEFAULT_REGION", "")
|
|
60
|
+
or "us-east-1"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Bucket name: config → env → default using account ID
|
|
64
|
+
bucket_name = file_config.get("S3_BUCKET") or os.environ.get("S3_BUCKET", "")
|
|
65
|
+
if not bucket_name:
|
|
66
|
+
try:
|
|
67
|
+
import boto3 # noqa: PLC0415
|
|
68
|
+
account_id = boto3.client("sts").get_caller_identity()["Account"]
|
|
69
|
+
bucket_name = f"agent101-blobs-{account_id}"
|
|
70
|
+
except Exception: # noqa: BLE001
|
|
71
|
+
bucket_name = "agent101-blobs"
|
|
72
|
+
|
|
73
|
+
return {"table_name": table_name, "bucket_name": bucket_name, "region": region}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# DynamoDB provisioning
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def provision_dynamodb(table_name: str) -> tuple[bool, str]:
|
|
81
|
+
"""
|
|
82
|
+
Create DynamoDB table if absent (PAY_PER_REQUEST, PK+SK, PITR).
|
|
83
|
+
Skip + validate schema if present.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
import boto3 # noqa: PLC0415
|
|
87
|
+
from botocore.exceptions import ClientError # noqa: PLC0415
|
|
88
|
+
|
|
89
|
+
client = boto3.client("dynamodb")
|
|
90
|
+
|
|
91
|
+
# Check if table already exists
|
|
92
|
+
try:
|
|
93
|
+
response = client.describe_table(TableName=table_name)
|
|
94
|
+
# Validate schema compatibility
|
|
95
|
+
key_attrs = {k["AttributeName"] for k in response["Table"]["KeySchema"]}
|
|
96
|
+
if {"PK", "SK"}.issubset(key_attrs):
|
|
97
|
+
# Ensure PITR is enabled even on pre-existing tables
|
|
98
|
+
pitr = client.describe_continuous_backups(TableName=table_name)
|
|
99
|
+
pitr_status = (
|
|
100
|
+
pitr["ContinuousBackupsDescription"]
|
|
101
|
+
["PointInTimeRecoveryDescription"]
|
|
102
|
+
["PointInTimeRecoveryStatus"]
|
|
103
|
+
)
|
|
104
|
+
if pitr_status != "ENABLED":
|
|
105
|
+
client.update_continuous_backups(
|
|
106
|
+
TableName=table_name,
|
|
107
|
+
PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": True},
|
|
108
|
+
)
|
|
109
|
+
return True, _ok(f"DynamoDB table '{table_name}' already exists — skipping")
|
|
110
|
+
else:
|
|
111
|
+
return True, _warn(
|
|
112
|
+
f"DynamoDB table '{table_name}' exists but schema is incompatible "
|
|
113
|
+
f"(found keys: {key_attrs}). agent101 requires PK+SK. "
|
|
114
|
+
"Proceeding — rename the table in ~/.agent101/config.json if needed."
|
|
115
|
+
)
|
|
116
|
+
except ClientError as e:
|
|
117
|
+
if e.response["Error"]["Code"] != "ResourceNotFoundException":
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
# Table does not exist — create it
|
|
121
|
+
client.create_table(
|
|
122
|
+
TableName=table_name,
|
|
123
|
+
KeySchema=[
|
|
124
|
+
{"AttributeName": "PK", "KeyType": "HASH"},
|
|
125
|
+
{"AttributeName": "SK", "KeyType": "RANGE"},
|
|
126
|
+
],
|
|
127
|
+
AttributeDefinitions=[
|
|
128
|
+
{"AttributeName": "PK", "AttributeType": "S"},
|
|
129
|
+
{"AttributeName": "SK", "AttributeType": "S"},
|
|
130
|
+
],
|
|
131
|
+
BillingMode="PAY_PER_REQUEST",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Wait for ACTIVE state before enabling PITR
|
|
135
|
+
waiter = client.get_waiter("table_exists")
|
|
136
|
+
waiter.wait(
|
|
137
|
+
TableName=table_name,
|
|
138
|
+
WaiterConfig={"Delay": 2, "MaxAttempts": 20},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# PITR may not be available immediately after ACTIVE — retry up to 5×
|
|
142
|
+
import time # noqa: PLC0415
|
|
143
|
+
for attempt in range(5):
|
|
144
|
+
try:
|
|
145
|
+
client.update_continuous_backups(
|
|
146
|
+
TableName=table_name,
|
|
147
|
+
PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": True},
|
|
148
|
+
)
|
|
149
|
+
break
|
|
150
|
+
except ClientError as e:
|
|
151
|
+
if e.response["Error"]["Code"] == "ContinuousBackupsUnavailableException" and attempt < 4:
|
|
152
|
+
time.sleep(3)
|
|
153
|
+
else:
|
|
154
|
+
raise
|
|
155
|
+
|
|
156
|
+
return True, _ok(f"DynamoDB table '{table_name}' created")
|
|
157
|
+
|
|
158
|
+
except ImportError:
|
|
159
|
+
return False, _fail("DynamoDB provisioning — boto3 not installed")
|
|
160
|
+
except Exception as exc: # noqa: BLE001
|
|
161
|
+
return False, _fail(f"DynamoDB provisioning failed — {exc}\n Fix: check IAM permissions (dynamodb:CreateTable, dynamodb:UpdateContinuousBackups)")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# S3 provisioning
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def provision_s3(bucket_name: str, region: str = "us-east-1") -> tuple[bool, str]:
|
|
169
|
+
"""
|
|
170
|
+
Create S3 bucket if absent with private ACL + block public access.
|
|
171
|
+
Skip if already owned by this account.
|
|
172
|
+
NOTE: us-east-1 must NOT pass CreateBucketConfiguration (boto3 quirk).
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
import boto3 # noqa: PLC0415
|
|
176
|
+
from botocore.exceptions import ClientError # noqa: PLC0415
|
|
177
|
+
|
|
178
|
+
client = boto3.client("s3", region_name=region)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
if region == "us-east-1":
|
|
182
|
+
client.create_bucket(Bucket=bucket_name)
|
|
183
|
+
else:
|
|
184
|
+
client.create_bucket(
|
|
185
|
+
Bucket=bucket_name,
|
|
186
|
+
CreateBucketConfiguration={"LocationConstraint": region},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Block all public access (security baseline)
|
|
190
|
+
client.put_public_access_block(
|
|
191
|
+
Bucket=bucket_name,
|
|
192
|
+
PublicAccessBlockConfiguration={
|
|
193
|
+
"BlockPublicAcls": True,
|
|
194
|
+
"IgnorePublicAcls": True,
|
|
195
|
+
"BlockPublicPolicy": True,
|
|
196
|
+
"RestrictPublicBuckets": True,
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
return True, _ok(f"S3 bucket '{bucket_name}' created")
|
|
200
|
+
|
|
201
|
+
except ClientError as e:
|
|
202
|
+
code = e.response["Error"]["Code"]
|
|
203
|
+
if code == "BucketAlreadyOwnedByYou":
|
|
204
|
+
return True, _ok(f"S3 bucket '{bucket_name}' already exists — skipping")
|
|
205
|
+
if code == "BucketAlreadyExists":
|
|
206
|
+
return False, _fail(
|
|
207
|
+
f"S3 bucket '{bucket_name}' already exists and is owned by another account.\n"
|
|
208
|
+
" Fix: set a unique S3_BUCKET name in ~/.agent101/config.json"
|
|
209
|
+
)
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
except ImportError:
|
|
213
|
+
return False, _fail("S3 provisioning — boto3 not installed")
|
|
214
|
+
except Exception as exc: # noqa: BLE001
|
|
215
|
+
return False, _fail(f"S3 provisioning failed — {exc}\n Fix: check IAM permissions (s3:CreateBucket, s3:PutPublicAccessBlock)")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# run_all — called by install.sh
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
def run_all(plugin_dir: str = "") -> int:
|
|
223
|
+
"""
|
|
224
|
+
Provision all AWS resources, print results, return error count.
|
|
225
|
+
Caller (install.sh) always exits 0 — this is advisory.
|
|
226
|
+
"""
|
|
227
|
+
print(f"\n\033[1mProvisioning AWS resources\033[0m")
|
|
228
|
+
|
|
229
|
+
config = _resolve_config(plugin_dir)
|
|
230
|
+
errors = 0
|
|
231
|
+
|
|
232
|
+
passed, msg = provision_dynamodb(config["table_name"])
|
|
233
|
+
print(msg)
|
|
234
|
+
if not passed:
|
|
235
|
+
errors += 1
|
|
236
|
+
|
|
237
|
+
passed, msg = provision_s3(config["bucket_name"], config["region"])
|
|
238
|
+
print(msg)
|
|
239
|
+
if not passed:
|
|
240
|
+
errors += 1
|
|
241
|
+
|
|
242
|
+
if errors > 0:
|
|
243
|
+
print(
|
|
244
|
+
f"\n {YELLOW}⚠{NC} Provisioning: {errors} resource(s) failed. "
|
|
245
|
+
"Personal mode features require DynamoDB + S3.\n"
|
|
246
|
+
" Fix the errors above then re-run ./install.sh"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return errors
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Entry point — called by install.sh as:
|
|
254
|
+
# python3 "$PLUGIN_DIR/server/provision.py" "$PLUGIN_DIR"
|
|
255
|
+
# Always exits 0 (advisory).
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
plugin_dir = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
259
|
+
run_all(plugin_dir)
|
|
260
|
+
sys.exit(0)
|