Every metaphor subcommand. Core commands (init, list, add, sync, etc.) are implemented in-process; plugin commands forward to external plugin binaries via subprocess.
metaphor [-v|--verbose] <command> [args...]
| 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
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?.
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):
git clone --depth 1 <template> <name>.- Removes the template's
.git— the result is a fresh workspace, not a fork. - Stamps the template placeholders across every UTF-8 text file:
__project__→<name>(identifiers),__PROJECT__→Name(prose and titles). Binaries are skipped. - Overwrites the template's usage-guide
README.mdwith a product README for<name>. 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 doctorBare — --bare, or no name at all:
- Writes
metaphor.yamlwithversion: 1and an emptyprojectslist. - 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.yamlList projects registered in the workspace.
- Reads
metaphor.yamlfrom 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>
- Local projects:
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 viaManifest::validate, but surfaces if a cycle is ever introduced at runtime.
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 iffind_and_loadreturns one form andstd::env::current_dirreturns the other. Workaround: invoke from the canonical path, or canonicalizemetaphor.yaml'spath: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 deeperpath:still wins via the longest-prefix rule, but this overlap can be surprising — prefer distinct paths.
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_BINpoints the launcher at a specific binary for dev/testing. - Bare
metaphoron a TTY tries the UI first; withoutmetaphor-uiinstalled it prints the install hint and falls back to the REPL. - The launcher exports
METAPHOR_LAUNCHERso the UI always queries the binary that started it.
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-addedhelp/versionargs are omitted.
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.
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. Baremetaphoron 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.
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 ifgitisn't on$PATH;metaphor build's{git_sha}tags and--affectedneed it.docker available— only emitted when at least one project has aDockerfile. WARN ifdockerisn't on$PATH.
projects (per project)
directory exists— FAIL if the project'sresolved_pathis missing. This is the only project-level FAIL — everything else is advisory.missing .dockerignore— WARN if aDockerfileexists but.dockerignoredoes 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_DIRor$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.
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.
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 workspaceif 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.
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.0Errors:
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 rootif 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.
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.
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.
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-project — git 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.
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.
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.
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 execis the successor to the previous nativemetaphor deploy: it shells out to the workspace'sinfraproject (./deploy.shif executable, otherwisemake 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.
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 ofdeployment/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-runningto 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)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.
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:
- Not yet cloned (path doesn't exist) →
git clone <remote> <path>, thengit checkout <ref>if a ref is pinned. - Already cloned → if the ref changed or
--updateis set:git fetch --tags --prune, thengit checkout <ref>(orgit pull --ff-onlyif no ref is pinned). - Branch pins are fast-forwarded.
git fetchadvancesorigin/<ref>but leaves the local branch behind, so after checkout sync runsgit 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 noorigin/<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. - 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-sapiensAfter 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: deadbeef0123456789abcdef0123456789abcdefref— the value frommetaphor.yamlat 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.
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). |
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- 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-erroris set. Parallel mode always runs every scheduled job to completion and reports failures at the end.
--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.
- Single-shot plugin commands (no
--all/--projects/--affected) do not requiremetaphor.yaml. They just spawn the plugin from whatever cwd you're in. - Multi-project commands require a manifest —
find_and_loadwalks up from cwd looking formetaphor.yaml. No manifest → clear error. --parallel,--continue-on-error, and--no-cacheare all rejected without a selector flag (--all/--projects/--affected) so typos don't silently behave as single-shot.
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.
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.
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).
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.
Webapp code generation. Forwards to metaphor-schema generate:webapp <args…>. The generate:webapp prefix is added automatically; you only supply the rest.
Laravel-style scaffolding (make:*). Forwards to metaphor-codegen make <args…>.
Module-level scaffolding inside a project. Forwards to metaphor-codegen module <args…>.
Application-level scaffolding. Forwards to metaphor-codegen apps <args…>.
Protocol buffer operations (buf / tonic). Forwards to metaphor-codegen proto <args…>.
Database migrations. Forwards to metaphor-codegen migration <args…>.
Database seeding. Forwards to metaphor-codegen seed <args…>.
Development workflow (run, watch, hot reload). Forwards to metaphor-dev dev <args…>.
Code quality and linting. Forwards to metaphor-dev lint <args…>.
Test generation and execution. Forwards to metaphor-dev test <args…>.
Documentation generation. Forwards to metaphor-dev docs <args…>.
Configuration validation and management. Forwards to metaphor-dev config <args…>.
Job scheduling. Forwards to metaphor-dev jobs <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.
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.
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 binaries are resolved at invocation time:
- If
METAPHOR_PLUGIN_BIN_DIRis set, look for<dir>/<binary-name>and use it if it exists. - Otherwise invoke the bare name and rely on
$PATH.
See plugins.md for setup, troubleshooting, and the contract each plugin must implement.
- Plugin commands use clap's
trailing_var_arg = trueandallow_external_subcommands = true. The CLI does no validation on the forwarded args — the plugin owns its own argument schema. - The
--verboseflag 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).