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:
@@ -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(),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user