Skip to main content

CmdForge Registry Design

Purpose

Build a centralized registry for CmdForge to enable discovery, publishing, dependency management, and future curation at scale.

Terminology

TermDefinition
Tool definitionThe full YAML file in the registry (config.yaml) containing name, steps, arguments, etc.
Tool configThe configuration within a tool definition (arguments, steps, provider settings)
cmdforge.yamlProject manifest file declaring tool dependencies and overrides
config.yamlThe tool definition file, both in registry and when installed locally
OwnerImmutable namespace slug identifying the publisher (e.g., rob, alice)
PublisherA registered user who can publish tools to the registry
Wrapper scriptAuto-generated bash script in ~/.local/bin/ that invokes a tool

Canonical naming: Use CmdForge-Registry (capitalized, hyphenated) for the repository name.

Diagram References

  • System overview: discussions/diagrams/cmdforge-registry_rob_1.puml
  • Data flows: discussions/diagrams/cmdforge-registry_rob_5.puml

System Overview

Users interact via the CLI and a future Web UI. Both call a Registry API hosted at https://cmdforge.brrd.tech/api/v1 (future alias: cmdforge.brrd.tech/api/v1). The API syncs from a Gitea-backed registry repo and maintains a SQLite cache/search index.

Canonical API base path: https://cmdforge.brrd.tech/api/v1

All API endpoints are versioned under /api/v1. When breaking changes are needed, a new version (/api/v2) will be introduced with deprecation notices.

Core API endpoints:

  • GET /api/v1/tools
  • GET /api/v1/tools/search?q=... (with advanced filtering)
  • GET /api/v1/tools/{owner}/{name}
  • GET /api/v1/tools/{owner}/{name}/versions
  • GET /api/v1/tools/{owner}/{name}/download?version=...
  • POST /api/v1/tools (publish)
  • GET /api/v1/categories
  • GET /api/v1/tags (list all tags with counts)
  • GET /api/v1/collections
  • GET /api/v1/collections/{name}
  • GET /api/v1/stats/popular
  • POST /api/v1/webhook/gitea

Pagination

All list endpoints support pagination:

ParameterDefaultMaxDescription
page1-Page number (1-indexed)
per_page20100Items per page
sortdownloads-Sort field
orderdesc-Sort order (asc/desc)

Stable ordering: To ensure deterministic results across pages, sorting includes a secondary key:

  • Primary: requested field (e.g., downloads)
  • Secondary: published_at (desc)
  • Tertiary: id (for absolute stability)
ORDER BY downloads DESC, published_at DESC, id DESC
LIMIT 20 OFFSET 0

Response pagination metadata:

{
"data": [...],
"meta": {
"page": 1,
"per_page": 20,
"total": 142,
"total_pages": 8
}
}

Input Constraints

Size limits to prevent oversized uploads:

FieldMax SizeNotes
config.yaml64 KBTool definition
README.md256 KBDocumentation
Request body512 KBTotal POST payload
Tool name64 charsAlphanumeric + hyphen
Description500 charsShort summary
Tag32 charsIndividual tag
Tags array10 itemsMaximum tags per tool

Validation errors:

{
"error": {
"code": "PAYLOAD_TOO_LARGE",
"message": "config.yaml exceeds 64KB limit",
"details": {
"field": "config",
"size": 72000,
"limit": 65536
}
}
}

Sort Fields and Indexes

Allowed sort fields:

EndpointAllowed sort values
GET /toolsdownloads, published_at, name
GET /tools/searchrelevance, downloads, published_at
GET /categoriesname, tool_count

Invalid sort values return 400:

{"error": {"code": "INVALID_SORT", "message": "Unknown sort field 'foo'. Allowed: downloads, published_at, name"}}

Tags Endpoint

GET /api/v1/tags - List all tags with usage counts.

ParameterDefaultDescription
category-Filter tags to those used in a specific category
limit100Maximum tags to return (max 500)

Response:

{
"data": [
{"name": "cli", "count": 45},
{"name": "ai", "count": 32},
{"name": "text", "count": 28}
],
"meta": {"total": 87}
}

GET /api/v1/tools/search supports advanced filtering beyond basic text search.

ParameterTypeDefaultDescription
qstringrequiredSearch query (uses FTS5 full-text search)
categorystring-Filter by single category
categoriesstring-Filter by multiple categories (comma-separated, OR logic)
tagsstring-Filter by tags (comma-separated, AND logic)
ownerstring-Filter by publisher/owner
min_downloadsint-Minimum download count
max_downloadsint-Maximum download count
published_afterdate-Published after date (ISO 8601: YYYY-MM-DD)
published_beforedate-Published before date (ISO 8601: YYYY-MM-DD)
deprecatedboolfalseInclude deprecated tools
include_facetsboolfalseInclude faceted counts in response
sortstringrelevanceSort by: relevance, downloads, published_at, name
pageint1Page number
per_pageint20Results per page (max 100)

Tag filtering (AND logic): When multiple tags are specified, only tools with ALL tags are returned:

GET /api/v1/tools/search?q=summarize&tags=cli,ai
# Returns tools that have BOTH "cli" AND "ai" tags

Category filtering (OR logic): When multiple categories are specified, tools in ANY category are returned:

GET /api/v1/tools/search?q=summarize&categories=text-processing,productivity
# Returns tools in "text-processing" OR "productivity" category

Faceted response: When include_facets=true, the response includes counts for filtering:

{
"data": [...],
"meta": {"page": 1, "per_page": 20, "total": 42, "total_pages": 3},
"facets": {
"categories": [
{"name": "text-processing", "count": 25},
{"name": "productivity", "count": 17}
],
"tags": [
{"name": "ai", "count": 30},
{"name": "cli", "count": 22},
{"name": "text", "count": 18}
],
"owners": [
{"name": "official", "count": 15},
{"name": "rob", "count": 10}
]
}
}

Database indexes:

-- Frequent query patterns
CREATE INDEX idx_tools_owner_name ON tools(owner, name);
CREATE INDEX idx_tools_owner ON tools(owner); -- For owner filtering
CREATE INDEX idx_tools_category ON tools(category);
CREATE INDEX idx_tools_published_at ON tools(published_at DESC);
CREATE INDEX idx_tools_downloads ON tools(downloads DESC);
CREATE INDEX idx_tools_owner_name_version ON tools(owner, name, version);

-- For pagination stability
CREATE INDEX idx_tools_sort_stable ON tools(downloads DESC, published_at DESC, id DESC);

-- Publisher lookups
CREATE INDEX idx_publishers_slug ON publishers(slug);
CREATE INDEX idx_publishers_email ON publishers(email);

-- Token lookups
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX idx_api_tokens_publisher ON api_tokens(publisher_id);

API Version Compatibility

Forward compatibility: Clients should ignore unknown fields in API responses:

# Good: ignore unknown fields
tool = response['data']
name = tool.get('name')
# Don't fail if 'new_field' exists but client doesn't know about it

# Bad: strict parsing that fails on unknown fields
tool = ToolSchema.parse(response['data']) # May fail on new fields

Backward compatibility: The API will:

  • Never remove fields in a version (only deprecate)
  • Never change field types
  • Add new optional fields without version bump
  • Use new version (/api/v2) for breaking changes

Deprecation process:

  1. Add X-Deprecated-Field: old_field header
  2. Document in changelog
  3. Remove after 6 months minimum
  4. Major version bump if widely used

Client version header:

X-CmdForge-Client: cli/1.2.0

Helps server track client versions for deprecation decisions.

Source of Truth

  • Gitea registry repo is the source of truth.
  • API syncs repo content into SQLite for fast queries, stats, and FTS5 search.
  • index.json remains useful for offline CLI search and as a fallback.

If the cache is stale, the API can fall back to repo reads; a warning header may be emitted.

Namespacing and Paths

Support owner/name from day one:

  • Registry path: tools/{owner}/{name}/config.yaml
  • API URL: /tools/{owner}/{name}
  • Install: cmdforge registry install rob/summarize
  • Shorthand: cmdforge registry install summarize resolves to the official namespace.

PR branches: submit/{owner}/{name}/{version}.

Namespace Identity

The owner is an immutable slug, not the display name:

-- In publishers table
slug TEXT UNIQUE NOT NULL, -- immutable: "rob", "alice-dev"
display_name TEXT NOT NULL, -- mutable: "Rob", "Alice Developer"

Slug rules:

  • Lowercase alphanumeric + hyphens only: ^[a-z0-9][a-z0-9-]*[a-z0-9]$
  • 2-39 characters
  • Cannot start/end with hyphen
  • Set once at registration, cannot be changed
  • Reserved slugs: official, admin, system, api, registry

Rename policy:

  • display_name can be changed anytime via dashboard
  • slug (owner) is permanent to preserve URLs and tool references
  • If a publisher absolutely must change slug (legal reasons, etc.):
    1. Create new account with new slug
    2. Republish tools under new namespace
    3. Mark old tools as deprecated with replacement pointing to new namespace
    4. Old namespace remains reserved (cannot be reused by others)

Why immutable:

  • rob/summarize@1.0.0 must always resolve to the same tool
  • Prevents namespace hijacking after rename
  • Simplifies caching and CDN strategies

Tool Format (Registry == Local)

Registry tool folders mirror local tools:

tools/
rob/
summarize/
config.yaml
README.md

Tool files match the existing CmdForge format. Registry-specific metadata is kept under registry:. Deprecation is tool-defined and top-level:

name: summarize
version: "1.2.0"
deprecated: true
deprecated_message: "Security issue. Use v1.2.1"
replacement: "rob/summarize@1.2.1"
registry:
published_at: "2025-01-15T10:30:00Z"
downloads: 142

Attribution and Source Fields

Tools can include optional source attribution for provenance and licensing:

name: summarize
version: "1.2.0"
description: "Summarize text using AI"

# Attribution fields (optional)
source:
type: original # original, adapted, or imported
license: MIT # SPDX license identifier
url: https://example.com/tool-repo
author: "Original Author"

# For adapted/imported tools
original_tool: other/original-summarize@1.0.0
changes: "Added French language support"

Source types:

TypeDescription
originalCreated from scratch by the publisher
adaptedBased on another tool with modifications
importedDirect import of external tool (e.g., from npm/pip)

License field:

  • Uses SPDX identifiers: MIT, Apache-2.0, GPL-3.0, etc.
  • Required for registry publication
  • Validated against SPDX license list

Collections

Collections are curated groups of tools that can be installed together with a single command.

Collection Structure

Collections are defined in collections/{name}.yaml:

name: text-processing-essentials
display_name: "Text Processing Essentials"
description: "Essential tools for text processing and manipulation"
icon: "📝"

tools:
- official/summarize
- official/translate
- official/fix-grammar
- official/simplify
- official/tone-shift

# Optional
curator: official
tags: ["text", "nlp", "writing"]

Collections API

List all collections:

GET /api/v1/collections

Response:
{
"data": [
{
"name": "text-processing-essentials",
"display_name": "Text Processing Essentials",
"description": "Essential tools for text processing...",
"icon": "📝",
"tool_count": 5,
"curator": "official"
}
],
"meta": {"page": 1, "per_page": 20, "total": 8}
}

Get collection details:

GET /api/v1/collections/{name}

Response:
{
"data": {
"name": "text-processing-essentials",
"display_name": "Text Processing Essentials",
"description": "Essential tools for text processing...",
"icon": "📝",
"curator": "official",
"tools": [
{"owner": "official", "name": "summarize", "version": "1.2.0", ...},
{"owner": "official", "name": "translate", "version": "2.1.0", ...}
]
}
}

CLI Commands

# List available collections
cmdforge collections list

# List in JSON format
cmdforge collections list --json

# View collection details
cmdforge collections info text-processing-essentials

# View in JSON format
cmdforge collections info text-processing-essentials --json

# Install all tools in a collection
cmdforge collections install text-processing-essentials

# Install with pinned versions from collection
cmdforge collections install text-processing-essentials --pinned

Admin Collections API

Collections are managed via the admin dashboard at /dashboard/admin/collections:

GET    /api/v1/admin/collections              # List all collections (admin)
POST /api/v1/admin/collections # Create collection (admin)
PUT /api/v1/admin/collections/:name # Update collection (admin)
DELETE /api/v1/admin/collections/:name # Delete collection (admin)

Schema compatibility note: The current CmdForge config parser may reject unknown top-level keys like deprecated, replacement, and registry. Before implementing registry features:

  1. Update the YAML parser to ignore unknown keys (permissive mode)
  2. Or explicitly define these fields in the Tool dataclass with defaults
  3. Validate registry-specific fields only when publishing, not when running locally

This ensures local tools continue to work even if they don't have registry fields.

Versioning and Immutability

  • Unique key: owner/name + version.
  • Published versions are immutable.
  • Deprecation uses deprecated, deprecated_message, and replacement.
  • CLI warns on install if a version is deprecated.

Yank Policy

Yanking allows removing a version from resolution without deleting it (for auditability):

# In tool config
yanked: true
yanked_reason: "Critical security vulnerability CVE-2025-1234"
yanked_at: "2025-01-20T15:00:00Z"

Yanked version behavior:

OperationBehavior
install foo@1.0.0 (exact)Warns but allows install
install foo@^1.0.0 (constraint)Excludes yanked, resolves to next valid
search / browseHidden by default, shown with --include-yanked
Direct URL accessReturns tool with yanked: true in response
Already installedContinues to work, no forced removal

Database schema addition:

-- Add to tools table
yanked BOOLEAN DEFAULT FALSE,
yanked_reason TEXT,
yanked_at TIMESTAMP

Yank vs Delete:

  • Yank: Version remains in DB, excluded from resolution, auditable
  • Delete: Reserved for DMCA/legal, requires admin action, leaves tombstone record

Version Format

Tools use semantic versioning (semver):

MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]

Examples:
1.0.0 # stable release
1.2.3 # stable release
2.0.0-alpha.1 # prerelease
2.0.0-beta.2 # prerelease
2.0.0-rc.1 # release candidate

Version Constraints

Manifest files support these constraint formats:

ConstraintMeaningExample Match
1.2.3Exact version1.2.3 only
>=1.2.0Minimum version1.2.0, 1.3.0, 2.0.0
<2.0.0Below version1.9.9, 1.0.0
>=1.0.0,<2.0.0Range1.0.0 to 1.9.9
^1.2.3Compatible (same major)1.2.3 to 1.9.9
~1.2.3Approximately (same minor)1.2.3 to 1.2.9
*Any versionlatest stable

Version Resolution Rules

When resolving a version constraint:

  1. Filter: Get all versions matching the constraint
  2. Exclude prereleases: Unless constraint explicitly includes them (e.g., >=2.0.0-alpha.1)
  3. Sort: By semver precedence (descending)
  4. Select: Highest matching version

Tie-breakers:

  • Stable versions preferred over prereleases
  • Later publish date wins if versions are equal (shouldn't happen with immutability)

Unsatisfiable constraints:

// API Response: 404
{
"error": {
"code": "VERSION_NOT_FOUND",
"message": "No version of 'rob/summarize' satisfies constraint '>=5.0.0'",
"details": {
"tool": "rob/summarize",
"constraint": ">=5.0.0",
"available_versions": ["1.0.0", "1.1.0", "1.2.0"],
"latest_stable": "1.2.0"
}
}
}

Prerelease Handling

  • Prereleases are not returned for * or range constraints by default
  • To install prerelease: cmdforge registry install rob/summarize@2.0.0-beta.1
  • To allow prereleases in manifest: version: ">=2.0.0-0" (the -0 suffix includes prereleases)

Download Endpoint Version Selection

The /api/v1/tools/{owner}/{name}/download endpoint accepts version parameters:

ParameterBehaviorExample
(none)Returns latest stable version/download1.2.0
version=1.2.0Exact version (must exist)/download?version=1.2.0
version=^1.0.0Server resolves constraint/download?version=^1.0.01.2.0
version=latestAlias for latest stable/download?version=latest

Server-side resolution: The API server resolves version constraints, not the client. This ensures consistent resolution and allows the server to apply policies (e.g., exclude yanked versions).

GET /api/v1/tools/rob/summarize/download?version=^1.0.0&install=true

Response (200):
{
"data": {
"owner": "rob",
"name": "summarize",
"resolved_version": "1.2.0",
"config": "... YAML content ..."
},
"meta": {
"constraint": "^1.0.0",
"available_versions": ["1.0.0", "1.1.0", "1.2.0"]
}
}

Invalid/unsatisfiable constraint:

GET /api/v1/tools/rob/summarize/download?version=^5.0.0

Response (404):
{
"error": {
"code": "CONSTRAINT_UNSATISFIABLE",
"message": "No version matches constraint '^5.0.0'",
"details": {
"constraint": "^5.0.0",
"latest_stable": "1.2.0",
"available_versions": ["1.0.0", "1.1.0", "1.2.0"]
}
}
}

Tool Resolution Order

When a tool is invoked, the CLI searches in this order:

  1. Local project: ./.cmdforge/<owner>/<name>/config.yaml (or ./.cmdforge/<name>/ for unnamespaced)
  2. Global user: ~/.cmdforge/<owner>/<name>/config.yaml
  3. Registry: Fetch from API, install to global, then run
  4. Error: Tool '<toolname>' not found

Step 3 only occurs if auto_fetch_from_registry: true in config (default: true).

Path convention: Use .cmdforge/ (with leading dot) for both local and global to maintain consistency.

Resolution also respects namespacing:

  • summarize → searches for any tool named summarize, prefers official/summarize if exists
  • rob/summarize → searches for exactly rob/summarize

Official Namespace

The slug official is reserved for curated, high-quality tools maintained by the registry administrators.

  • Shorthand summarize resolves to official/summarize if it exists
  • If no official/summarize, falls back to most-downloaded tool named summarize
  • To avoid ambiguity, always use full owner/name in manifests

Reserved slugs that cannot be registered: official, admin, system, api, registry, cmdforge

Auto-Fetch Behavior

When enabled (auto_fetch_from_registry: true), missing tools are automatically fetched:

$ summarize < file.txt
# Tool 'summarize' not found locally.
# Fetching from registry...
# Installed: official/summarize@1.2.0
# Running...

Behavior details:

  • Fetches latest stable version unless pinned in cmdforge.yaml
  • Installs to ~/.cmdforge/<owner>/<name>/
  • Generates wrapper script in ~/.local/bin/
  • Subsequent runs use local copy (no re-fetch)

To disable (require explicit install):

# ~/.cmdforge/config.yaml
auto_fetch_from_registry: false

Wrapper Script Collisions

When two tools from different owners have the same name:

ScenarioBehavior
Install official/summarizeCreates wrapper ~/.local/bin/summarize
Install rob/summarize (collision)Creates wrapper ~/.local/bin/rob-summarize
Uninstall official/summarizeRemoves summarize wrapper, promotes rob-summarizesummarize if desired

The first-installed tool with a given name gets the short wrapper. Subsequent tools use owner-name format.

To invoke a specific owner's tool:

# Short form (whichever was installed first)
summarize < file.txt

# Explicit owner form (always works)
rob-summarize < file.txt

# Or via cmdforge run
cmdforge run rob/summarize < file.txt

Project Manifest (cmdforge.yaml)

Defines tool dependencies with optional runtime overrides:

name: my-ai-project
version: "1.0.0"
dependencies:
- name: rob/summarize
version: ">=1.0.0"
overrides:
rob/summarize:
provider: ollama

Overrides are applied at runtime and do not mutate installed tool configs.

CLI Config and Tokens

Global config lives in ~/.cmdforge/config.yaml:

registry:
url: https://cmdforge.brrd.tech/api/v1 # Must match canonical base path
token: "reg_xxxxxxxxxxxx"
client_id: "anon_abc123def456"
auto_fetch_from_registry: true

client_id is generated locally and used for anonymous install dedupe.

Publishing and Auth

Publishing uses registry accounts, not Gitea accounts:

  • Public endpoints require no auth.
  • POST /tools requires a registry token.
  • The API server uses a private Gitea service account to open PRs.

Publish Idempotency and Edge Cases

Idempotency key: owner/name@version

ScenarioAPI ResponseHTTP Code
New version, no PR existsCreate PR, return URL201 Created
PR already exists (pending)Return existing PR URL200 OK
Version already publishedError: version exists409 Conflict
PR was closed without mergeAllow new PR201 Created
PR was merged, then tool deletedError: version exists (tombstone)409 Conflict

Version immutability enforcement:

// Attempt to publish existing version
// Response: 409 Conflict
{
"error": {
"code": "VERSION_EXISTS",
"message": "Version 1.2.0 of 'rob/summarize' already exists and cannot be overwritten",
"details": {
"published_at": "2025-01-15T10:30:00Z",
"action": "Bump version number to publish changes"
}
}
}

Closed PR handling:

  • Track PR state in database: pending, merged, closed
  • If PR was closed (rejected/abandoned), allow new submission for same version
  • If PR was merged, version is immutable forever

Update flow (new version, not overwrite):

  1. Developer modifies tool locally
  2. Bumps version in config.yaml (e.g., 1.2.01.3.0)
  3. Runs cmdforge registry publish
  4. New PR created for 1.3.0
  5. Old version 1.2.0 remains available

Publisher Registration

Publishers register on the registry website, not Gitea:

Registration flow:

  1. User visits https://gitea.brrd.tech/registry/register (or future cmdforge.brrd.tech)
  2. Creates account with email + password + slug
  3. Receives verification email (optional in v1, but track verified status)
  4. Logs into dashboard at /dashboard
  5. Generates API token from dashboard
  6. Uses token in CLI for publishing

Authentication Security

Password hashing:

  • Algorithm: Argon2id (memory-hard, recommended by OWASP)
  • Parameters: memory=65536, iterations=3, parallelism=4
  • Library: argon2-cffi for Python
from argon2 import PasswordHasher
ph = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4)
hash = ph.hash(password)
ph.verify(hash, password) # raises on mismatch

API token format:

reg_<random-32-bytes-base62>

Example: reg_7kX9mPqR2sT4vW6xY8zA1bC3dE5fG7hJ
  • Prefix reg_ for easy identification in logs/configs
  • 32 bytes of cryptographically random data
  • Base62 encoded (alphanumeric, no special chars)
  • Total length: ~47 characters
  • Stored as SHA-256 hash in database (never plain text)

Token lifecycle:

ActionBehavior
GenerateCreate new token, return once, store hash
ListShow token name, created date, last used (not the token itself)
RevokeSet revoked_at timestamp, reject future uses
RotateGenerate new token, optionally revoke old

Rate limits:

EndpointLimitWindowScopeRetry-After
POST /register51 hourIP3600
POST /login1015 minIP900
POST /login (failed)515 minIP + email900
POST /tokens101 hourToken3600
POST /tools201 hourToken3600
GET /tools/*1001 minIP60
GET /download601 minIP60

Rate limit response (429):

{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Try again in 60 seconds.",
"details": {
"limit": 100,
"window": "1 minute",
"retry_after": 60
}
}
}

Headers on rate-limited response:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705766400

Scope priority: For authenticated requests, both IP and token limits apply. The more restrictive limit wins.

Account lockout:

  • After 5 failed login attempts: 15-minute lockout for that email
  • After 10 failed attempts: 1-hour lockout
  • Lockout clears on successful password reset

Password reset flow (deferred to v1.1):

  1. User requests reset via email
  2. Server generates time-limited token (1 hour expiry)
  3. Email contains reset link with token
  4. User sets new password
  5. All existing sessions/tokens optionally invalidated

Email verification flow (deferred to v1.1):

  1. On registration, send verification email
  2. User clicks link with verification token
  3. Set verified = true in database
  4. Unverified accounts can browse but not publish

Token Scopes and Authorization

Tokens have scopes that limit their capabilities:

ScopePermissions
readView own published tools, download stats
publishSubmit new tools, update own tool metadata
adminYank tools, manage categories (registry admins only)

Default scope: New tokens get read,publish by default.

Ownership enforcement:

@app.route('/api/v1/tools', methods=['POST'])
@require_token(scopes=['publish'])
def publish_tool():
token = get_current_token()
tool_data = request.json

# Enforce owner == token holder's slug
if tool_data['owner'] != token.publisher.slug:
return {
"error": {
"code": "FORBIDDEN",
"message": f"Cannot publish to namespace '{tool_data['owner']}'. "
f"Your namespace is '{token.publisher.slug}'."
}
}, 403

# Proceed with publish...

GET /api/v1/me/tools authorization:

  • Requires valid token with read scope
  • Returns only tools where owner == token.publisher.slug
  • Includes pending PRs and all versions (including yanked)

Web Session Security

Dashboard login uses session cookies (not tokens) for browser auth:

Cookie settings:

SESSION_COOKIE_NAME = 'cmdforge_session'
SESSION_COOKIE_HTTPONLY = True # Prevent JS access
SESSION_COOKIE_SECURE = True # HTTPS only in production
SESSION_COOKIE_SAMESITE = 'Lax' # CSRF protection
SESSION_COOKIE_MAX_AGE = 86400 * 7 # 7 days

CSRF protection:

  • All POST/PUT/DELETE forms include csrf_token hidden field
  • Token validated server-side before processing
  • 403 Forbidden if token missing or invalid

Session lifecycle:

EventAction
LoginCreate session, set cookie
LogoutDelete session, clear cookie
Idle 24hSession expires, re-login required
Password changeInvalidate all sessions
Token revocationExisting sessions continue (token != session)

Secure session storage:

# Store sessions in DB, not filesystem
from flask_session import Session
app.config['SESSION_TYPE'] = 'sqlalchemy'
app.config['SESSION_SQLALCHEMY_TABLE'] = 'sessions'

Database schema:

-- Publishers
CREATE TABLE publishers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL, -- immutable namespace: "rob", "alice-dev"
display_name TEXT NOT NULL, -- mutable: "Rob", "Alice Developer"
bio TEXT,
website TEXT,
verified BOOLEAN DEFAULT FALSE,
locked_until TIMESTAMP, -- account lockout
failed_login_attempts INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- API tokens (one publisher can have multiple)
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
token_hash TEXT NOT NULL,
name TEXT NOT NULL, -- "CLI token", "CI token"
last_used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP -- NULL if active
);

-- Tools (links to publisher)
CREATE TABLE tools (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL, -- namespace slug (immutable, from publisher.slug)
name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT,
category TEXT,
tags TEXT, -- JSON array
config_yaml TEXT NOT NULL, -- Full tool config
readme TEXT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
deprecated BOOLEAN DEFAULT FALSE,
deprecated_message TEXT,
replacement TEXT,
downloads INTEGER DEFAULT 0,
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name, version)
);

-- Download stats (for deduplication)
CREATE TABLE download_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id),
client_id TEXT NOT NULL,
downloaded_at DATE NOT NULL,
UNIQUE(tool_id, client_id, downloaded_at)
);

-- Search index (FTS5)
CREATE VIRTUAL TABLE tools_fts USING fts5(
name, description, tags, readme,
content='tools',
content_rowid='id'
);

-- FTS5 sync triggers (required for external content tables)
CREATE TRIGGER tools_ai AFTER INSERT ON tools BEGIN
INSERT INTO tools_fts(rowid, name, description, tags, readme)
VALUES (new.id, new.name, new.description, new.tags, new.readme);
END;

CREATE TRIGGER tools_ad AFTER DELETE ON tools BEGIN
INSERT INTO tools_fts(tools_fts, rowid, name, description, tags, readme)
VALUES ('delete', old.id, old.name, old.description, old.tags, old.readme);
END;

CREATE TRIGGER tools_au AFTER UPDATE ON tools BEGIN
INSERT INTO tools_fts(tools_fts, rowid, name, description, tags, readme)
VALUES ('delete', old.id, old.name, old.description, old.tags, old.readme);
INSERT INTO tools_fts(rowid, name, description, tags, readme)
VALUES (new.id, new.name, new.description, new.tags, new.readme);
END;

-- Pending PRs (track publish state)
CREATE TABLE pending_prs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
owner TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
pr_number INTEGER NOT NULL,
pr_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending, merged, closed
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name, version)
);

-- Webhook sync log (idempotency)
CREATE TABLE webhook_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
delivery_id TEXT UNIQUE NOT NULL, -- Gitea delivery ID
event_type TEXT NOT NULL,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Note on tags indexing: The tags column stores JSON arrays as text. For v1, FTS5 will search within the JSON string. If tag filtering becomes a bottleneck, normalize to a tool_tags junction table:

-- Future: normalized tags (if needed)
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);

CREATE TABLE tool_tags (
tool_id INTEGER REFERENCES tools(id),
tag_id INTEGER REFERENCES tags(id),
PRIMARY KEY (tool_id, tag_id)
);

Connecting to your account:

The recommended way to authenticate is using the app pairing flow, which eliminates manual token copying:

CLI connection flow:

$ cmdforge config connect rob

Connecting to CmdForge as @rob...
Device: my-laptop

Waiting for approval from the web interface...
Go to https://cmdforge.brrd.tech/dashboard/connections
and click 'Connect New App', then 'I've Run the Command'

Press Ctrl+C to cancel

Waiting...

Connected successfully!
Your device 'my-laptop' is now linked to @rob

You can now publish tools with: cmdforge registry publish

TUI connection flow:

The TUI (cmdforge ui) includes a Connect button for connecting without using the command line:

  1. Click "Connect" button in the main menu
  2. Enter your CmdForge username (create account at cmdforge.brrd.tech if needed)
  3. A countdown timer shows the pairing expiration (5 minutes)
  4. Go to cmdforge.brrd.tech/dashboard/connections in your browser
  5. Click "Connect New App" and approve the pending connection
  6. TUI automatically detects approval and saves the token

CLI first-time publish flow:

$ cmdforge registry publish

No registry account configured.

Options:
1. Connect to account (recommended): cmdforge config connect <username>
2. Manual token entry

Choose option [1]: 1
Enter username: rob

Connecting to CmdForge as @rob...
[Follows connection flow above]

Validating tool...
✓ config.yaml is valid
✓ README.md exists (2.3 KB)
✓ Version 1.0.0 not yet published

Publishing rob/my-tool@1.0.0...
✓ PR created: https://gitea.brrd.tech/rob/CmdForge-Registry/pulls/42

Your tool is pending review. You'll receive an email when it's approved.

TUI publish flow:

When already connected, the TUI main menu shows "Publish" instead of "Connect":

  1. Select a tool in the list
  2. Click "Publish" button
  3. If no version in config, TUI prompts for version number
  4. Confirm and publish

Private sync after save:

When connected to an account, saving a tool in the TUI offers to sync it privately to the registry:

  1. Save a tool (new or edited)
  2. TUI asks: "Sync to registry privately?"
  3. If yes, enter/confirm version number
  4. Tool is published with visibility: private (only you can see it)
  5. Useful for backup or accessing your tools from multiple machines

CLI Commands Reference

Full mapping of CLI commands to API calls:

Registry Commands

# Search for tools (basic)
$ cmdforge registry search <query> [--category=<cat>] [--limit=20]
→ GET /api/v1/tools/search?q=<query>&category=<cat>&limit=20

# Search with advanced filtering
$ cmdforge registry search <query> [options]
Options:
-c, --category CAT Filter by category
-t, --tag TAG Filter by tag (repeatable, AND logic)
-o, --owner OWNER Filter by publisher/owner
--min-downloads N Minimum downloads
--popular Shortcut for --min-downloads 100
--new Shortcut for --max-downloads 10
--since DATE Published after (YYYY-MM-DD)
--before DATE Published before (YYYY-MM-DD)
-s, --sort FIELD Sort by: relevance, downloads, published_at, name
-l, --limit N Max results (default: 20)
--json Output as JSON
--show-facets Show category/tag counts
--deprecated Include deprecated tools

# List available tags
$ cmdforge registry tags [-c CATEGORY] [-l LIMIT] [--json]
→ GET /api/v1/tags?category=<cat>&limit=<limit>

# Browse tools (TUI)
$ cmdforge registry browse [--category=<cat>]
→ GET /api/v1/tools?category=<cat>&page=1
→ GET /api/v1/categories

# View tool details
$ cmdforge registry info <owner/name>
→ GET /api/v1/tools/<owner>/<name>

# Install a tool
$ cmdforge registry install <owner/name> [--version=<ver>]
→ GET /api/v1/tools/<owner>/<name>/download?version=<ver>&install=true
→ Writes to ~/.cmdforge/<owner>/<name>/config.yaml
→ Generates ~/.local/bin/<name> wrapper (or <owner>-<name> if collision)

# Uninstall a tool
$ cmdforge registry uninstall <owner/name>
→ Removes ~/.cmdforge/<owner>/<name>/
→ Removes wrapper script

# Publish a tool
$ cmdforge registry publish [path] [--dry-run]
→ POST /api/v1/tools (with registry token)
→ Returns PR URL

# List my published tools
$ cmdforge registry my-tools
→ GET /api/v1/me/tools (with registry token)

# Update index cache
$ cmdforge registry update
→ GET /api/v1/index.json
→ Writes to ~/.cmdforge/registry/index.json

Project Commands

# Install project dependencies from cmdforge.yaml
$ cmdforge install
→ Reads ./cmdforge.yaml
→ For each dependency:
GET /api/v1/tools/<owner>/<name>/download?version=<constraint>&install=true
→ Installs to ~/.cmdforge/<owner>/<name>/

# Add a dependency to cmdforge.yaml
$ cmdforge add <owner/name> [--version=<constraint>]
→ Adds to ./cmdforge.yaml dependencies
→ Runs install for that tool

# Show project dependencies status
$ cmdforge deps
→ Reads ./cmdforge.yaml
→ Shows installed status for each dependency
→ Note: "cmdforge list" is reserved for listing installed tools

Command naming note: cmdforge list already exists to list locally installed tools. Use cmdforge deps to show project manifest dependencies.

Config Commands

# Show current configuration
$ cmdforge config show
→ Displays registry URL, token status, client ID, auto-fetch setting

# Connect to your CmdForge account (recommended)
$ cmdforge config connect <username>
→ Initiates app pairing flow
→ Polls /api/v1/pairing/check/<username>?hostname=<hostname>
→ On approval, saves token to ~/.cmdforge/config.yaml

# Set registry token manually (alternative to connect)
$ cmdforge config set-token <token>
→ Saves token to ~/.cmdforge/config.yaml

# Set configuration values
$ cmdforge config set <key> <value>
→ Available keys: auto_fetch, default_provider, registry_url

Flags available on most commands

FlagDescription
--offlineUse cached index only, don't fetch
--refreshForce refresh of cached data
--jsonOutput in JSON format
--verboseShow detailed output

Publish State Tracking

The GUI tracks the publish state of local tools to show whether they've been published, are pending review, or have been modified since publishing.

Local State Storage

When a tool is published, two fields are saved to the local config.yaml:

name: my-tool
description: My awesome tool
# ... tool config ...
registry_hash: sha256:abc123... # Hash of published config
registry_status: pending # Moderation status: pending, approved, rejected

Visual Indicators

The Tools page shows different indicators based on publish state:

StateIndicatorMeaning
Published✓ (green)Approved in registry, local matches published
Pending◐ (yellow)Submitted, awaiting moderator review
Modified● (orange)Published but local config has changes
Local(none)Never published to registry

Automatic Status Sync

When the Tools page loads, a background sync automatically checks the registry for status updates on all published tools. This means:

  • When a moderator approves your tool, the indicator updates automatically on next visit
  • No manual refresh needed - just navigate to the Tools page
  • Status messages appear when tool statuses change

Manual Sync

A "Sync Status" button is available to force an immediate status check for the selected tool. This is useful if you want to check status without leaving the page.

Hash Computation

The publish state hash is computed from the tool's core content, excluding:

  • registry_hash - The stored hash itself
  • registry_status - The moderation status
  • version - Publication version (added during publish)
  • tags - Publication tags (added during publish)

This ensures that only actual tool content changes (steps, arguments, prompts) trigger the "modified" indicator.

API Endpoint

The status sync uses:

GET /api/v1/me/tools/<name>/status

Response:
{
"data": {
"name": "my-tool",
"version": "1.0.0",
"status": "approved",
"config_hash": "sha256:abc123...",
"published_at": "2025-01-15T10:30:00Z"
}
}

Webhooks and Security

HMAC Verification

All Gitea webhooks are verified using HMAC-SHA256:

import hmac
import hashlib

def verify_webhook(request, secret):
signature = request.headers.get('X-Gitea-Signature')
if not signature:
return False

expected = hmac.new(
secret.encode(),
request.body,
hashlib.sha256
).hexdigest()

return hmac.compare_digest(signature, expected)

Replay Protection

While sync is idempotent, implement basic replay protection:

def process_webhook(request):
delivery_id = request.headers.get('X-Gitea-Delivery')

# Check if already processed
if db.webhook_log.exists(delivery_id=delivery_id):
return {"status": "already_processed"}, 200

# Verify signature
if not verify_webhook(request, WEBHOOK_SECRET):
return {"error": "invalid_signature"}, 401

# Process with lock to prevent concurrent processing
with db.lock(f"webhook:{delivery_id}"):
# Double-check after acquiring lock
if db.webhook_log.exists(delivery_id=delivery_id):
return {"status": "already_processed"}, 200

# Process the webhook
result = sync_from_repo()

# Log successful processing
db.webhook_log.insert(
delivery_id=delivery_id,
event_type=request.json.get('action'),
processed_at=datetime.utcnow()
)

return {"status": "processed"}, 200

Sync Job Locking

Prevent concurrent sync operations:

# Using file lock or database advisory lock
SYNC_LOCK_TIMEOUT = 300 # 5 minutes max

def sync_from_repo():
try:
with acquire_lock("registry_sync", timeout=SYNC_LOCK_TIMEOUT):
# Pull latest from Gitea
repo.fetch()
repo.reset('origin/main', hard=True)

# Parse and update database
for tool_path in glob('tools/*/*/config.yaml'):
update_tool_in_db(tool_path)

# Rebuild FTS index if needed
rebuild_fts_index()

except LockTimeout:
logger.warning("Sync already in progress, skipping")
return {"status": "skipped", "reason": "sync_in_progress"}

Atomic Sync Strategy

To avoid partially updated DB during webhook sync, use transactional table swap:

def sync_from_repo_atomic():
with acquire_lock("registry_sync", timeout=SYNC_LOCK_TIMEOUT):
# 1. Pull latest from Gitea
repo.fetch()
repo.reset('origin/main', hard=True)

# 2. Parse all tools into memory
new_tools = []
for tool_path in glob('tools/*/*/config.yaml'):
tool_data = parse_tool(tool_path)
if tool_data:
new_tools.append(tool_data)

# 3. Atomic swap using transaction
with db.transaction():
# Create temp table
db.execute("CREATE TABLE tools_new AS SELECT * FROM tools WHERE 0")

# Bulk insert into temp table
for tool in new_tools:
db.execute("INSERT INTO tools_new ...", tool)

# Swap tables atomically
db.execute("ALTER TABLE tools RENAME TO tools_old")
db.execute("ALTER TABLE tools_new RENAME TO tools")
db.execute("DROP TABLE tools_old")

# Rebuild FTS index
db.execute("INSERT INTO tools_fts(tools_fts) VALUES('rebuild')")

# Update sync timestamp
db.execute("UPDATE sync_status SET last_sync = ?", [datetime.utcnow()])

Why atomic: Per-row updates with FTS triggers can yield inconsistent reads under load. Readers may see partial state mid-sync. Table swap ensures all-or-nothing visibility.

Error Handling

Error ScenarioBehavior
Repo fetch failsLog error, retry in 5 min, alert if 3 failures
YAML parse errorSkip tool, log error, continue with others
Database write failsRollback transaction, retry once, then alert
Lock timeoutSkip this sync, next webhook will retry

Automated CI Validation

PRs are validated automatically using CmdForge (dogfooding):

PR Submitted


┌─────────────────────────────────────┐
│ Gitea CI runs validation tools: │
│ • schema-validator │
│ • security-scanner │
│ • duplicate-detector │
└───────────────┬─────────────────────┘

┌───────┴───────┐
│ │
All pass Any fail
│ │
▼ ▼
Auto-merge or Add comment,
flag for review request changes

Validation checks:

  1. Schema validation: config.yaml matches expected format
  2. Security scan: No dangerous shell commands, no secrets in prompts
  3. Duplicate detection: AI-powered similarity check against existing tools
  4. README check: README.md exists and is non-empty

CI workflow (.gitea/workflows/validate.yaml):

name: Validate Tool Submission
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Validate schema
run: python scripts/validate_tool.py ${{ github.event.pull_request.head.sha }}
- name: Security scan
run: cmdforge run security-scanner < changed_files.txt
- name: Check duplicates
run: cmdforge run duplicate-detector < changed_files.txt

Registry Repository Structure

Full structure of the CmdForge-Registry repo:

CmdForge-Registry/
├── README.md # Registry overview
├── CONTRIBUTING.md # How to submit tools
├── LICENSE

├── tools/ # All published tools
│ ├── rob/
│ │ ├── summarize/
│ │ │ ├── config.yaml
│ │ │ └── README.md
│ │ └── translate/
│ │ ├── config.yaml
│ │ └── README.md
│ └── alice/
│ └── code-review/
│ ├── config.yaml
│ └── README.md

├── categories/
│ └── categories.yaml # Category definitions

├── collections/ # Curated tool collections
│ ├── text-processing-essentials.yaml
│ ├── developer-toolkit.yaml
│ └── data-pipeline-basics.yaml

├── index.json # Auto-generated search index

├── .gitea/
│ └── workflows/
│ ├── validate.yaml # PR validation
│ ├── build-index.yaml # Rebuild index on merge
│ └── notify-api.yaml # Webhook to API server

└── scripts/
├── validate_tool.py # Schema validation
├── build_index.py # Generate index.json
├── check_duplicates.py # Similarity detection
└── security_scan.py # Security checks

categories.yaml format:

categories:
- name: text-processing
description: Tools for manipulating and analyzing text
icon: 📝
- name: code
description: Tools for code review, generation, and analysis
icon: 💻
- name: data
description: Tools for data transformation and analysis
icon: 📊
- name: media
description: Tools for image, audio, and video processing
icon: 🎨
- name: productivity
description: General productivity and automation tools
icon:

Download Stats

Counting Methodology

  • Count installs only, not views or searches
  • Increment after successful download (response sent)
  • Dedupe by client_id + tool_id + date
def download_tool(owner, name, version, install=False, client_id=None):
tool = get_tool(owner, name, version)
if not tool:
return {"error": "not_found"}, 404

config_yaml = tool.config_yaml

# Only count if this is an install (not just viewing)
if install:
record_download(tool.id, client_id)

return {"config": config_yaml}, 200

def record_download(tool_id, client_id):
today = date.today()

# Use client_id if provided, otherwise generate anonymous fallback
effective_client_id = client_id or f"anon_{hash(request.remote_addr)}"

# Dedupe: only count once per client per tool per day
try:
db.download_stats.insert(
tool_id=tool_id,
client_id=effective_client_id,
downloaded_at=today
)
# Increment counter (can be async/batch updated)
db.execute("UPDATE tools SET downloads = downloads + 1 WHERE id = ?", [tool_id])
except IntegrityError:
pass # Already counted today, ignore

Client ID Generation

CLI generates a persistent anonymous ID on first run:

# In CLI, on first run
import uuid
import os

CONFIG_PATH = os.path.expanduser("~/.cmdforge/config.yaml")

def get_or_create_client_id():
config = load_config()
if 'client_id' not in config:
config['client_id'] = f"anon_{uuid.uuid4().hex[:16]}"
save_config(config)
return config['client_id']

Fallback when client_id missing:

  • If header X-Client-ID not sent, use IP hash as fallback
  • This still provides some dedupe for anonymous users
  • Logged users' downloads are attributed to their account instead

Privacy Considerations

  • No IP addresses stored in database
  • client_id is client-controlled and can be regenerated
  • Stats are aggregated (total count), not individual tracking

Async Stats Strategy

To avoid DB contention on the hot download path:

from queue import Queue
from threading import Thread

# In-memory queue for stats
stats_queue = Queue()

def record_download_async(tool_id, client_id):
"""Non-blocking: enqueue for background processing"""
stats_queue.put({
'tool_id': tool_id,
'client_id': client_id,
'date': date.today()
})

def stats_worker():
"""Background thread: batch process stats every 5 seconds"""
batch = []
while True:
try:
item = stats_queue.get(timeout=5)
batch.append(item)
except Empty:
if batch:
flush_batch(batch)
batch = []

def flush_batch(batch):
"""Bulk insert with conflict ignore"""
with db.transaction():
for item in batch:
try:
db.execute("""
INSERT INTO download_stats (tool_id, client_id, downloaded_at)
VALUES (?, ?, ?)
ON CONFLICT DO NOTHING
""", [item['tool_id'], item['client_id'], item['date']])
except Exception as e:
logger.warning(f"Stats insert failed: {e}")
# Don't fail downloads for stats errors

Failure behavior: If stats DB write fails, log the error but don't fail the download. Stats are "best effort" - the download must succeed.

  • Primary search: SQLite FTS5 inside the API.
  • index.json provides offline CLI search and backup.
  • If FTS5 is stale, return results with X-Search-Index-Stale: true.

API Caching Strategy

Cache Headers

EndpointCache-ControlETagNotes
GET /index.jsonmax-age=300, stale-while-revalidate=60Yes5 min cache, background refresh
GET /tools/{owner}/{name}max-age=60Yes1 min cache
GET /tools/{owner}/{name}/downloadmax-age=3600, immutableYesImmutable versions, 1 hour
GET /tools/searchno-cacheNoAlways fresh
GET /categoriesmax-age=3600YesCategories change rarely

ETag Implementation

import hashlib
from datetime import datetime

def get_tool_etag(tool):
"""Generate ETag from tool identity (immutable versions don't change)"""
# Since versions are immutable, owner/name@version is stable
# Use published_at for extra safety (not updated_at, which doesn't exist)
content = f"{tool.owner}/{tool.name}@{tool.version}:{tool.published_at.isoformat()}"
return hashlib.md5(content.encode()).hexdigest()

def get_index_etag():
"""Generate ETag from last sync timestamp"""
last_sync = db.get_last_sync_time()
return hashlib.md5(last_sync.isoformat().encode()).hexdigest()

@app.route('/api/v1/tools/<owner>/<name>/download')
def download_tool(owner, name):
version = request.args.get('version', 'latest')
tool = resolve_and_get_tool(owner, name, version)
etag = get_tool_etag(tool)

# Check If-None-Match header
if request.headers.get('If-None-Match') == etag:
return '', 304 # Not Modified

response = jsonify({
"data": {
"owner": tool.owner,
"name": tool.name,
"resolved_version": tool.version,
"config": tool.config_yaml
}
})
response.headers['ETag'] = etag
response.headers['Cache-Control'] = 'max-age=3600, immutable'
return response

Note: Since tool versions are immutable, the ETag based on owner/name@version is permanently stable. The published_at timestamp is included for defense-in-depth but won't change.

DB vs Repo Read Strategy

ScenarioRead FromReason
Normal operationSQLite DBFast, indexed, FTS
DB empty/corruptedGitea repoFallback/recovery
Webhook sync in progressDB (stale OK)Avoid blocking reads
Search querySQLite FTS5Full-text search
Download specific versionDB, fallback to repoDB is cache, repo is truth

Staleness Detection

STALE_THRESHOLD = timedelta(minutes=10)

def is_db_stale():
last_sync = db.get_last_sync_time()
return datetime.utcnow() - last_sync > STALE_THRESHOLD

@app.route('/tools/search')
def search_tools(q):
results = db.search_fts(q)

response = jsonify({"results": results})
if is_db_stale():
response.headers['X-Search-Index-Stale'] = 'true'
response.headers['X-Last-Sync'] = db.get_last_sync_time().isoformat()

return response

Error Model

Response Envelopes

Success response:

{
"data": { ... },
"meta": {
"page": 1,
"per_page": 20,
"total": 42,
"total_pages": 3
}
}

Error response:

{
"error": {
"code": "TOOL_NOT_FOUND",
"message": "Tool 'foo/bar' does not exist",
"details": {
"owner": "foo",
"name": "bar",
"suggestion": "Did you mean 'rob/bar'?"
},
"docs_url": "https://cmdforge.brrd.tech/docs/errors#TOOL_NOT_FOUND"
}
}

Error Codes

CodeHTTPDescription
TOOL_NOT_FOUND404Tool does not exist
VERSION_NOT_FOUND404Requested version doesn't exist
VERSION_EXISTS409Cannot overwrite published version
INVALID_VERSION400Version string is not valid semver
INVALID_CONSTRAINT400Version constraint syntax error
CONSTRAINT_UNSATISFIABLE404No version matches constraint
VALIDATION_ERROR400Tool config validation failed
UNAUTHORIZED401Missing or invalid auth token
FORBIDDEN403Token valid but lacks permission
RATE_LIMITED429Too many requests
SLUG_TAKEN409Namespace slug already registered
ACCOUNT_LOCKED403Too many failed login attempts
SERVER_ERROR500Internal error (logged for debugging)

Error Scenarios and Fallbacks

CLI Error Handling

ScenarioCLI BehaviorUser Message
Registry offlineUse cached tools if available"Registry unavailable. Using cached version."
Tool not foundCheck cache, then fail"Tool 'foo/bar' not found in registry or cache."
Version constraint unsatisfiableShow available versions"No version matches '>=5.0.0'. Available: 1.0.0, 1.1.0, 1.2.0"
Auth token expiredPrompt for new token"Token expired. Please re-authenticate."
Rate limitedWait and retry (backoff)"Rate limited. Retrying in 30 seconds..."
Network timeoutRetry with backoff, then fail"Connection timed out. Check your network."

Validation Failure Details

When VALIDATION_ERROR occurs, provide specific field errors:

{
"error": {
"code": "VALIDATION_ERROR",
"message": "Tool configuration is invalid",
"details": {
"errors": [
{
"path": "steps[0].provider",
"message": "Provider 'gpt5' is not recognized",
"allowed": ["claude", "openai", "ollama", "mock"]
},
{
"path": "version",
"message": "Version '1.0' is not valid semver (use '1.0.0')"
}
]
},
"docs_url": "https://cmdforge.brrd.tech/docs/tool-format"
}
}

Dependency Resolution Failures

When cmdforge install fails on a manifest:

$ cmdforge install

Error: Could not resolve all dependencies

rob/summarize@^2.0.0
✗ No matching version (latest: 1.2.0)

alice/translate@>=1.0.0
✓ Found 1.3.0

Suggestions:
- Update rob/summarize constraint to "^1.0.0"
- Contact the tool author for a v2 release

Graceful Degradation

Component DownFallback Behavior
API serverCLI uses ~/.cmdforge/registry/index.json for search
Gitea repoAPI serves from DB cache (may be stale)
FTS5 indexFall back to LIKE queries (slower but works)
NetworkUse locally installed tools, skip registry features

UX Requirements (CLI/TUI)

Publishing UX

  • cmdforge registry publish --dry-run validates locally and shows what would be submitted:

    $ cmdforge registry publish --dry-run

    Validating tool...
    ✓ config.yaml is valid
    ✓ README.md exists (2.3 KB)
    ✓ Version 1.1.0 not yet published

    Would submit:
    Owner: rob
    Name: summarize
    Version: 1.1.0
    Category: text-processing
    Tags: summarization, ai, text

    Config preview:
    ─────────────────────────────
    name: summarize
    version: "1.1.0"
    description: Summarize text using AI
    ...
    ─────────────────────────────

    Run without --dry-run to submit for review.
  • Version bump reminder: CLI warns if version hasn't changed from published:

    ⚠ Version 1.0.0 is already published. Bump version in config.yaml to publish changes.
  • First-time publishing flow prompts for token and saves it to config.

Progress Indicators

Long-running operations show progress:

$ cmdforge install

Installing project dependencies...
[1/3] rob/summarize@^1.0.0
Resolving version... 1.2.0
Downloading... done
Installing... done ✓
[2/3] alice/translate@>=2.0.0
Resolving version... 2.1.0
Downloading... done
Installing... done ✓
[3/3] official/code-review@*
Resolving version... 1.0.0
Downloading... done
Installing... done ✓

✓ Installed 3 tools
$ cmdforge registry publish

Submitting rob/summarize@1.1.0...
Validating... done ✓
Uploading... done ✓
Creating PR... done ✓

✓ PR created: https://gitea.brrd.tech/rob/CmdForge-Registry/pulls/42

Your tool is pending review. You'll receive an email when it's approved.

TUI Browse

cmdforge registry browse opens a full-screen terminal UI:

┌─ CmdForge Registry ───────────────────────────────────────┐
│ Search: [________________] [All Categories ▼] [Sort: Popular ▼] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ▶ rob/summarize v1.2.0 ⬇ 142 │
│ Summarize text using AI │
│ [text-processing] [ai] [summarization] │
│ │
│ alice/translate v2.1.0 ⬇ 98 │
│ Translate text between languages │
│ [text-processing] [translation] │
│ │
│ official/code-review v1.0.0 ⬇ 87 │
│ AI-powered code review │
│ [code] [review] [ai] │
│ │
├─────────────────────────────────────────────────────────────┤
│ ↑↓ Navigate Enter: Details i: Install /: Search q: Quit │
└─────────────────────────────────────────────────────────────┘

Keyboard shortcuts:

KeyAction
↑/↓ or j/kNavigate list
EnterView tool details
iInstall selected tool
/Focus search box
cChange category filter
sChange sort order
?Show help
qQuit

Virtual scrolling: For large tool lists (>100), use virtual scrolling to maintain performance.

Project Initialization

$ cmdforge init

Creating cmdforge.yaml...

Project name [my-project]: my-ai-project
Version [1.0.0]:

Would you like to add any tools? (search with 's', skip with Enter)
> s
Search: summ
1. rob/summarize v1.2.0 - Summarize text using AI
2. alice/summary v1.0.0 - Generate summaries

Add tool (number, or Enter to finish): 1
Added rob/summarize@^1.2.0

Add tool (number, or Enter to finish):

✓ Created cmdforge.yaml

name: my-ai-project
version: "1.0.0"
dependencies:
- name: rob/summarize
version: "^1.2.0"

Run 'cmdforge install' to install dependencies.

Accessibility

  • CLI: All output works with screen readers, no color-only information
  • TUI: Full keyboard navigation, high-contrast mode support
  • Web UI: WCAG 2.1 AA compliance target
    • Semantic HTML
    • ARIA labels for interactive elements
    • Focus management in modals
    • Skip links for navigation

Offline Cache

Cache registry index locally:

~/.cmdforge/registry/index.json

Refresh when older than 24 hours; support --offline and --refresh flags.

Index Integrity

The cached index.json includes integrity metadata:

{
"version": "1.0",
"generated_at": "2025-01-20T12:00:00Z",
"checksum": "sha256:abc123...",
"tool_count": 142,
"tools": [...]
}

API response headers:

ETag: "abc123def456"
X-Index-Checksum: sha256:abc123...
X-Index-Generated: 2025-01-20T12:00:00Z

CLI verification:

def verify_cached_index():
"""Verify cached index integrity on load"""
cached = load_cached_index()
if not cached:
return None

# Verify checksum
content = json.dumps(cached['tools'], sort_keys=True)
computed = hashlib.sha256(content.encode()).hexdigest()

if computed != cached.get('checksum', '').replace('sha256:', ''):
logger.warning("Cached index checksum mismatch, will refresh")
return None

return cached

Corruption handling:

  • If checksum fails, discard cache and fetch fresh
  • If partial write detected (missing fields), discard and refresh
  • CLI shows warning: "Cached index corrupted, fetching fresh copy..."

Web UI Vision

The registry includes a full website, not just an API:

Site structure:

cmdforge.brrd.tech (or gitea.brrd.tech/registry)
├── / # Landing page
├── /tools # Browse all tools
├── /tools/{owner}/{name} # Tool detail page
├── /categories # Browse by category
├── /categories/{name} # Tools in category
├── /collections # Browse curated collections
├── /collections/{name} # Collection detail page
├── /search?q=... # Search results
├── /docs # Documentation
│ ├── /docs/getting-started
│ ├── /docs/creating-tools
│ ├── /docs/publishing
│ └── /docs/best-practices
├── /tutorials # Step-by-step guides
│ ├── /tutorials/first-tool
│ ├── /tutorials/chaining-steps
│ └── /tutorials/code-steps
├── /examples # Example projects
├── /blog # Updates, announcements (optional)
├── /register # Publisher registration
├── /login # Publisher login
├── /dashboard # Publisher dashboard
│ ├── /dashboard/tools # My published tools
│ ├── /dashboard/connections # Connected apps
│ └── /dashboard/settings # Account settings
└── /api/v1/... # API endpoints

Landing page content:

  • Hero: "Share and discover AI-powered CLI tools"
  • Quick install example
  • Featured/popular tools
  • Category highlights
  • "Get Started" CTA

Tool detail page:

  • Name, description, version, author
  • README rendered as markdown (sanitized)
  • Install command (copy-to-clipboard)
  • Version history
  • Download stats
  • Category/tags
  • "Report" button for abuse

README Security

When rendering README markdown, apply XSS sanitization:

import bleach
from markdown import markdown

ALLOWED_TAGS = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr',
'ul', 'ol', 'li',
'strong', 'em', 'code', 'pre',
'blockquote',
'a', 'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td'
]

ALLOWED_ATTRS = {
'a': ['href', 'title'],
'img': ['src', 'alt', 'title'],
'code': ['class'], # for syntax highlighting
}

def render_readme_safe(readme_raw: str) -> str:
"""Convert markdown to sanitized HTML"""
# Convert markdown to HTML
html = markdown(readme_raw, extensions=['fenced_code', 'tables'])

# Sanitize to prevent XSS
safe_html = bleach.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRS,
strip=True
)

# Linkify URLs
safe_html = bleach.linkify(safe_html)

return safe_html

Storage strategy:

  • Store raw README in tools.readme
  • Render and sanitize on request (or cache rendered HTML)
  • Never trust client-submitted HTML directly

Tech stack options:

OptionProsCons
Flask + Jinja + TailwindSimple, Python-only, fast to buildLess interactive
FastAPI + Vue/React SPAModern, interactiveMore complex, separate build
Astro/Next.jsGreat SEO, static-firstDifferent stack (Node.js)

Recommendation: Flask + Jinja + Tailwind for v1

  • Keeps everything in Python
  • Server-rendered is fine for a registry
  • Good SEO out of the box
  • Can add interactivity with Alpine.js or htmx if needed

Monetization considerations:

  • AdSense-compatible (server-rendered pages)
  • Analytics tracking for traffic insights
  • Future: sponsored tools, featured placements
  • Future: premium publisher tiers (more tools, priority review)

Registry Curation System

The registry includes a moderation system for content curation, abuse prevention, and quality control.

Roles and Permissions

RolePermissions
userPublish tools, manage own tools, view public content
moderatorAll user permissions + approve/reject tools, resolve reports, view all publishers
adminAll moderator permissions + ban/unban publishers, change roles, delete tools, view audit log

Database columns:

-- In publishers table
role TEXT DEFAULT 'user', -- 'user', 'moderator', 'admin'
banned INTEGER DEFAULT 0,
banned_at TIMESTAMP,
banned_by TEXT,
ban_reason TEXT,

-- In tools table
visibility TEXT DEFAULT 'public', -- 'public', 'private', 'unlisted'
moderation_status TEXT DEFAULT 'pending', -- 'pending', 'approved', 'rejected', 'removed'
moderation_note TEXT,
moderated_by TEXT,
moderated_at TIMESTAMP,

Tool Visibility

VisibilityIn Search/ListDirect LinkWho Can See
publicYes (if approved)Yes (if approved)Everyone
privateNoNoOwner only
unlistedNoYes (if approved)Anyone with link

Moderation Workflow

Tool Published

├── visibility = 'private' or 'unlisted'
│ └── Auto-approved (moderation_status = 'approved')

└── visibility = 'public'
└── moderation_status = 'pending'

├── Moderator approves → 'approved' → Visible in search
├── Moderator rejects → 'rejected' → Not visible
└── Moderator removes → 'removed' → Removed from view

Admin API Endpoints

Tool Moderation (moderator+):

GET  /api/v1/admin/tools/pending           # List pending tools
GET /api/v1/admin/tools/<id> # Get full tool details for review
POST /api/v1/admin/tools/<id>/approve # Approve a tool
POST /api/v1/admin/tools/<id>/reject # Reject with reason (required)
POST /api/v1/admin/tools/<id>/remove # Soft-delete approved tool
DELETE /api/v1/admin/tools/<id> # Hard delete (admin only)

Tool Detail Response (GET /api/v1/admin/tools/<id>):

{
"data": {
"id": 123,
"owner": "alice",
"name": "my-tool",
"version": "1.0.0",
"description": "Tool description",
"category": "Text Processing",
"tags": "ai,text",
"published_at": "2025-01-15T10:30:00Z",
"publisher_name": "Alice Smith",
"visibility": "public",
"moderation_status": "pending",
"scrutiny_status": "pending_review",
"scrutiny_report": {
"findings": [
{"check": "shell_commands", "result": "warning", "message": "...", "suggestion": "..."}
]
},
"config": {
"name": "my-tool",
"description": "...",
"arguments": [...],
"steps": [...]
},
"readme": "# My Tool\n\nDocumentation here..."
}
}

Publisher Management (moderator+ to view, admin to modify):

GET  /api/v1/admin/publishers              # List all publishers
GET /api/v1/admin/publishers/<id> # Publisher details
POST /api/v1/admin/publishers/<id>/ban # Ban with reason (admin)
POST /api/v1/admin/publishers/<id>/unban # Unban (admin)
POST /api/v1/admin/publishers/<id>/role # Change role (admin)

Reports (moderator+):

GET  /api/v1/admin/reports                 # List reports
POST /api/v1/admin/reports/<id>/resolve # Resolve with action

Audit Log (admin only):

GET /api/v1/admin/audit-log                # View moderation history
?target_type=tool|publisher
?target_id=<id>
?actor_id=<id>
?since=<date>

Ban Behavior

When a publisher is banned:

  1. banned set to 1, banned_at, banned_by, ban_reason recorded
  2. All active API tokens revoked
  3. All their tools set to moderation_status = 'removed'
  4. Action logged to audit trail

Banned publishers see error on any authenticated API call:

{
"error": {
"code": "ACCOUNT_BANNED",
"message": "Your account has been banned: <reason>"
}
}

Report Resolution Actions

ActionEffect
dismissClose report, no action taken
warnClose report, no automated action (manual warning)
remove_toolRemove the reported tool
ban_publisherBan the tool's publisher

Audit Log

All moderation actions are logged:

CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL, -- 'approve_tool', 'reject_tool', 'ban_publisher', etc.
target_type TEXT NOT NULL, -- 'tool', 'publisher', 'report'
target_id TEXT NOT NULL,
actor_id TEXT NOT NULL, -- Who performed the action
details TEXT, -- JSON with additional context
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Web UI Admin Dashboard

Moderators and admins see an "Admin Panel" link in their dashboard sidebar leading to:

  • /dashboard/admin - Overview with pending counts
  • /dashboard/admin/pending - Pending tools queue
  • /dashboard/admin/publishers - Publisher management
  • /dashboard/admin/reports - Report queue

Pending Tools Review Page

The pending tools page (/dashboard/admin/pending) provides a comprehensive interface for reviewing submitted tools:

Table View:

  • Tool name (clickable to open detail modal)
  • Publisher name and category
  • Scrutiny status with expandable warnings
  • Submission date
  • Approve/Reject action buttons

Pagination:

  • Page number links with ellipsis for large ranges
  • First («) and Last (») page buttons
  • Previous/Next navigation
  • "Page X of Y (total)" indicator

Tool Detail Modal:

Clicking a tool name opens a draggable modal showing:

  1. Scrutiny Warnings - Yellow warning boxes at the top showing any security or quality concerns from automated analysis, including:

    • Check name (e.g., "shell_commands", "network_access")
    • Warning message
    • Suggestion for resolution
  2. Description - Tool's description text

  3. Arguments - List of tool arguments with flags, variables, and descriptions

  4. Steps - Full display of all tool steps:

    • Prompt steps: Shows provider, output variable, and full prompt content
    • Code steps: Shows output variable and code with syntax highlighting (dark theme)
    • Tool steps: Shows tool name and arguments
  5. README - Full README content if provided

Modal Features:

  • Draggable by header bar
  • Scrollable content area (header and buttons stay fixed)
  • Approve/Reject buttons in modal footer
  • Dark overlay prevents interaction with page behind
  • Background scroll locked while modal is open
  • Closes with X button or after action

Creating the First Admin

After deployment, create the first admin via direct SQL:

UPDATE publishers SET role = 'admin' WHERE slug = 'rob';

Subsequent admins can be promoted via the web UI or API.

Implementation Phases

Phase 1: Foundation

  • Define cmdforge.yaml manifest format
  • Implement tool resolution order (local → global → registry)
  • Create CmdForge-Registry repo on Gitea (bootstrap)
  • Add 3-5 example tools to seed the registry

Phase 2: Core Backend

  • Set up Flask/FastAPI project structure
  • Implement SQLite database schema
  • Build core API endpoints (list, search, get, download)
  • Implement webhook receiver for Gitea sync
  • Set up HMAC verification

Phase 3: CLI Commands

  • cmdforge registry search
  • cmdforge registry install
  • cmdforge registry info
  • cmdforge registry browse (TUI)
  • Local index caching

Phase 4: Publishing

  • Publisher registration (web UI)
  • Token management
  • cmdforge registry publish command
  • PR creation via Gitea API
  • CI validation workflows

Phase 5: Project Dependencies

  • cmdforge install (from manifest)
  • cmdforge add command
  • Runtime override application
  • Dependency resolution

Phase 6: Smart Features

  • SQLite FTS5 search index
  • AI-powered auto-categorization
  • Duplicate/similarity detection
  • Security scanning

Phase 7: Full Web UI

  • Landing page
  • Tool browsing/search pages
  • Tool detail pages with README rendering
  • Publisher dashboard
  • Documentation/tutorials section

Phase 8: Polish & Scale

  • Rate limiting
  • Abuse reporting
  • Analytics integration
  • Performance optimization
  • Monitoring/alerting