Skip to content

Latest commit

 

History

History
675 lines (452 loc) · 39 KB

File metadata and controls

675 lines (452 loc) · 39 KB

CLI reference

Every metaphor subcommand. Core commands (init, list, add, sync, etc.) are implemented in-process; plugin commands forward to external plugin binaries via subprocess.

Synopsis

metaphor [-v|--verbose] <command> [args...]

Global flags

Flag Effect
-v, --verbose Sets RUST_LOG=debug and initializes env_logger before dispatching. Available on every subcommand.
--help, -h Print help. Works at every level.
--version, -V Print the binary version.

Every invocation prints a two-line banner before running the command:

⚡ Metaphor CLI
Orchestrate independent project repos

Exit codes

  • 0 — success.
  • non-zero — propagated from the plugin binary (<plugin> exited with status: <code>), or from a workspace error (e.g. metaphor.yaml already exists, metaphor.yaml not found).

If a plugin binary cannot be spawned at all (not installed, not executable), the error is failed to spawn <plugin> — is it installed?.


Core commands

metaphor init

Initialize a new workspace — either scaffolded from the workspace template, or bare.

Flag / arg Effect
<name> Create ./<name> from the workspace template. Omit to init the current directory bare.
--template <URL> Clone from <URL> instead of the default template repo.
--bare Skip the clone; just write an empty metaphor.yaml in the current directory.

With a name — clones the metaphor-workspace template into ./<name> (the workspace analogue of module create cloning backbone-module):

  1. git clone --depth 1 <template> <name>.
  2. Removes the template's .git — the result is a fresh workspace, not a fork.
  3. Stamps the template placeholders across every UTF-8 text file: __project__<name> (identifiers), __PROJECT__Name (prose and titles). Binaries are skipped.
  4. Overwrites the template's usage-guide README.md with a product README for <name>.
  5. git init — fresh history.

Errors if ./<name> already exists. The default template is SSH (git@github.com:faridlab/metaphor-workspace.git) because the repo is private — pass --template with an HTTPS URL for a public fork. A clone failure reports whether the repo is reachable.

metaphor init acme
# → 📥 Cloning workspace template from git@github.com:faridlab/metaphor-workspace.git
# → ✅ Workspace 'acme' created from the metaphor-workspace template (12 file(s) stamped)
# →    Next: cd acme && metaphor sync && metaphor doctor

Bare--bare, or no name at all:

  • Writes metaphor.yaml with version: 1 and an empty projects list.
  • Refuses to overwrite an existing manifest (metaphor.yaml already exists at …).
mkdir my-workspace && cd my-workspace
metaphor init --bare
# → Initialized empty metaphor workspace at …/metaphor.yaml

metaphor list

List projects registered in the workspace.

  • Reads metaphor.yaml from the current working directory.
  • Errors if no manifest is found (metaphor.yaml not found in <cwd> or any parent directory).
  • Prints No projects registered. when the list is empty.
  • Otherwise prints <n> project(s): followed by one line per project:
    • Local projects: - <name> [<ProjectType>] path=<path>
    • Remote projects: - <name> [<ProjectType>] path=<path> remote=<url> ref=<ref-or-HEAD>

metaphor graph

Print the project dependency graph derived from each project's depends_on.

Flag Effect
--json Emit structured JSON: { "version": 1, "data": { "nodes": [...], "edges": [{"from","to"}, ...] } }.
--focus <name> Show only the subgraph reachable from <name> via depends_on edges. Errors with unknown project '<name>' if the project isn't in the manifest.

Text output is an indented listing: each project on its own line with its direct dependencies listed beneath it as └─ <dep>. Projects are printed in sorted order.

Errors:

  • cycle detected among projects: <a, b, ...> — unreachable today because the loader rejects cycles via Manifest::validate, but surfaces if a cycle is ever introduced at runtime.

metaphor info

Summarize the workspace and the project cwd is currently inside. Useful after cd-ing deep into a project to confirm "yes, metaphor sees this."

Flag Effect
--json Emit the report under the standard { "version": 1, "data": ... } envelope.

Text output:

workspace: /Users/you/my-workspace
current project: billing-api (BackendService)
  path: ./services/billing-api
  resolved: /Users/you/my-workspace/services/billing-api
  depends_on: billing-domain
  depended-by: billing-web
projects: 3 registered

How "current project" is determined. The project whose resolved_path is the longest path-component-wise prefix of cwd wins. /ws/api is not a prefix of /ws/api-v2 — the match is component-aware, matching --affected's file-to-project mapping rule. If cwd isn't inside any registered project, the command prints current project: (not inside any registered project) and exits 0.

Known limitations.

  • Paths are not canonicalized before comparison. A workspace accessed via a symlinked path (e.g. /home/alice -> /Users/alice) may fail to match if find_and_load returns one form and std::env::current_dir returns the other. Workaround: invoke from the canonical path, or canonicalize metaphor.yaml's path: entries to absolute paths.
  • If a project is registered at path: . (the workspace root itself is a project), it matches every cwd under the workspace. Any nested project under a strictly deeper path: still wins via the longest-prefix rule, but this overlap can be surprising — prefer distinct paths.

metaphor ui (interactive mode)

Launches the interactive terminal UI: a workspace overview screen (apps, environments, deployed versions, health), a searchable command browser — Enter runs with defaults and o opens the options sheet; only destructive commands confirm — plus a streaming run view with Ctrl+C cancel. Built with Ink; requires the separate metaphor-ui npm package.

  • Install: npm install -g @metaphor/metaphor-ui (Node.js >= 20). METAPHOR_UI_BIN points the launcher at a specific binary for dev/testing.
  • Bare metaphor on a TTY tries the UI first; without metaphor-ui installed it prints the install hint and falls back to the REPL.
  • The launcher exports METAPHOR_LAUNCHER so the UI always queries the binary that started it.

metaphor manifest

Prints metaphor's full command tree by introspecting its own clap definitions — the source of truth the interactive UI renders menus from, so menus cannot drift from the real CLI.

  • Default: indented text tree.
  • --json: stable {version: 1, data: ...} envelope with per-command name, about, aliases, hidden, trailing-var-arg markers, and per-flag detail (help, defaults, possible values, num_args, global). Auto-added help/version args are omitted.

metaphor overview

Workspace state at first sight, in one read-only pass: workspace identity, the current project, runtime apps with local version + git branch + dirty flag, deployment environments from metaphor.deploy.yaml joined with the last successful record in deployment/history/<env>.jsonl (which service runs which version where, when, by whom), plugin install state, and a doctor-style health tally. Env-file contents are never read or emitted. Missing pieces become null sections with a hint, never errors.

  • Default: compact text summary.
  • --json: stable envelope, same sections.

metaphor repl

Interactive REPL for driving metaphor from one prompt instead of invoking the binary per command. Same subcommands, same flags — no special vocabulary.

Two entry points:

  • metaphor repl — explicit. Works everywhere (including piped stdin for scripting/tests).
  • metaphor ui — the newer interactive surface; see above. Bare metaphor on a TTY tries the UI first and falls back here.
  • Inside either interactive mode, everything runs by spawning the real binary, so exit codes and output are exactly what a shell would see.

Inside the loop:

Input Effect
any subcommand Parsed like a real CLI invocation (list, show project api, build --all --dry-run, …). Shell-style quoting is honored.
help / ? Print the built-in table and every registered subcommand with its one-line description.
clear Clear the screen.
exit / quit / :q / Ctrl-D Leave.
Ctrl-C Abandon the current line; stay in the loop.
bogus input Prints the error and keeps the loop alive — a typo doesn't eject you.

History. Up/Down recalls previous lines. Persisted across sessions to $XDG_DATA_HOME/metaphor/repl-history (or ~/.local/share/metaphor/repl-history / the platform equivalent).

Nested REPLs are refused — typing repl inside the REPL errors with "already in a repl".

Banner. The metaphor banner prints once at startup; individual commands don't re-print it, so the REPL output stays tidy.

metaphor doctor

Diagnostic runner. Walks a standard set of checks against the workspace and prints [OK] / [WARN] / [FAIL] lines with hints. Exits non-zero iff any check fails.

Flag Effect
--json Emit the full report under the standard { "version": 1, "data": ... } envelope.

Checks, by category:

workspace

  • manifest valid — implicitly OK if load succeeded.
  • git available — WARN if git isn't on $PATH; metaphor build's {git_sha} tags and --affected need it.
  • docker available — only emitted when at least one project has a Dockerfile. WARN if docker isn't on $PATH.

projects (per project)

  • directory existsFAIL if the project's resolved_path is missing. This is the only project-level FAIL — everything else is advisory.
  • missing .dockerignore — WARN if a Dockerfile exists but .dockerignore does not. Hint tells the user what minimal content to drop in.
  • metaphor.env.yaml invalid YAML / metaphor.build.yaml invalid YAML / compose.fragment.yml invalid YAML — WARN if any convention file is unparseable. Absent files are skipped silently.

plugins

  • For each known plugin (metaphor-schema, metaphor-codegen, metaphor-dev, metaphor-agent): OK if discovered via $METAPHOR_PLUGIN_BIN_DIR or $PATH, WARN otherwise.

Exit code. 0 when every check is OK or WARN, 1 when any check is FAIL. Ship doctor as a pre-flight in CI to catch drift before a build.

metaphor show projects

JSON-friendly inspection of the full project list.

Flag Effect
--json Emit { "version": 1, "data": { "projects": [...] } }. Each project is serialized with every manifest field (name, type, path, remote?, ref?, depends_on).

Without --json, this is identical to metaphor list.

metaphor show project [<name>]

JSON-friendly detail view for a single project. <name> is optional — when omitted, the project is auto-detected from cwd using the same rule as metaphor info.

Flag Effect
--json Emit { "version": 1, "data": { "project": {...}, "resolved_path": "<absolute path>" } }.

Without --json, prints a labeled block: name, type, path, resolved, and depends_on. For projects with a remote, remote and ref lines are also shown.

Errors:

  • project '<name>' not found in workspace if the name was given but doesn't match.
  • not inside a registered project (cd into one or pass a name) if no name is given and cwd isn't inside any registered project.

metaphor plugin

Manage plugin binaries.

Subcommands:

Command Effect
metaphor plugin add <name>[@<version>] Download a known plugin from its GitHub release and install it. Default version is latest.
metaphor plugin list [--json] Same as metaphor plugins below.

metaphor plugin add — installs the matching release asset into $METAPHOR_PLUGIN_BIN_DIR if set, otherwise ~/.metaphor/bin (created if missing). Requires curl and tar on $PATH. Only the known plugin names (metaphor-schema, metaphor-codegen, metaphor-dev, metaphor-agent) are accepted; arbitrary plugins wait on the in-process registry.

Example:

metaphor plugin add metaphor-dev@latest
metaphor plugin add metaphor-schema@0.1.0

Errors:

  • unknown plugin '<name>'. Known plugins: … if <name> isn't in the known set.
  • unsupported platform: <os>-<arch> on a host that doesn't match one of the four published targets ({x86_64,aarch64}-{apple-darwin,unknown-linux-gnu}).
  • download failed: <url> if the tag doesn't exist or the asset is missing — check the plugin repo's releases page.
  • tarball did not contain '<name>' at its root if the release asset doesn't follow the contract in plugins.md § Release asset contract.

After install, the command prints the installed path and <plugin> --version output. If the install directory isn't on $PATH and METAPHOR_PLUGIN_BIN_DIR isn't set, a note shows the two ways to make metaphor find it.

metaphor plugins

List the plugin binaries this metaphor install can see. Equivalent to metaphor plugin list.

Flag Effect
--json Emit the list under the stable { "version": 1, "data": { "plugins": [...] } } envelope.

Text output shows each known plugin (metaphor-schema, metaphor-codegen, metaphor-dev, metaphor-agent) with / installed status, the subcommands it backs, the resolved path (if installed), and the output of <plugin> --version.

Discovery uses the same rules as command dispatch: $METAPHOR_PLUGIN_BIN_DIR first, then $PATH. See plugins.md.

metaphor cache

Manage the task-result cache. See the "Running across many projects" section below for when entries are written and what invalidates them.

Subcommands:

Command Effect
metaphor cache clear Remove every cache entry. Prints the count and byte total removed.
metaphor cache stats [--json] Show the cache root directory, the number of entries, total bytes on disk, and the newest entry's timestamp.

Both require a workspace — they walk up from cwd to find metaphor.yaml (the cache lives at <workspace_root>/.metaphor/cache/). Add .metaphor/ to your .gitignore.

metaphor build

Run docker build in each selected project, tagging images consistently. Implements PLAN.md D-2.

Flag Effect
--all / --projects=a,b / --affected Project selection — exactly one required. Same semantics as the passthrough flags.
--base <ref> / --head <ref> Git refs for --affected. Defaults: main, HEAD.
--parallel <N> Concurrent project builds. Default: 1.
--continue-on-error Keep building remaining projects after a failure.
--tag <template> Repeatable. Placeholders: {name}, {git_sha}, {version}. Default: {name}:{git_sha}.
--dockerfile <name> Workspace-wide Dockerfile filename (relative to each project root). Default: Dockerfile. Per-project override available via <project>/metaphor.build.yaml.
--push docker push every tag after a successful build.
--dry-run Print the docker commands that would run.

Each project is built with docker build -f <dockerfile> -t <tag…> . from its own resolved_path. A missing docker binary or Dockerfile is the project's failure — reported per-project in the summary line.

{git_sha} is per-projectgit rev-parse HEAD runs inside each project's directory, falling back to the workspace sha when the project isn't its own git repo. Tags don't collide across projects that share a name but have independent histories.

Per-project overrides via an optional <project>/metaphor.build.yaml:

dockerfile: Dockerfile.api        # overrides workspace-wide --dockerfile
tags:                             # added to tags from --tag; deduped
  - acme/{name}:latest
  - acme/{name}:stable

--dry-run output is shell-quoted so the printed docker build … line is safe to copy-paste into a shell.

metaphor compose generate

Merge each project's compose.fragment.yml into a workspace-level docker-compose.yml. Implements PLAN.md D-3.

Flag Effect
--out <path> Output path. Default: <workspace_root>/docker-compose.yml.
--write Write to disk. Without this, the generated YAML prints to stdout (dry-run).

A fragment is the service body (image, build, volumes, etc.) — not a full compose file. The command refuses fragments that contain a top-level services: key. Projects without a fragment are silently skipped; the command fails only if no project declares one.

metaphor env check

Validate that every required env var declared in each project's metaphor.env.yaml has a value. Implements PLAN.md D-4.

Flag Effect
--projects=a,b Limit to these projects.
--json Emit the report under the standard { "version": 1, "data": ... } envelope.

Lookup order for each var: process environment → per-project <project>/.env → workspace-root .env → declared default:. A per-project .env value always wins over the workspace .env, so "this service needs THIS value" overrides the shared default. Missing required vars cause a non-zero exit and a MISS line in the text report; the error also enumerates every missing project::VAR_NAME pair. secret: true entries are flagged [secret] in output but their values are never read or printed.

.env parser. Supports blank lines, full-line # comments, optional export prefix, double/single-quoted values (quotes stripped, inner # kept literal), and unquoted values with trailing # comment stripped. No escape sequences, no $VAR interpolation — enough for the vast majority of hand-written .env files.

metaphor docker and metaphor deploy

Both are passthroughs to the metaphor-dev plugin and read metaphor.deploy.yaml at the workspace root. They are documented in the Plugin passthrough section below.

  • metaphor docker <up|down|logs|ps|restart|pull|build> — local docker-compose lifecycle.
  • metaphor deploy <push|rollback|status|logs|migrate|exec> — remote deployment workflow. metaphor deploy exec is the successor to the previous native metaphor deploy: it shells out to the workspace's infra project (./deploy.sh if executable, otherwise make deploy). Implements PLAN.md D-5.

Security. metaphor deploy exec runs arbitrary code from the infra project. Only run it in workspaces you trust — cloning a random metaphor workspace and running deploy exec executes whatever deploy.sh contains, with your shell's privileges.

metaphor clean

Remove stale build-artifact directories across registered projects. Safe by default — the first invocation is always a dry-run that lists what would be freed; pass --apply to actually delete.

Flag Default Effect
--older-than <dur> 30d Only consider directories whose mtime is older than this. Accepts h (hours), d (days), w (weeks), m (30-day months), y (365-day years). A bare number is days. Values below 1 hour are rejected as a typo-protection.
--projects <a,b> all Limit to the named projects (comma-separated).
--apply off Actually delete. Without this, clean is a dry-run.
--json off Emit the report under the standard { "version": 1, "data": ... } envelope.
--quick off Skip per-directory sizing. Fast on huge trees (no recursive stat walk); reported sizes read as 0.
--confirm-over <size> Refuse --apply if total-freed would exceed this (e.g. 10GB, 500MB). Bypass with --yes.
--yes off Suppress the --confirm-over safety gate. Has no effect without --apply.
--docker off Also reclaim this workspace's Docker build-cache volumes (see below). Composes with --apply, --confirm-over, and --yes.
--include-running off With --docker, also reclaim build-cache volumes currently in use by a running container — by emptying them in place. Without it, in-use volumes are reported but left alone.

What counts as a "build artifact" is per project type — only directory names in the safelist are ever touched. This means a source directory coincidentally named build/ inside a webapp is at risk, but inside a crate is not. The safelist:

Type Directories removed
crate, cli-tool target
backend-service target, node_modules, dist, build, __pycache__
webservice, webapp, docs-site node_modules, dist, .next, .cache, build, .nuxt, .parcel-cache
mobileapp build, .gradle, node_modules, Pods, DerivedData
desktopapp target, build, dist, node_modules
module target, node_modules, build, dist, __pycache__
infra .terraform

mtime vs. atime. clean filters on modification time (last build), not access time. Most modern mounts disable atime updates for performance, so atime is unreliable. If a target/ dir has mtime newer than --older-than, it's preserved even if you never actually use it.

Missing mtime = preserve. If a filesystem doesn't report a modification time, the directory is skipped (never deleted) — the safe default.

Interaction with VCS. clean is unaware of .gitignore and git status. If you have committed any of these directories to source control (a vendored dist/, an intentional target/ build product), --apply deletes them and git status will show them as missing on the next check. Uncommitted changes inside these dirs are also gone. When in doubt, run the dry-run first and inspect the output.

Comparison with other cache commands. metaphor clean reclaims disk from build artifacts (compiler output, package caches). For the task-result cache under .metaphor/cache/, use metaphor cache clear instead — they are separate stores with different invalidation semantics.

Docker build-cache volumes (--docker). The dev stack keeps Rust/Node build artifacts in named Docker volumes (e.g. <project>_cargo_target), which the host-side sweep above can't see. A runaway cargo_target volume is the usual cause of a No space left on device that takes Postgres down with it. --docker reclaims these, with the same safety posture as the host path: a dry-run by default, --apply to actually delete, and --confirm-over/--yes thresholds honoured.

Scope and safety:

  • Workspace-scoped. Only volumes labelled with this workspace's own Compose project name(s) are touched — read from the top-level name: field of deployment/compose*.y{a,}ml. Volumes from other Compose projects on the same daemon are never considered.
  • Build caches only — never data. Within those projects, only volumes whose short name is on the build-cache safelist are eligible: cargo_target, cargo_registry, cargo_git, target, node_modules, gradle_cache, build_cache. Anything else — pgdata, miniodata, redisdata, and any other data volume — is never removed or emptied.
  • Idle vs. in-use. Idle volumes are removed outright (docker volume rm). A volume currently mounted by a running container is reported but skipped by default; pass --include-running to empty it in place (the container and volume stay, but the cache is wiped and will be rebuilt on the next build).
  • No daemon, no-op. If Docker isn't running, or the workspace declares no Compose project name, the Docker pass prints a skip notice and the host sweep still completes.
metaphor clean --docker                      # dry-run: host artifacts + Docker build-cache volumes
metaphor clean --docker --apply              # delete idle build-cache volumes (skip in-use)
metaphor clean --docker --apply --include-running   # also empty in-use caches (forces a rebuild)

metaphor add <name>

Register a new project in the workspace manifest without hand-editing YAML.

Flag Required Effect
--project-type <type> yes One of the kebab-case project types: backend-service, webservice, webapp, mobileapp, desktopapp, module, crate, cli-tool, infra, docs-site.
--path <path> yes Absolute or relative to workspace root.
--remote <url> no Git remote URL.
--ref <ref> no Git ref to pin (tag, branch, or commit hash). Only meaningful with --remote. See workspace.md § ref.
--depends-on <names> no Comma-separated or repeatable list of project names this one depends on. Every name must already exist in the manifest.
--clone no Clone the remote into the project path immediately. Requires --remote. Also writes a metaphor.lock entry with the resolved commit hash.

Validation uses the same rules as manifest loading (workspace.md § depends_on) — duplicate names, unknown deps, and self-deps are rejected.

# Register a local project
metaphor add billing-api --project-type backend-service --path ./services/billing-api

# Register with remote
metaphor add billing-web --project-type webapp --path ./apps/billing-web \
  --depends-on billing-api,billing-domain \
  --remote git@github.com:acme/billing-web.git

# Register, pin to a tag, and clone in one step
metaphor add backbone-sapiens --project-type module \
  --path ./modules/backbone-sapiens \
  --remote https://github.com/faridlab/backbone-sapiens \
  --ref v1.0.0 --clone

--clone will error if the target directory already exists. When used, it also creates (or updates) metaphor.lock with the resolved commit hash — the project is immediately pinned to a reproducible state.

Comments in a hand-edited metaphor.yaml are not preserved — add round-trips the full manifest through serde_yaml.

metaphor sync

Clone or update every remote project to its pinned ref. Projects are processed in topological order (dependencies before dependents).

Flag Effect
--update Re-resolve refs even if metaphor.lock already has a matching entry. Forces git fetch + git checkout on every remote project.
--projects <a,b,c> Only sync the named projects (comma-separated). Projects without a remote are silently skipped.

For each project with a remote URL:

  1. Not yet cloned (path doesn't exist) → git clone <remote> <path>, then git checkout <ref> if a ref is pinned.
  2. Already cloned → if the ref changed or --update is set: git fetch --tags --prune, then git checkout <ref> (or git pull --ff-only if no ref is pinned).
  3. Branch pins are fast-forwarded. git fetch advances origin/<ref> but leaves the local branch behind, so after checkout sync runs git merge --ff-only origin/<ref> to land on the tip it just fetched. This only applies when <ref> names a branch — a tag or raw SHA has no origin/<ref>, so those immutable pins never move. A local branch that has diverged from the remote fails with the git stderr rather than being clobbered.
  4. The resolved commit hash is recorded in metaphor.lock.

Projects without a remote are ignored. If no remote projects exist, sync prints "No projects with a remote to sync." and exits 0.

Git output is captured — on success only the commit hash is shown; on failure the full git stderr is included in the error.

# First time: clone all remote modules
metaphor sync

# After bumping a ref in metaphor.yaml
metaphor sync --update

# Sync only one module
metaphor sync --projects backbone-sapiens

metaphor.lock

After every sync, metaphor.lock is written to the workspace root. It records the exact commit each remote project resolved to:

version: 1
projects:
  - name: backbone-sapiens
    ref: v1.0.0
    resolved: a1b2c3d4e5f6789012345678901234567890abcd
  - name: backbone-bucket
    resolved: deadbeef0123456789abcdef0123456789abcdef
  • ref — the value from metaphor.yaml at sync time (omitted if HEAD was used).
  • resolved — the full 40-character commit hash.

Check metaphor.lock into version control. Team members running metaphor sync will get the same commits. Running metaphor sync --update refreshes the lock. This is analogous to Cargo.lock or package-lock.json.


Running across many projects

Every passthrough command accepts the same RunFlags block. Without any of these flags, the command behaves as before (single invocation, inherited stdio, no cwd change).

Flag Effect
--all Run the plugin once per registered project. Topological order (deps first).
--projects <a,b,c> Run only across the named projects (comma-separated). Topological order within the selection. Mutually exclusive with --all and --affected.
--affected Run only on projects whose files changed (via git diff) plus their transitive dependents. See below.
--base <ref> Base ref for --affected. Default: main.
--head <ref> Head ref for --affected. Default: HEAD.
--parallel <N> Max concurrent plugin invocations. Default: 1 (sequential).
--continue-on-error Keep going on project failures; exit non-zero at the end with a summary. Without this, execution stops at the first failure.
--no-cache Bypass the task result cache (neither read nor write).

Examples

metaphor lint --all
metaphor test --projects=billing-api,billing-web
metaphor lint --affected --base=origin/main
metaphor lint --all --parallel=4 --continue-on-error

# Plugin-specific args go after --
metaphor lint --all -- --strict
metaphor test --projects=api -- --filter unit

How it works

  • Each project is spawned in its own working directory (current_dir = Project::resolved_path). Plugins don't need any new flags to learn which project they're running for — they just use $PWD.
  • Output is buffered per project and printed under a == <project> == header so parallel runs stay readable.
  • Sequential (default) mode stops at the first failure unless --continue-on-error is set. Parallel mode always runs every scheduled job to completion and reports failures at the end.

--affected: git semantics

--affected requires a git workspace. It runs:

git diff --name-only <base>..<head>

Two-dot range (..), not three-dot. base..head lists commits reachable from head but not from base, which matches Nx's nx affected convention. Using two-dot means the affected set doesn't shift when base moves (e.g. after a git fetch).

Metaphor then maps each changed file to the project with the longest matching path prefix and closes that set under reverse-dependency edges — so touching a shared module also selects everything that depends on it.

Only tracked files count. A newly-created, never-git added file does not mark its project as affected. This matches Nx's behavior.

Failure modes:

  • failed to invoke \git diff`` — git not installed, or not a git repo.
  • \git diff ..` failed` — ref doesn't exist, or worktree is corrupt.

There is no silent fallback to --all — a missing base ref is always an error.

Interaction with the manifest

  • Single-shot plugin commands (no --all / --projects / --affected) do not require metaphor.yaml. They just spawn the plugin from whatever cwd you're in.
  • Multi-project commands require a manifest — find_and_load walks up from cwd looking for metaphor.yaml. No manifest → clear error.
  • --parallel, --continue-on-error, and --no-cache are all rejected without a selector flag (--all / --projects / --affected) so typos don't silently behave as single-shot.

Task-result cache

Multi-project runs cache successful results at <workspace_root>/.metaphor/cache/. On a cache hit, the stored stdout/stderr is replayed and the recorded exit code is returned — the plugin is not spawned at all. The == <project> == (cached) header tells you a replay happened.

Cache key = blake3(plugin-binary-path || plugin --version || argv || project-name || project-tree-hash). Any change to the plugin binary it points to, the plugin's self-reported version, the forwarded args, or any file under the project (except .git/, target/, node_modules/, dist/, build/, .next/, .venv/, __pycache__/, .metaphor/) invalidates the entry.

What's cached: only runs that exited 0. Failures always re-run so a flaky test doesn't get stuck "red".

What isn't cached: single-shot plugin invocations (they inherit stdio, which the cache can't replay losslessly). Cross-project side effects (writing to shared infra) — if a plugin mutates state outside its project tree, a cache hit won't re-apply the mutation.

Security note. Cache entries store the recorded stdout and stderr of successful runs verbatim — including any tokens, passwords, or URLs a plugin may have printed. Treat .metaphor/cache/ like CI log artifacts: don't commit it, don't share it, scrub it if a plugin leaks secrets. .metaphor/ should be in your .gitignore.

Known limitation — ignore list is fixed. The tree-hash skips a hard-coded set of directories (.git/, target/, node_modules/, dist/, build/, .next/, .venv/, __pycache__/, .metaphor/). There is no .metaphorignore or .gitignore integration yet. Files outside those dirs contribute to the hash even if your VCS would ignore them. If this bites you, metaphor cache clear + restructuring the project is the workaround until richer ignore rules land.

Bypass or manage the cache with --no-cache, metaphor cache clear, or metaphor cache stats.


Plugin passthrough commands

Each subcommand forwards its arguments verbatim to a plugin binary. Pass --help through to see the plugin's own help, e.g. metaphor schema --help.

Mapping table

metaphor command Plugin binary Forwarded as
metaphor schema <args…> metaphor-schema metaphor-schema <args…> (raw passthrough — no subcommand prefix)
metaphor webapp <args…> metaphor-schema metaphor-schema generate:webapp <args…>
metaphor make <args…> metaphor-codegen metaphor-codegen make <args…>
metaphor module <args…> metaphor-codegen metaphor-codegen module <args…>
metaphor apps <args…> metaphor-codegen metaphor-codegen apps <args…>
metaphor proto <args…> metaphor-codegen metaphor-codegen proto <args…>
metaphor migration <args…> metaphor-codegen metaphor-codegen migration <args…>
metaphor seed <args…> metaphor-codegen metaphor-codegen seed <args…>
metaphor dev <args…> metaphor-dev metaphor-dev dev <args…>
metaphor lint <args…> metaphor-dev metaphor-dev lint <args…>
metaphor test <args…> metaphor-dev metaphor-dev test <args…>
metaphor docs <args…> metaphor-dev metaphor-dev docs <args…>
metaphor config <args…> metaphor-dev metaphor-dev config <args…>
metaphor jobs <args…> metaphor-dev metaphor-dev jobs <args…>
metaphor docker <args…> metaphor-dev metaphor-dev docker <args…>
metaphor deploy <args…> metaphor-dev metaphor-dev deploy <args…>
metaphor agent <args…> metaphor-agent metaphor-agent agent <args…>

All passthrough commands accept -- and hyphen-prefixed arguments without metaphor itself trying to interpret them (trailing_var_arg = true, allow_hyphen_values = true).

metaphor schema <args…>

Schema parsing and code generation. Raw passthrough — what you type after schema is what metaphor-schema receives. Run metaphor schema --help for the plugin's own command list.

metaphor webapp <args…>

Webapp code generation. Forwards to metaphor-schema generate:webapp <args…>. The generate:webapp prefix is added automatically; you only supply the rest.

metaphor make <args…>

Laravel-style scaffolding (make:*). Forwards to metaphor-codegen make <args…>.

metaphor module <args…>

Module-level scaffolding inside a project. Forwards to metaphor-codegen module <args…>.

metaphor apps <args…>

Application-level scaffolding. Forwards to metaphor-codegen apps <args…>.

metaphor proto <args…>

Protocol buffer operations (buf / tonic). Forwards to metaphor-codegen proto <args…>.

metaphor migration <args…>

Database migrations. Forwards to metaphor-codegen migration <args…>.

metaphor seed <args…>

Database seeding. Forwards to metaphor-codegen seed <args…>.

metaphor dev <args…>

Development workflow (run, watch, hot reload). Forwards to metaphor-dev dev <args…>.

metaphor lint <args…>

Code quality and linting. Forwards to metaphor-dev lint <args…>.

metaphor test <args…>

Test generation and execution. Forwards to metaphor-dev test <args…>.

metaphor docs <args…>

Documentation generation. Forwards to metaphor-dev docs <args…>.

metaphor config <args…>

Configuration validation and management. Forwards to metaphor-dev config <args…>.

metaphor jobs <args…>

Job scheduling. Forwards to metaphor-dev jobs <args…>.

metaphor docker <args…>

Local docker-compose lifecycle. Forwards to metaphor-dev docker <args…>. Subcommands: up, down, logs, ps, restart, pull, build. Reads metaphor.deploy.yaml at the workspace root.

metaphor deploy <args…>

Remote deployment. Forwards to metaphor-dev deploy <args…>. Subcommands: push, rollback, status, logs, migrate, exec. Most subcommands read metaphor.deploy.yaml; deploy exec shells out to the workspace's infra project (./deploy.sh if executable, otherwise make deploy). Run metaphor deploy --help for the full plugin surface.

metaphor agent <args…>

Install Claude Code skills and subagents into a project's .claude/ directory. Forwards to metaphor-agent agent <args…>. Run metaphor agent --help for the plugin's own command list.


Plugin discovery

Plugin binaries are resolved at invocation time:

  1. If METAPHOR_PLUGIN_BIN_DIR is set, look for <dir>/<binary-name> and use it if it exists.
  2. Otherwise invoke the bare name and rely on $PATH.

See plugins.md for setup, troubleshooting, and the contract each plugin must implement.

Notes on argument parsing

  • Plugin commands use clap's trailing_var_arg = true and allow_external_subcommands = true. The CLI does no validation on the forwarded args — the plugin owns its own argument schema.
  • The --verbose flag is global and is consumed before the plugin args are forwarded. It does not propagate to the plugin process. If you need the plugin to be verbose, pass its own verbose flag (e.g. metaphor schema -- -v build).