Workspace CLI (ws) — Contributor Guide
How to add new subcommands, classify their permission level, and maintain the security model.
Purpose
While many utility frameworks exist to provide convenience for a human user, this setup is also meant to simplify commands in a flexible workspace for AI agents resulting in clearer commands that can be auto-approved more meaningfully and safely.
Over time as more common patterns are detected they can result in new convenience scripts for better overall visibility and value in the utility framework. This is made available directly as an agent skill, auto-enabled at least for Claude.
Architecture
scripts/ws is a bash dispatcher. Each subcommand either:
- Delegates to an existing scripts/*.sh script (e.g. ws list → ws-list.sh)
- Wraps a script with component-directory resolution (e.g. ws push mimir → cd components/mimir && git-push.sh)
The dispatcher handles argument parsing, component validation, and help text. Existing scripts remain standalone and unchanged.
Adding a New Subcommand
1. Write the script (or identify an existing one)
Follow the existing pattern:
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
2. Add to the dispatcher
In scripts/ws, add three things:
a) Help text — add a # comment line in the header block (between
# Commands: and the blank line):
# mycommand [args] Description of what it does
b) Case entry — add before the *) catch-all:
mycommand)
bash "$SCRIPT_DIR/my-script.sh" "$@"
;;
Or if it needs component resolution:
mycommand)
ws_mycommand "$@"
;;
c) Function (if component-aware) — add with the other ws_* functions:
ws_mycommand() {
local comp="${1:-.}"
shift
ws_validate_component "$comp"
cd "$COMPONENT_DIR" && bash "$SCRIPT_DIR/my-script.sh" "$@"
}
3. Classify its permission level
Every subcommand falls into one of three tiers:
| Tier | Auto-approve? | Deny rule? | Examples |
|---|---|---|---|
| Safe | Yes (allow) | No | list, status, clone, pull, resolve, vscode, test, review (listing/status), log, clean, realm list, realm init, hoard list, hoard init, hoard init <template>, hoard init <template> <args>, component list, component init <flavor>, component init <flavor> <name>, actions |
| Side-effect | User's choice (ask) | No | push, push --force, pr, issue, commit, review --resolve*, realm <url>, hoard <url> (URL clones touch arbitrary external git URLs) |
| Arbitrary execution | Always asks (deny) | Yes | exec |
Safe: Read-only or creates local files only. Add to the allow list in
.claude/settings.json.
Side-effect: Modifies external state (pushes code, creates issues/PRs, sends messages). Prompts by default but users can whitelist for bulk operations. Do NOT add a deny rule — let users decide.
Arbitrary execution: Takes user-provided commands and runs them. Must have
a deny rule in .claude/settings.json. Currently only exec is in this tier.
4. Update .claude/settings.json
{
"permissions": {
"deny": [
"Bash(bash scripts/ws exec *)",
"Bash(bash scripts/ws exec * *)",
"Bash(bash scripts/ws exec * * *)"
],
"allow": [
"Bash(bash scripts/ws mycommand)",
"Bash(bash scripts/ws mycommand *)"
]
}
}
Note: Deny patterns shown above are truncated. The actual
.claude/settings.jsonincludes patterns for all supported arities (up to 7 wildcards). Always check the repo's committed file for the full set.
5. Update docs
scripts/wshelp text (already done in step 2a)CLAUDE.md— add to the Available commands list if it's a common commanddocs/dev-setup.md— add to the commands table- This file — update the tier table above if adding a new tier
Security Rules
These apply to all subcommands:
- Never
eval— use"$@"for command passthrough - Validate component names — use
ws_validate_component, which checks the regex^[a-z][a-z0-9-]*$and verifies againstecosystem.yaml - Quote everything —
"$target","$@","$ROOT_DIR" - Don't source
.envin the dispatcher — only in scripts that need tokens - Bash 3.2 compatible — no associative arrays, no
${var,,}, noreadarray
Why exec always requires human approval
ws exec <comp> <cmd...> runs arbitrary commands. If it were
auto-approvable, a compromised prompt or injected instruction could run
anything on the system. The deny rule in .claude/settings.json ensures
every exec invocation requires human approval, regardless of user settings.
Component name validation
Component names pass through yq expressions (.components.$name). The
regex ^[a-z][a-z0-9-]*$ prevents:
- Path traversal (../../etc)
- Shell metacharacters (;, |, $, etc.)
- Newline injection (bash =~ matches full string, not per-line)
- yq expression injection (no dots, brackets, etc.)
Forking and renaming
The workspace name yggdrasil appears as a special case in ws_validate_component
(one string comparison) and throughout documentation. To fork and rename:
- Change the
"yggdrasil"check inscripts/ws→ws_validate_component() - Search docs for
yggdrasiland update narrative references - Update remote names and org prefixes in
scripts/git-push.shandscripts/git-cr.shto match your fork's remote naming - Update the domain in
ecosystem.yaml - Update
.mcp.jsonif you change component names that host MCP servers
The name is intentionally not stored in a variable — it's a single check in a security-sensitive function, and indirection would add complexity for a one-time operation.
Reserved component names
The following names are reserved and cannot be used as component names:
| Name | Reserved in | Reason |
|---|---|---|
yggdrasil |
ws_validate_component, ws-review.sh |
Refers to the workspace root itself |
help |
ws-review.sh |
Subcommand keyword — ws review help shows usage |
If you add new subcommand keywords to any ws-*.sh script, guard them before
the component name validation block (see the help check in ws-review.sh as
a pattern).
Local Permission Overrides for Bulk Operations
Side-effect commands (push, cr, issue, commit) prompt for approval by default.
For bulk operations (filing multiple issues, pushing several components),
you can auto-approve them in your local settings.
Setup: Create .claude/settings.local.json (gitignored) and add the
patterns you want to auto-approve. In Claude Code permission
rules, each * matches one argument (not multiple). So multi-argument
commands need one * per argument:
{
"permissions": {
"allow": [
"Bash(bash scripts/ws push * *)",
"Bash(bash scripts/ws cr * * *)",
"Bash(bash scripts/ws issue * * * *)",
"Bash(bash scripts/ws commit * *)"
]
}
}
| Pattern | Matches | Example |
|---|---|---|
Bash(bash scripts/ws push *) |
Push with component only | ws push mimir |
Bash(bash scripts/ws push * *) |
Push with component + branch | ws push mimir feat/foo |
Bash(bash scripts/ws cr * * *) |
CR with component + title + bodyfile | ws cr mimir "feat: add X" .crs/x.md |
Bash(bash scripts/ws issue * * * *) |
Issue with all 4 args | ws issue mimir "fix: Y" bug .issues/y.md |
Bash(bash scripts/ws commit * *) |
Commit (bodyfile-driven) | ws commit mimir .commits/race-fix.md |
Note: ws exec always requires human approval — the project-level
deny rule in .claude/settings.json cannot be overridden by local settings.
See "Why exec always requires human approval" above.
ws component init
Scaffolds a new component into components/<name>/ from a template
shipped at templates/components/<flavor>/.
Usage:
ws component init <flavor> [name] [template-args]
ws component list
Behavior:
- Validates the flavor exists. Errors with the available-flavors list if not.
- Resolves the component name. Required; prompted on a tty if omitted.
- Pre-flight checks:
components/<name>/doesn't exist,<name>isn't already inecosystem.local.yaml. Warns + confirms if<name>shadows a realm-catalog entry. - Copies the template directory into
components/<name>/. git init -b main, initial commit using the user's git config —git config user.namefor the author name (fallback toidentity.human_account) andgit config user.emailfor the email (fallback to<human_account>@local).- Adds an entry to
ecosystem.local.yamlundercomponents.<name>.repo(matches the fieldws-clone.shreads). The URL is inferred fromidentity.human_accountand the component name, in canonicalhttps://github.com/<user>/<name>.gitform. - Prints educational output explaining the local-first → upstream-when-
ready flow plus a flavor-appropriate
gh repo createsuggestion.
ecosystem.local.yaml is the per-developer (gitignored) layer of the
three-layer merge. The new component is immediately usable from this
workspace; when ready to share with the community, move the entry into
the realm's ecosystem.yaml with realm-appropriate fields (tier,
chartVersion, etc.) and push the realm.
Currently shipped flavors:
gh-pages— tutorial-friendly GitHub Pages site, bare markdown + default GitHub Jekyll theme. Comprehensive README walks the user through the edit → PR → bot-review → merge → see-it-live demo loop.
Per-flavor flag handling is hardcoded in scripts/ws-component.sh
for v1.
Finding Patterns Worth Scripting
Use the workflow-auditor skill (.agent/skills/workflow-auditor/SKILL.md)
to detect repeated manual workarounds that could become new subcommands.
The skill triggers on 3+ instances of the same awkward pattern in a session.
Common progression: manual workaround → noticed by auditor → proposed as
script → reviewed → added to ws → classified and permissioned.