Extensions
Extensions teach the CLI new capabilities. Each extension provides commands — build, test, lint, serve, publish — for a specific language, framework, or tool. The extension model is open: you can write extensions in any language and distribute them as workspace packages, npm packages, or standalone directories.
Official extensions
Putnami ships four official extensions:
| Extension | Purpose | Commands | Details |
|---|---|---|---|
@putnami/typescript |
TypeScript/JavaScript toolchain | build, test, lint, serve, generate | TypeScript extension |
@putnami/go |
Go toolchain | build, test, lint, serve | Go extension |
@putnami/python |
Python toolchain | test, lint, serve | Python extension |
@putnami/ci |
CI/CD automation | publish, release | CI extension |
Each language extension page documents its commands, flags, caching behavior, and templates in full.
Installing extensions
putnami deps add @putnami/typescript @putnami/go @putnami/pythonExtensions are auto-discovered from:
- Workspace-level entries in
putnami.workspace.json→extensionsfield - Scope-level entries in scope
putnami.json→extensionsfield - Project-level
devDependenciesinpackage.json
No manual registration is needed for npm-based extensions.
Extension caching
Discovered extensions are cached in .putnami/extensions.cache.json for fast startup. The cache auto-invalidates when dependencies change.
The extension manifest
Every extension is defined by a putnami.extension.json file. The manifest declares commands (the public API) and tasks (atomic executable units).
Minimal manifest
{
"$schema": "https://putnami.dev/schemas/putnami-extension.json",
"commands": {
"build": {
"run": [{ "id": "build", "task": "build-exec" }]
}
},
"tasks": {
"build-exec": {
"kind": "command",
"command": "echo",
"args": ["built"]
}
}
}Top-level fields
| Field | Required | Description |
|---|---|---|
$schema |
no | JSON Schema reference for editor support |
commands |
yes | Public commands exposed to users |
tasks |
no | Atomic executable units referenced by commands |
hooks |
no | Lifecycle hooks (e.g., preBuild) |
templates |
no | Project templates (see Templates) |
contracts |
no | Input/output schemas for task validation |
extensionDependencies |
no | Other extensions this one depends on |
workspaceDevDependencies |
no | NPM devDependencies required at workspace root |
autoServe |
no | Whether serve auto-discovers projects with this extension |
Commands
A command is a user-facing operation invoked via putnami <command>. It defines activation rules, CLI flags, and a pipeline of steps:
{
"commands": {
"build": {
"description": "Build the project",
"activationFiles": ["tsconfig.json"],
"flags": {
"target": {
"type": "string",
"default": "production",
"choices": ["development", "production"]
}
},
"run": [
{ "id": "generate", "task": "codegen", "dependsOn": ["^generate"] },
{ "id": "transpile", "task": "build-transpile", "dependsOn": ["generate"] },
{ "id": "types", "task": "build-types", "dependsOn": ["generate"] }
]
}
}
}Command fields:
| Field | Required | Description |
|---|---|---|
run |
yes | Pipeline step DAG |
description |
no | Human-readable description |
activationFiles |
no | File patterns that must exist for the command to activate |
flags |
no | CLI flags the command accepts |
defaults |
no | Default parameter values |
priority |
no | Higher priority wins when multiple extensions provide the same command |
quiet |
no | Suppress from job recap |
visibility |
no | public (default) or internal |
outputs |
no | Named outputs projected from step results |
channel |
no | Execution channel for concurrency control |
Commands only activate for projects that match activationFiles patterns. If no patterns are specified, the command activates for all projects that use the extension.
Flag properties:
| Property | Type | Description |
|---|---|---|
type |
"string" | "boolean" | "number" | "array" |
Value type |
short |
string? |
Short alias (e.g., "-r") |
description |
string? |
Help text |
default |
any? |
Default value |
required |
boolean? |
Whether the flag must be provided |
choices |
string[]? |
Valid values for string or array types |
Tasks
A task is an atomic executable unit — a subprocess with defined inputs, outputs, and caching:
{
"tasks": {
"build-transpile": {
"kind": "command",
"command": "bun",
"args": ["run", "{extensionRoot}/bin/build"],
"cwd": "{projectRoot}",
"timeoutMs": 300000,
"inputs": {
"source": { "from": "project", "files": ["src/**/*.ts", "src/**/*.tsx"] },
"config": { "from": "project", "files": ["tsconfig.json", "package.json"] }
},
"outputs": {
"dist": { "kind": "directory", "path": "dist/" }
},
"cache": {
"enabled": true,
"deterministic": true
}
}
}
}Task fields:
| Field | Required | Description |
|---|---|---|
kind |
yes | Execution kind (currently only command) |
command |
yes | Command to execute |
args |
no | Command arguments |
cwd |
no | Working directory (supports template variables) |
env |
no | Environment variables |
timeoutMs |
no | Execution timeout (default: 300000ms) |
inputs |
no | Input port declarations |
outputs |
no | Output port declarations |
cache |
no | Cache policy ({ enabled, deterministic }) |
inputSchemaRef |
no | Reference to a contract schema for input validation |
outputSchemaRef |
no | Reference to a contract schema for output validation |
Template variables in task fields:
| Variable | Expands to |
|---|---|
{extensionRoot} |
Absolute path to the extension directory |
{projectRoot} |
Absolute path to the project directory |
{workspaceRoot} |
Absolute path to the workspace root |
Input ports
Tasks declare their inputs via inputs. Each input has a source:
| Source | Description |
|---|---|
project |
Files from the project directory (use files for globs) |
task |
Output from another task in the pipeline |
params |
Command parameters |
env |
Environment variables |
runtime |
Runtime context (platform, arch, versions) |
Pipeline steps
Each step in a command's run array references a task and declares its position in the execution DAG:
| Field | Required | Description |
|---|---|---|
id |
yes | Unique step identifier |
task |
yes | Name of a task in the extension's tasks section |
dependsOn |
no | Step IDs or external references this step depends on |
with |
no | Input bindings passed to the task |
optional |
no | If true, failure marks step as skipped (not failed) |
if |
no | Plan-time condition (params only) — excludes step from DAG |
when |
no | Runtime condition (params + step results) — skips step |
cache |
no | Per-step cache override |
timeoutMs |
no | Per-step timeout override |
Dependency references in dependsOn:
| Pattern | Meaning |
|---|---|
"transpile" |
Wait for step transpile in the same command |
"^generate" |
Wait for the upstream dependency's generate step |
"/project-name:build" |
Wait for another project's build command |
"*generate" |
Wait for all providers of a generate step |
The ^ prefix is the most common — it creates cross-project dependency chains so downstream projects wait for their dependencies to complete the same phase.
Conditional execution:
if(plan-time): evaluated before the pipeline runs, removes the step entirely from the graphwhen(runtime): evaluated during execution, skips the step if false but keeps it in the DAG
{ "id": "types", "task": "build-types", "if": "params.emitTypes == true" }
{ "id": "deploy", "task": "deploy-exec", "when": "steps.test.status == 'success'" }Input bindings via with:
{
"id": "transpile",
"task": "build-transpile",
"with": {
"target": { "value": "production" },
"config": { "from": "command", "path": "flags.target" },
"schemas": { "fromStep": "generate", "output": "schemas" }
}
}The JSONL event protocol
Extensions communicate with the CLI via JSON Lines on stdout. Each line is a JSON object with protocol version v: 1 and a type field. Stderr is reserved for human-readable diagnostic output.
Invocation
The CLI spawns task commands as:
{command} [args...] --putnamiContext <file> --output jsonl [job-flags...]The --putnamiContext argument points to a temporary JSON file with workspace, project, extension, and parameter context:
{
"workspaceRoot": "/home/user/my-workspace",
"workspace": { "name": "my-workspace", "rootPath": "/home/user/my-workspace" },
"project": {
"name": "@myorg/my-app",
"path": "apps/my-app",
"fullPath": "/home/user/my-workspace/apps/my-app"
},
"extension": { "name": "my-extension", "path": "./extensions/my-extension" },
"job": { "name": "build" },
"params": { "target": "linux/amd64" },
"outputPath": "/tmp/putnami/my-extension/my-app/build",
"cacheRoot": "/tmp/putnami/my-extension/my-app"
}The project field is absent for workspace-level jobs.
Event types
| Event | Description | Key fields |
|---|---|---|
meta |
Job metadata (emit once at start) | data: { extension, job } |
log |
Structured log message | level, message |
phase |
Lifecycle phase markers | name, action (start/end), status on end |
progress |
Progress reporting | current, total, message |
diagnostic |
Errors/warnings with source location | severity, message, code, location: { file, line, column } |
metric |
Performance and size metrics | name, value, unit |
artifact |
Output artifact registration | id, name, kind, path |
summary |
Human-readable label for job recap | message |
result |
Job result (must be last event) | data: { status: "OK" | "FAILED" | "SKIP" } |
Example JSONL output
{"v": 1, "type": "meta", "time": "...", "level": "info", "message": "Starting build", "data": {"extension": "my-ext", "job": "build"}}
{"v": 1, "type": "phase", "time": "...", "name": "compile", "action": "start"}
{"v": 1, "type": "log", "time": "...", "level": "info", "message": "Compiling 42 files..."}
{"v": 1, "type": "progress", "time": "...", "current": 21, "total": 42, "message": "Compiling..."}
{"v": 1, "type": "diagnostic", "time": "...", "severity": "warning", "message": "unused import", "code": "TS2304", "location": {"file": "src/main.go", "line": 5, "column": 1}}
{"v": 1, "type": "metric", "time": "...", "name": "binary-size", "value": 4096, "unit": "bytes"}
{"v": 1, "type": "artifact", "time": "...", "id": "binary", "name": "App Binary", "kind": "file", "path": "bin/app"}
{"v": 1, "type": "summary", "time": "...", "message": "Build complete (4.1 KB)"}
{"v": 1, "type": "phase", "time": "...", "name": "compile", "action": "end", "status": "success"}
{"v": 1, "type": "result", "time": "...", "level": "info", "message": "Job OK", "data": {"status": "OK"}}Any-language extensions
Extensions can be written in any language — Go, Python, shell scripts, Rust — without requiring npm, TypeScript, or Bun. The only requirements are a putnami.extension.json manifest and executables that speak the JSONL protocol on stdout.
my-extension/
├── putnami.extension.json # Manifest with commands and tasks
└── bin/
├── build # Executable (any language)
├── test
└── lintExample: Shell script extension
putnami.extension.json:
{
"$schema": "https://putnami.dev/schemas/putnami-extension.json",
"commands": {
"build": {
"flags": {
"target": { "type": "string", "default": "release", "choices": ["debug", "release"] },
"clean": { "type": "boolean", "default": false, "description": "Clean before building" }
},
"run": [{ "id": "build", "task": "build-exec" }]
}
},
"tasks": {
"build-exec": {
"kind": "command",
"command": "{extensionRoot}/bin/build",
"cwd": "{projectRoot}",
"inputs": {
"source": { "from": "project", "files": ["src/**/*", "Makefile"] }
},
"cache": { "enabled": true }
}
}
}bin/build:
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/putnami-jsonl.sh"
CONTEXT_FILE="" TARGET="release" CLEAN="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--putnamiContext) CONTEXT_FILE="$2"; shift 2 ;;
--output) shift 2 ;;
--target) TARGET="$2"; shift 2 ;;
--clean) CLEAN="true"; shift ;;
--no-clean) CLEAN="false"; shift ;;
*) shift ;;
esac
done
[ -z "$CONTEXT_FILE" ] && { echo "Error: --putnamiContext required" >&2; exit 2; }
parse_context "$CONTEXT_FILE"
emit_meta "$PUTNAMI_CTX_EXTENSION_NAME" "$PUTNAMI_CTX_JOB_NAME"
emit_phase_start "build"
emit_log "info" "Building $PUTNAMI_CTX_PROJECT_NAME (target=$TARGET)..."
if make -C "$PUTNAMI_CTX_PROJECT_PATH" "$TARGET" 2>/dev/stderr; then
emit_phase_end "build" "success"
emit_result "OK"
exit 0
else
emit_phase_end "build" "failed"
emit_result "FAILED"
exit 1
fiExample: Python extension
putnami.extension.json:
{
"$schema": "https://putnami.dev/schemas/putnami-extension.json",
"commands": {
"test": {
"run": [{ "id": "test", "task": "test-exec" }]
}
},
"tasks": {
"test-exec": {
"kind": "command",
"command": "{extensionRoot}/bin/test",
"cwd": "{projectRoot}",
"inputs": {
"source": { "from": "project", "files": ["**/*.py"] }
},
"cache": { "enabled": true }
}
}
}bin/test:
#!/usr/bin/env python3
import argparse, json, subprocess, sys
from datetime import datetime, timezone
def emit(event):
event.setdefault("v", 1)
event.setdefault("time", datetime.now(timezone.utc).isoformat())
print(json.dumps(event), flush=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--putnamiContext", required=True)
parser.add_argument("--output", default="jsonl")
parser.add_argument("--verbose", "-V", action="store_true")
args = parser.parse_args()
with open(args.putnamiContext) as f:
ctx = json.load(f)
project = ctx.get("project")
if not project:
emit({"type": "result", "data": {"status": "SKIP"}})
sys.exit(0)
emit({"type": "meta", "level": "info", "message": "Starting test",
"data": {"extension": ctx["extension"]["name"], "job": "test"}})
emit({"type": "phase", "name": "test", "action": "start"})
result = subprocess.run(
["python", "-m", "pytest", "-v" if args.verbose else "-q"],
cwd=project["fullPath"], capture_output=True, text=True,
)
if result.returncode == 0:
emit({"type": "phase", "name": "test", "action": "end", "status": "success"})
emit({"type": "result", "level": "info", "message": "Job OK", "data": {"status": "OK"}})
else:
for line in result.stdout.splitlines():
if "FAILED" in line:
emit({"type": "diagnostic", "severity": "error", "message": line.strip()})
emit({"type": "phase", "name": "test", "action": "end", "status": "failed"})
emit({"type": "result", "level": "info", "message": "Job FAILED", "data": {"status": "FAILED"}})
sys.exit(1)
if __name__ == "__main__":
main()Lifecycle hooks
Extensions can define hooks for cross-cutting concerns like code generation.
Command hooks
Command hooks execute as subprocesses:
{
"hooks": {
"preBuild": {
"kind": "command",
"command": "bunx",
"args": ["@my/extension:generate"],
"cwd": "{projectRoot}",
"timeoutMs": 120000
}
}
}Module hooks
Module hooks import JavaScript modules directly:
{
"hooks": {
"onLoad": "./hooks/on-load",
"onBeforeJob": "./hooks/on-before-job",
"onAfterJob": "./hooks/on-after-job"
}
}Registering local extensions
Add extension paths to putnami.workspace.json:
{
"extensions": [
"./my-extension",
"/opt/shared-extensions/custom-ext"
]
}Paths can be relative (resolved from workspace root) or absolute. Each path must contain a putnami.extension.json.
Command priority
When multiple extensions provide the same command name, priority determines which one runs:
{
"commands": {
"build": {
"priority": 10,
"run": [{ "id": "build", "task": "build-exec" }]
}
}
}Higher priority value wins (default: 0). Only the highest-priority matching command runs for each project.
Managing extensions
putnami extensions list # List all active extensions
putnami extensions install # Install extensions from workspace config
putnami extensions update # Update to latest compatible versions
putnami extensions remove <ext> # Remove an installed extension
putnami extensions validate [path] # Validate an extension manifest
putnami extensions test [path] # Run extension tests
putnami extensions package [path] # Package for distributionValidating extensions
putnami extensions validate checks:
- JSON Schema conformance
- All pipeline steps reference defined tasks
- Pipeline DAG is acyclic
- Activation file patterns are valid globs
- Contract schema references are valid
putnami extensions validate ./my-extension