Command set and the interactive and flag-based interaction model (R6).
CLI sketch#
run <profile> [--refresh] [-- claude args](--refreshchecks tracked branches for a new commit now)lsshow <p>(resolved closure, overrides, token estimate)diff <a> <b>dry-run <p>(prints exactclaudecommand)init(create config, optionally from an org data repo URL, pinned with--refor tracking--branch)new <p> [--scope user|project] [--from <p2>]edit <p>trust <p>account add <name> [--dir] [--default] [--marketplace] [--plugin],account ls,account rm <name>(named accounts, see launcher.md, section 3). The global--account <name>picks one for any command.config [show|path|source ls|add|pin|rm|set|unset|edit](change the configuration afterinit, see below)shell-init <bash|zsh|fish|pwsh>compile [--check]lintcatalog init [dir]catalog buildsearch <q>recommend(rule-based, no LLM)doctor [--policy](overlap, unused, deprecated-in-use, stale owners, policy shadowing, capability matrix)update [--check] [--version vX.Y.Z] [--prerelease] [--dry-run] [--require-signature] [--rollback] [--yes] [--force] [--allow-downgrade](verified self-update, see update.md).
Interaction model (R6)#
Requirement R6 (decided 2026-10-06): the CLI supports both an interactive mode (prompts, pickers and wizards) and a flag and option based mode. Neither replaces the other.
Rules#
- Flags are the contract. Every command is fully usable with flags and arguments alone, for scripts, CI, shell aliases and documentation. Interactive mode is an additional front-end over the same command definitions, never the only way to do something.
- When interactive mode is used. Only when all of these are true:
- both stdin and stdout are terminals
--no-interactiveis not set- the
CIenvironment variable is not set - the terminal is not
dumb.
Otherwise a missing required value is an error, never a prompt. The error names the missing flags and exits with code 2 (usage).
- Flags beat prompts. The CLI does not ask again for a value given on the command line. Interactive mode only asks for what is missing.
- Parity. Every interactive flow ends by printing the equivalent flag-based command line (for example
Equivalent: ccshelf new sre --from base --plugin sre-kit@acme), so a script can replay a session. - Trust prompts fail closed. To accept a changed profile closure (any change, with risky items marked), the user must give one of these: an explicit interactive confirmation, or an explicit flag that names what is accepted. An example of the flag:
ccshelf trust sre --accept <closure-hash>.--yesnever accepts trust, and non-TTY and CI runs never default to allow (see the trust model in profiles.md, "Profile sources and sharing"). - No prompts after
claudestarts. The launcher prompts only before it startsclaude. It restores the terminal to its original state before it starts or replaces itself withclaude, so Claude Code's own terminal handling is untouched. - Machine-readable output.
- Read commands (
ls,show,search,doctor,diff) support--json(stable schema, versioned) and a plain text format. show --jsonanddiffuse the effective value of a field that a profile does not set. Inmcp,claudeai_connectorsiskeepandstrictisfalse, and thedefaultsarray lists the fields that fell back to these values. A profile that leaves a field unset and a profile that sets the default value give the samediffresult.- The CLI honors
NO_COLORand--no-color. - Color is never the only carrier of meaning.
--plaingives a line-oriented prompt mode for screen readers and dumb terminals.
- Read commands (
- Stable exit codes (0 success, 1 failure, 2 usage error, 3 blocked by policy, 4 needs trust, 130 interrupted), documented, so scripts can react.
- Shell completion for bash, zsh, fish and PowerShell comes from the same command definitions.
Keyboard prompts (D-46)#
Select and MultiSelect questions use an inline keyboard picker when stdin is a capable, file-backed terminal. The keys:
- up/down navigate without wrapping
- Enter chooses or submits
- Space toggles a multi-selection
- typing filters a filterable question (Backspace erases, Ctrl+U clears).
A multi-selection survives filtering, including an empty result, and returns the original option indexes in ascending order. Frames stay within 100 columns, 14 rows and 10 visible options, further bounded by the real terminal size. The picker sanitizes and caps each external segment, so a hostile profile cannot forge or overflow a prompt line.
The picker falls back to the numbered line prompt described above whenever the keyboard path cannot be trusted:
--plain- a non-TTY
CI--json- a redirected or non-file prompt stream
- an unsupported raw/VT console
- a terminal too small for the full key legend.
Both paths honor NO_COLOR/--no-color (the keyboard output contains no color escapes). Both cancel with the existing interrupted mapping (Escape, Ctrl+C, Ctrl+D, Ctrl+Z, EOF, context/signal cancellation).
Before returning, spawning or replacing itself with claude, a keyboard prompt restores the terminal to its original state (raw input and scoped output). It reports a restoration failure rather than ignoring it. A Linux PTY test asserts the exact before/after state and that the next reader still receives its whole line. The prompt reads input by synchronous polling on Linux/macOS and by Win32 console events on Windows, so no background reader is left behind. A resize during a picker exits safely with a retry with --plain hint instead of redrawing with stale dimensions.
A picker answers with the highlighted row on Enter, including a question that has no opt-in default (HasDefault). The numbered line prompt remains the strict form, which re-asks a question without a default.
Wizards and write confirmations (D-47)#
The full init and new wizards gather only what flags did not provide. Then they print a sanitized summary of exactly what they will write and ask a default-no confirmation (Write this configuration?, Create this profile? (does not grant trust)). The command-local --yes answers only that write question. It never accepts trust, and on its own it neither starts nor suppresses the wizard. Declining, aborting or cancelling before the gate writes nothing (the wizard re-checks a canceled read immediately before the first write). A declined confirmation is a failure with the command's own message (canceled: nothing was written for new, configuration not written for init). An abort or cancel keeps the interrupted code.
The equivalent command replays the run prompt-free:
initrecords--update-mode <effective> --yes(plus--forceand--configwhen used).newrecords--scope,--yesand--no-interactivewith its content flags.
A partial-flag run prints no equivalent command because it asked nothing.
config: change the configuration after init (D-50)#
init stays the only command that creates config.toml (and the only one that replaces a whole file, with --force). Everything after setup goes through ccshelf config, which works with flags alone:
config pathconfig show(sources numbered from 1,--jsonkindconfig)config source ls|add|pin|rmconfig set|unset <key> [value]config edit.
In a terminal, bare ccshelf config is a menu (add a source, change a pin, remove a source, change a setting, open in the editor). The menu returns after each action, including a declined write or a refused change (it reports the problem). Only Done or an interrupt leaves it. With no terminal or --no-interactive it prints help and exits 2. A command that lacks a value (source add with no source flag, source pin/rm with no number, set with no key) asks only on a terminal. Otherwise it exits 2 and names the flag.
- Allowlist.
setandunsetaccept onlytrust.on_change,trust.require_pin,trust.trust_project_profiles,trust.branch_check_interval,update.mode,update.interval,catalog.remote_url,default_account,ui.colorandui.interactive. The same validator that the file uses checks each one. The commands refuse these keys with exit 2 and a pointer to the dedicated command or toconfig edit, because they widen what ccshelf trusts or runs:- accounts
- sources
claude.path- the update source (
update.base_url,update.cosign_identity_repo,update.asset_hosts).
- Write confirmation. Like the wizards above, a write shows the changed lines (before and after) and asks a default-no
Write this configuration?.--yesanswers only that question, and declining exits 1 withconfiguration not written. Without a terminal, the command writes a non-weakening change without asking. Nothing here fetches a source or records trust. No command in this group toucheslock.jsonorproject-trust.json. In a terminal the wizard path prints the equivalent flag command, ending in--yes. - Weakening changes. These need
--yeswhen there is no terminal (exit 2 naming--yes) and always print a warning:trust.require_pinturned offtrust.trust_project_profilesturned onupdate.modeset toinstall- a new or re-pinned git source whose ref is not a tag or full commit id (possible only while
require_pinis off) - a git source that is added with
--branch, or switched to a branch or to another branch (D-55). The branch can move, but every new commit still needs trust.
A change to what is trusted to supply code or releases also weakens:
claude.pathupdate.base_urlupdate.cosign_identity_repo- a new
update.asset_hostsentry - the
marketplaceof an existing plugin source - the URL of an existing git source (compared by position when the number of sources is unchanged, so a removal is not flagged, but a one-step swap of sources is flagged, conservatively)
- any new
dirsource whose path starts with$(the variable is read at every run).
These changes are not weakening:
- going back to a stricter or default value
- changing
trust.on_change - removing a source
- adding a pinned source (a new source still needs trust before any of its profiles run).
- Branches (D-55).
init --git-url,config source add --git-urlandconfig source pintake--branch <name>instead of--ref. Giving both is a usage error (exit 2).pincan switch a source between a tag or commit and a branch, and it clears the other key. In a wizard, the question for a tag or commit also acceptsbranch:NAME, and the printed equivalent command uses--branch.initalso prints the weakening warning for a branch. It never asks for--yesbecause of it, becauseinitkeeps its meaning for--yes(the write in the full wizard).runanddry-runtake--refresh(see profiles.md, "Tracking a branch").trust.branch_check_intervaltakes a duration from 1h to 8760h and is not a weakening change. - Files.
- The file must already exist (exit 1 with a hint to run
init). - A write re-encodes the struct, so comments and layout are lost. The summary says so when the file has a
#comment. The command keeps the previous file asconfig.toml.bak(0600, replaced atomically, a symlink at either name is refused). - The command compares the bytes read at the start again just before the write. If the file changed meanwhile, nothing is written (exit 1,
changed while editing). - The confirmation diff compares the file as it is with the exact bytes about to be written, so dropped comments, added empty tables and any layout change are visible.
- The command validates paths (
~,$VARin account directories andclaude.path) as they would expand, but writes them as the user wrote them, so asetchanges only its own lines.account addandrmuse the same unexpanded, checked save: they keepconfig.toml.bakand refuse to write when the file changed since they read it. An existing entry and an explicit--dirkeep the text that the user wrote, and the default directory is written as an absolute path. Anaccount addthat changes nothing writes nothing. When anaccount addoraccount rmwrites over a file that has comments, it printswarning: wrote <path> without its comments (the previous file, with the comments, is kept as <path>.bak)on stderr after the write. The backup holds the comments. It prints no warning for a skipped write, a new file or a file without comments. With--json,account addandaccount rmsetcomments_dropped.account rm --jsonwrites kindaccount-rmwithname,config_dir,was_default,directory_deleted(alwaysfalse) andcomments_dropped(D-64). A missing file is created byaccount add, and the create is refused if the file appeared since it was read. - The command refuses duplicate sources (same type and repository, directory or plugin id) with a hint to use
source pinorsource rm. It compares git URLs after lower-casing the host and dropping a trailing/and.git, andgit@host:pequalsssh://git@host/p. Another folder (--path) of the same repository is a different source. - Validation errors name sources from 1, like
showandls.
- The file must already exist (exit 1 with a hint to run
config editopens a copy (0600, created exclusively in the config folder) in$VISUALor$EDITORand checks it like the real file. Only if the copy is valid does it replaceconfig.tomlwith the text exactly as saved (comments kept, previous file in.bak). For an invalid copy, the command prints the errors (with line and column when the parser gives one, and value errors name the key). It leaves the original untouched, keeps the copy and exits 1. If the editor fails, the command keeps the copy and prints its path, as for an invalid copy. A newconfig editstarts from the current file, so copy the fix over.config editmay repair a currently invalid file, needs a terminal (--pathprints the path instead), and does not create a missing file.
Human-readable presentation (D-42)#
The CLI uses restrained presentation: task-grouped root help, a short getting-started sequence, readable labels, and explicit hint: lines rather than banners or emoji. Root help groups commands into profiles, discovery and diagnostics, org data repo maintenance, and setup and utilities. Usage errors point to the failing command's --help unless a more specific recovery hint or missing-value instruction is already available.
Tables keep their aligned layout at normal widths. When the terminal cannot fit even the minimum column widths, they switch to wrapped, labeled records separated by blank lines, retaining values rather than truncating them. The fallback also works with --plain. Piped output has no terminal-width limit and retains the aligned layout. A terminal only one column wide cannot display a two-column character, so that character becomes ? in the fallback.
An empty profile or account list, a search with no matches, and an empty recommendation result are successful results on stdout, with a next-step hint:. Diagnostics and prompts remain on stderr. JSON keeps its existing envelope, collection types and exit codes, without human-only empty-state text. Scripts should use --json rather than parse presentation layouts.
update (D-40)#
ccshelf update follows the rules above. Its flags, confirmation, Equivalent: line, exit codes, --json output, trust model and the automatic update ([update] keys, terminal and CI conditions, CCSHELF_NO_UPDATE_CHECK) are in update.md.
Scope for the first release#
Interactive:
- a profile picker when
ccshelf runor bareccshelfis run in a terminal without a name (filter as you type) - a
newwizard (name, user/project location, parent, multi-select of installed plugins, standalone skills to hide, MCP servers) ending in a default-no creation confirmation - an
initwizard (config location, profile sources, optional account, update mode) ending in a default-no write confirmation - the
trustdiff-and-confirm editopens$EDITOR.
Not in scope for the first release: a full-screen dashboard or persistent TUI manager.
Design implications#
- One command tree (a single source for flags, help, completion and prompts) and a thin prompt layer behind an interface (
Prompter), so tests can feed scripted answers to flows. - Library choice is deferred to implementation, with constraints:
- static binary, no cgo
- works on Windows consoles (Windows Terminal and legacy conhost, with a plain fallback)
- minimal dependencies (supply-chain surface)
- no network use.
- Testing: non-interactive behavior with golden files on every OS. Interactive flows with the scripted
Prompterin unit tests plus a small pty smoke test per OS (ConPTY on Windows). - Cross-platform: raw-mode terminal handling differs per OS. The terminal restore of rule 6 interacts with the Windows spawn-and-wait versus Unix
execchoice. - Security: prompts must not echo secrets, and an interactive default must never be less safe than the flag default. The implementation:
- A question has no default unless it opts in (
HasDefault). - Trust confirmations use
ConfirmRisky, which has no default and needs the typed wordyes. - Labels, hints and errors are single-line sanitized, so a hostile profile cannot forge a prompt line.
- The terminal's echo state is restored when a secret prompt is cancelled.
The
CIvariable set to any non-empty value (includingfalse) disables prompts.ccshelfflags placed after the profile name (they would go toclaude) exit 2 with a hint,editneeds a terminal, andls --refreshre-resolves moved tags and tracked branches. In the printed equivalent command, the CLI redacts secret-looking flags and the argument after one. - A question has no default unless it opts in (
catalog init: set up an org data repo#
ccshelf catalog init [dir] creates a new org data repo, or adds the missing pieces to an existing marketplace repo. It never changes an existing file silently (see catalog-and-org-repo.md, "Setting up the data repo"). It follows the interaction rules above:
- Flags are the contract.
--marketplace-name,--org,--owner,--platform-owners(repeatable or comma separated)--ccshelf-ref,--ccshelf-version,--runner-label,--default-branch--mode new|adopt,--sidecars stub|none--profiles-only: a repo of profiles with no marketplace or catalog.--marketplace-name,--ownerand--sidecars stubare then usage errors. The printedEquivalent:command keeps it. In a terminal, when the flag is not given, the wizard asks "Does this repo hold only profiles (no plugin marketplace or catalog)?" before the marketplace-name question. The default is no. It does not ask when a marketplace exists (the default file or one incatalog.marketplaces), when--marketplace-name,--owner,--sidecars stubor--no-configis given, or when an existingccshelf.tomlwould make the answer yes impossible. An existingccshelf.tomlwithenabled = falsemeans profiles-only, with or without a terminal: ccshelf prints a note and does not ask. Without a terminal there is no question.--example-profile- the
--no-<group>flags (--no-config,--no-marketplace,--no-sidecars,--no-codeowners,--no-workflows,--no-readme,--no-gitattributes,--no-gitignore) --dry-run,--yes,--force,--write-suggestions--quiet(do not print the suggested lines ofneeds-mergefiles, which stay in--json)--git-init- the global
--json(data kindcatalog-init) and--root(the directory when no argument is given).
- Required values. The command needs:
- the marketplace name (unless an existing
marketplace.jsonhas it or the marketplace and README are skipped) - the platform owners (unless
ccshelf.tomland CODEOWNERS both exist or are skipped).
On a terminal, the command asks for missing values. It derives defaults from the directory name, an existing CODEOWNERS catch-all or the organization in the
originremote URL (read-only). It asks once for a missing action pin. Without a terminal (or withCI,--no-interactiveor--json) a missing value exits 2 and names every missing flag,--yesincluded. - the marketplace name (unless an existing
- Confirmation. Without
--yesa terminal run shows the plan and asksWrite these files?(default no). A non-interactive run needs--yes, or--dry-runto print the plan and write nothing.--yesconfirms the plan only. It has nothing to do with trust. - Parity. An interactive run ends with the
Equivalent:line on stderr (every value that was chosen, plus--yes, or--dry-runwhen that was the choice). - Exit codes.
- 0 on success (including
nothing to doand a plan withneeds-mergeentries). - 2 for an invalid or missing flag value and for a refused target argument (
.., the file system root, the home directory or a directory above it,~/.claude,$CLAUDE_CONFIG_DIR, ccshelf's config and cache directories, the tool repository). - 1 for a file system condition: the target or a parent is a symbolic link or not a directory, a conflict such as an existing
.bakor a directory where a file belongs, a write error (the files written are listed, and removed again when this run created them), a declined confirmation,git initfailing. - 130 when interrupted.
- 0 on success (including
- Scope. Nothing is committed, pushed or fetched.
--git-initruns onlygit init(initial branchmain) and only when there is no.git.
Reading a published catalog#
Outside an org data repo, ccshelf search can retrieve the published catalog.json when [catalog].remote_url is set in the user's ~/.config/ccshelf/config.toml (or the platform's config directory). For each search it fetches over HTTPS, caps the response at 8 MiB, and requires the current catalog format version. This setting takes precedence over the configured profile sources for search and profile recommendations. Plugin recommendations still need the org data repo's marketplace relevance rules. catalog build and doctor still use org data repo files.