Add meta-tools, collections, and attribution features

Meta-tools:
- Add ToolStep dataclass for tools calling other tools
- Implement execute_tool_step() with recursion depth limiting
- Add check_dependencies() for validating tool dependencies
- Add 'cmdforge check' command for dependency verification

Collections:
- Add collections API endpoints and database schema
- Create collections web UI (list and detail pages)
- Add collections to navigation header and homepage
- Document collections in REGISTRY.md

Attribution:
- Add source attribution fields to tool detail page
- Document source types (original, adapted, imported)
- Add license field documentation

Documentation updates across README, DESIGN.md, REGISTRY.md,
and new META_TOOLS.md design document.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-03 03:05:23 -04:00
co-authored by Claude Opus 4.5
parent 497fe87fc5
commit 2c139b3982
20 changed files with 1299 additions and 11 deletions
+96 -2
View File
@@ -710,6 +710,96 @@ def create_app() -> Flask:
response.headers["Cache-Control"] = "max-age=3600"
return response
# ─── Collections ─────────────────────────────────────────────────────────────
@app.route("/api/v1/collections", methods=["GET"])
def list_collections() -> Response:
"""List all collections."""
rows = query_all(
g.db,
"SELECT * FROM collections ORDER BY name",
)
data = []
for row in rows:
tools = json.loads(row["tools"]) if row["tools"] else []
tags = json.loads(row["tags"]) if row["tags"] else []
data.append({
"name": row["name"],
"display_name": row["display_name"],
"description": row["description"],
"icon": row["icon"],
"maintainer": row["maintainer"],
"tool_count": len(tools),
"tags": tags,
})
response = jsonify({"data": data})
response.headers["Cache-Control"] = "max-age=3600"
return response
@app.route("/api/v1/collections/<name>", methods=["GET"])
def get_collection(name: str) -> Response:
"""Get collection details with tool information."""
row = query_one(g.db, "SELECT * FROM collections WHERE name = ?", [name])
if not row:
return error_response("COLLECTION_NOT_FOUND", f"Collection '{name}' not found", 404)
tools_refs = json.loads(row["tools"]) if row["tools"] else []
pinned = json.loads(row["pinned"]) if row["pinned"] else {}
tags = json.loads(row["tags"]) if row["tags"] else []
# Fetch tool details for each tool in the collection
tools_data = []
for ref in tools_refs:
parts = ref.split("/")
if len(parts) != 2:
continue
owner, tool_name = parts
tool_row = query_one(
g.db,
"""
SELECT * FROM tools
WHERE owner = ? AND name = ? AND version NOT LIKE '%-%'
ORDER BY id DESC LIMIT 1
""",
[owner, tool_name],
)
if tool_row:
tools_data.append({
"owner": tool_row["owner"],
"name": tool_row["name"],
"version": tool_row["version"],
"description": tool_row["description"],
"category": tool_row["category"],
"downloads": tool_row["downloads"],
"pinned_version": pinned.get(ref),
})
else:
# Tool not found in registry
tools_data.append({
"owner": owner,
"name": tool_name,
"version": None,
"description": None,
"category": None,
"downloads": 0,
"pinned_version": pinned.get(ref),
"missing": True,
})
response = jsonify({
"data": {
"name": row["name"],
"display_name": row["display_name"],
"description": row["description"],
"icon": row["icon"],
"maintainer": row["maintainer"],
"tools": tools_data,
"tags": tags,
}
})
response.headers["Cache-Control"] = "max-age=3600"
return response
@app.route("/api/v1/stats/popular", methods=["GET"])
def popular_tools() -> Response:
limit = min(int(request.args.get("limit", 10)), 50)
@@ -1010,6 +1100,8 @@ def create_app() -> Flask:
description = (data.get("description") or "").strip()
category = (data.get("category") or "").strip() or None
tags = data.get("tags") or []
source = (data.get("source") or "").strip() or None
source_url = (data.get("source_url") or "").strip() or None
if not name or not TOOL_NAME_RE.match(name) or len(name) > MAX_TOOL_NAME_LEN:
return error_response("VALIDATION_ERROR", "Invalid tool name")
@@ -1126,8 +1218,8 @@ def create_app() -> Flask:
INSERT INTO tools (
owner, name, version, description, category, tags, config_yaml, readme,
publisher_id, deprecated, deprecated_message, replacement, downloads,
scrutiny_status, scrutiny_report, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
scrutiny_status, scrutiny_report, source, source_url, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
owner,
@@ -1145,6 +1237,8 @@ def create_app() -> Flask:
0,
scrutiny_status,
scrutiny_json,
source,
source_url,
datetime.utcnow().isoformat(),
],
)
+20
View File
@@ -51,6 +51,8 @@ CREATE TABLE IF NOT EXISTS tools (
downloads INTEGER DEFAULT 0,
scrutiny_status TEXT DEFAULT 'pending',
scrutiny_report TEXT,
source TEXT,
source_url TEXT,
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name, version)
);
@@ -228,6 +230,24 @@ CREATE TABLE IF NOT EXISTS pageviews (
CREATE INDEX IF NOT EXISTS idx_pageviews_path ON pageviews(path, viewed_at DESC);
CREATE INDEX IF NOT EXISTS idx_pageviews_date ON pageviews(date(viewed_at), path);
-- Collections (groups of tools that can be installed together)
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
icon TEXT,
maintainer TEXT NOT NULL,
tools TEXT NOT NULL,
pinned TEXT,
tags TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_collections_name ON collections(name);
CREATE INDEX IF NOT EXISTS idx_collections_maintainer ON collections(maintainer);
"""
+47
View File
@@ -173,6 +173,52 @@ def sync_categories(repo_dir: Path) -> None:
cache_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def sync_collections(conn, repo_dir: Path) -> None:
"""Sync collections from registry repo to database."""
collections_dir = repo_dir / "collections"
if not collections_dir.exists():
return
for collection_path in collections_dir.glob("*.yaml"):
try:
data = load_yaml(collection_path)
name = data.get("name") or collection_path.stem
display_name = data.get("display_name") or name
description = data.get("description")
icon = data.get("icon")
maintainer = data.get("maintainer") or "official"
tools = data.get("tools") or []
pinned = data.get("pinned") or {}
tags = data.get("tags") or []
tools_json = json.dumps(tools)
pinned_json = json.dumps(pinned) if pinned else None
tags_json = json.dumps(tags) if tags else None
existing = query_one(conn, "SELECT id FROM collections WHERE name = ?", [name])
if existing:
conn.execute(
"""
UPDATE collections
SET display_name = ?, description = ?, icon = ?, maintainer = ?,
tools = ?, pinned = ?, tags = ?, updated_at = ?
WHERE id = ?
""",
[display_name, description, icon, maintainer, tools_json,
pinned_json, tags_json, datetime.utcnow().isoformat(), existing["id"]],
)
else:
conn.execute(
"""
INSERT INTO collections (name, display_name, description, icon, maintainer, tools, pinned, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
[name, display_name, description, icon, maintainer, tools_json, pinned_json, tags_json],
)
except Exception:
continue
def sync_from_repo() -> Tuple[bool, str]:
repo_dir = get_repo_dir()
clone_or_update_repo(repo_dir)
@@ -195,6 +241,7 @@ def sync_from_repo() -> Tuple[bool, str]:
upsert_tool(conn, owner, name, data, config_text, readme_text)
except Exception:
continue
sync_collections(conn, repo_dir)
conn.commit()
finally:
conn.close()