Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ CLAUDE.local.md
docs/backlog.jsonl
docs/worklog.jsonl
.umbel-bundle
# delivery-superpowers: the locations hook redirects superpowers' specs/plans
# here instead of the vendored docs/superpowers/ default. Machine-local only.
.local

# beads: the Dolt DB is the source of truth, backed to the git remote via
# `bd dolt push` (refs/dolt/data); a clone runs `bd bootstrap`. The DB, credential,
Expand Down
73 changes: 58 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,19 @@ umbel skills [options] # low-level skill installer (v0 picker)
```

When invoked without `[name]` on a TTY, `run` / `apply` / `show` / `build`
open a single-select picker. For `run` and `apply` the picker prepends a
`(vanilla)` row meaning "no bundle, plain claude." Pinned bundle (or
vanilla pin) is pre-selected.
open a picker — **full** or **scoped**, depending on the pin:

- **Full picker** (no pin, or `run`/`apply`/`show`/`build` with no arg): every
discovered bundle, `(vanilla)` row prepended. On non-TTY, `run` falls through
to vanilla; the others error with a hint to pass `<name>` or pin.
- **Scoped picker** (multi-candidate pin + `run`): exactly the pin's candidates,
default (first) pre-selected, `(vanilla)` row only if `__vanilla__` is listed
in the pin. Ephemeral — selecting a candidate does not rewrite the pin.
(this variant fires for `run` only; `apply`/`show`/`build` use the full picker)

`show` and `build` always use the full picker but pre-select the default
candidate when a pin is present. The current pin (or vanilla pin) is
pre-selected in all picker contexts where it applies.

## PATH shim (recommended)

Expand All @@ -204,32 +214,65 @@ umbel shim install # writes ~/.local/share/umbel/bin/claud
export PATH="$HOME/.local/share/umbel/bin:$PATH"
```

After that, plain `claude` in a project with a `.umbel-bundle` pin runs
under that bundle. In a project without a pin, the shim shows the picker
After that, plain `claude` in a project with a single-candidate pin runs
directly under that bundle. A multi-candidate pin opens the scoped picker
so you choose which candidate to use for that launch — every launch, not
just the first. In a project without a pin, the shim shows the full picker
so you can choose a bundle for that session or pick `(vanilla)` to run
plain claude. Non-interactive shells fall back to vanilla automatically.
plain claude. Non-interactive shells fall back to vanilla automatically
(or to the default candidate, if the pin has one).

To opt out of all umbel routing for one invocation, call claude by its
absolute path, or temporarily `unset PATH`'s shim entry.

## Pin file

`<project>/.umbel-bundle` is plain text, one line:
`<project>/.umbel-bundle` is plain text, one candidate per line:

```text
discovery # primary: the bundle I use most here
delivery # also relevant on this repo
# __vanilla__ # parked: uncomment to offer plain claude too
```

- **One candidate** → resolves directly and launches (backward-compatible with existing single-line pins).
- **Many candidates** → scoped picker over just those candidates; the first is the default.
- **`__vanilla__` line** → offers "plain claude" as an explicit candidate in the scoped picker.
- **Absent / all-commented** → full picker on TTY, vanilla on non-TTY.

- a bundle name → run under that bundle;
- `__vanilla__` → run plain claude with no bundle, no picker;
- absent → picker on TTY, vanilla on non-TTY.
**File grammar:** lines are trimmed; blank lines and full-line `# …` comments are
skipped; inline trailing `name # …` comments are stripped (bundle names cannot
contain `#`). Duplicates are deduped (first occurrence wins).

`umbel apply <name>` writes a bundle pin. `umbel apply --vanilla` writes
the vanilla pin. `umbel unpin` removes the file. Commit it to share a
default with your team, or `.gitignore` it for per-developer setup.
**Default candidate:** the first listed candidate is pre-selected in the scoped
picker and resolved automatically in non-interactive shells.

**Visible behaviour shift:** a project with a multi-candidate pin opens an
(ephemeral) scoped picker on every launch instead of resolving directly. The
scoped picker never rewrites the pin — it resolves only the current launch.

**Hand-authored:** multi-candidate pins are written by hand. `umbel apply`
stays single-candidate and refuses (exit 2, hint to run `umbel unpin` first) to
overwrite an existing multi-candidate pin.

`umbel apply <name>` writes a single-candidate bundle pin. `umbel apply --vanilla`
writes the `__vanilla__` sentinel. `umbel unpin` removes the file. Commit it to
share a default with your team, or `.gitignore` it for per-developer setup.

See [`docs/adr/0007-multi-candidate-pins.md`](docs/adr/0007-multi-candidate-pins.md)
and [`CONTEXT.md`](CONTEXT.md) for rationale and terminology.

## Bundle resolution order for `run`

1. Explicit `<name>` arg
2. `UMBEL_BUNDLE` env var (set to `__vanilla__` to force vanilla)
3. `<project>/.umbel-bundle` pin file (name or `__vanilla__`)
4. On TTY → picker with `(vanilla)` row; on non-TTY → silent vanilla.
3. `<project>/.umbel-bundle` pin file:
- one candidate → run that bundle directly
- `__vanilla__` sentinel → run plain claude
- multiple candidates → scoped picker on TTY; default candidate on non-TTY
4. No pin (or all candidates commented out) → on TTY: full picker with `(vanilla)` row; on non-TTY: silent vanilla.

Arg and env bypass the picker entirely and are not constrained to the pin's candidate list.

## Skills picker (low-level, v0)

Expand Down
110 changes: 83 additions & 27 deletions docs/bundles-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,12 @@ Resolution order for `<name>`:

1. Explicit positional arg.
2. `UMBEL_BUNDLE` env var (literal `__vanilla__` resolves to vanilla; see below).
3. Pin file `<project>/.umbel-bundle`. Three states:
- Bundle name → run that bundle.
- `__vanilla__` sentinel → run plain claude, no flags, no picker.
- Absent / empty → unresolved (continue to step 4).
4. No source: on TTY, open the run picker with a `(vanilla)` row prepended
3. Pin file `<project>/.umbel-bundle` (ordered candidate list):
- One candidate → run that bundle directly (no picker).
- `__vanilla__` sentinel (as the single candidate) → run plain claude, no flags, no picker.
- Multiple candidates → on TTY, open the scoped picker (restricted to those candidates, default pre-selected); on non-TTY, resolve to the default candidate (first listed).
- Absent / all-commented → unresolved (continue to step 4).
4. No resolved candidate: on TTY, open the full picker with a `(vanilla)` row prepended
to the bundle list. On non-TTY, silently fall through to vanilla.

`--no-cache` forces a rebuild even if the hash matches.
Expand Down Expand Up @@ -372,48 +373,103 @@ The wrapper is the only umbel path that runs claude. There is no
<project>/.umbel-bundle
```

Plain text, one line. Three meaningful states:
Plain text, one **candidate** per line. Example:

| Content | Meaning |
|---------------|---------------------------------------------------------------|
| `<bundle>` | Run under this bundle. |
| `__vanilla__` | Run plain claude with no bundle, no picker. |
| (absent/empty)| No pin → picker on TTY, silent vanilla on non-TTY. |
```text
discovery # primary: the bundle I use most here
delivery # also relevant on this repo
# __vanilla__ # parked: uncomment to offer plain claude too
```

**File grammar:**

- Lines are trimmed; blank lines and full-line `# …` comments are skipped.
- Inline trailing `name # …` comments are stripped (bundle names cannot
contain `#`, so this is unambiguous).
- Duplicates are deduped; first occurrence wins.
- A pin whose candidates are all commented out (or the file is empty/absent)
is equivalent to no pin — never an error.

**Candidates and resolution:**

| Pin content | Meaning |
|----------------------------------|---------------------------------------------------------------------------------|
| One `<bundle>` line | Resolves directly; no picker. Backward-compatible with existing single-line pins. |
| `__vanilla__` (as single line) | Run plain claude with no bundle, no picker. |
| Multiple lines | Scoped picker on TTY (candidates only, default pre-selected); default candidate on non-TTY. |
| Absent / all-commented | No pin → full picker on TTY, silent vanilla on non-TTY. |

**Default candidate:** the first listed candidate. It is pre-selected in the
scoped picker and resolved automatically in non-interactive shells.

**`__vanilla__` as a candidate in a multi-line pin:** renders a `(vanilla)` row
in the scoped picker. There is no implicit vanilla row in the scoped picker —
it only appears if `__vanilla__` is listed.

**Candidates are not pre-built.** Each builds lazily on first pick or
non-interactive resolution (the existing `building bundle 'X'…` notice).

`umbel apply <name>` writes a bundle pin. `umbel apply --vanilla`
writes the `__vanilla__` sentinel. `umbel unpin` removes the file
entirely. The bundle-name regex (`^[a-z][a-z0-9-]{1,40}$`) rejects
underscores so the sentinel cannot collide with a real bundle.
`umbel apply <name>` writes a single-candidate bundle pin. `umbel apply --vanilla`
writes the `__vanilla__` sentinel. `umbel unpin` removes the file entirely.
`umbel apply` refuses (exit 2, hint to run `umbel unpin` first) to overwrite an
existing multi-candidate pin — multi-candidate pins are hand-authored. The
bundle-name regex (`^[a-z][a-z0-9-]{1,40}$`) rejects underscores so the
sentinel cannot collide with a real bundle.

VCS treatment: not auto-managed. README documents the recommendation —
**commit it** if the team wants a shared default; ignore it for per-developer
setups. umbel makes no edits to `.gitignore` and does not stage the file.

## Pickers

Pickers fire when a no-arg subcommand is invoked on a TTY. Behavior on
non-TTY varies by verb:
Pickers fire when a no-arg subcommand is invoked on a TTY. There are two
picker variants: the **full picker** and the **scoped picker**.

- `run` falls through to vanilla (silent, no prompt).
Behavior on non-TTY varies by verb:

- `run` falls through to vanilla (no pin) or the default candidate (multi-candidate pin) — silent, no prompt.
- `apply` / `show` / `build` error with a hint to pass `<name>` or pin.

### `run` / `apply` / `unpin` / `show` / `build`
### Full picker

Single-select picker. For `run` and `apply`, a `(vanilla)` row is
prepended to the bundle list. Row format:
Used by `run` when nothing resolves (no pin, no arg, no `UMBEL_BUNDLE` env),
and by `apply`, `show`, and `build` when invoked without an arg on a TTY. Shows every
discovered bundle. For `run` and `apply`, a `(vanilla)` row is prepended. Row
format:

```
(vanilla) Run claude with no bundle
data-science Tools for data science work [user] [pinned]
base Universal baseline [user]
ds-no-mcp DS without DuckDB MCP [project] [extends: data-science]
ds-no-mcp DS without DuckDB MCP [project] [shadowed]
```

Pinned bundle (or vanilla pin) is pre-selected. The picker is purely
ephemeral — selecting a bundle from `run` does **not** write a pin. To
persist a default, run `umbel apply` (which uses the same picker but
writes the pin on selection). `unpin` shows only the current pin (if
any) for confirmation; absent pin → no-op.
The current pin (or vanilla pin) is pre-selected. `show` and `build` use the
full picker but pre-select the default candidate when a pin is present.

### Scoped picker

Used by `run` on a TTY when the pin has **more than one candidate**. Restricted
to exactly the pin's candidates — no other bundles are shown. Row format mirrors
the full picker; the default candidate (first listed in the pin) is pre-selected.
A `(vanilla)` row appears only if `__vanilla__` is listed as a candidate in the
pin.

The scoped picker is purely **ephemeral** — selecting a candidate resolves the
launch only and does **not** rewrite the pin. To persist a default, run
`umbel apply` (which uses the full picker and writes a single-candidate pin on
selection, after confirming any existing multi-candidate pin is removed).

### `umbel list` and multi-candidate pins

`umbel list` marks every candidate in the PINNED column (`yes`),
distinguishing the default candidate (`yes*`) with a footnote.

### `unpin`

`unpin` removes the pin file immediately with no confirmation prompt. On success it
prints `unpinned`; when no pin file exists it prints `no pin to remove` and exits 0
(no-op).

## PATH shim

Expand Down
12 changes: 9 additions & 3 deletions src/bundle/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import {
userBundlesDir,
} from "./env.ts";
import type { BundleManifest } from "./manifest.ts";
import { readPin } from "./pin.ts";
import { type Candidate, readPin } from "./pin.ts";
import { type ResolvedSources, resolveSources } from "./resolve.ts";

const VANILLA_ENV_SENTINEL = "__vanilla__";

export type ResolveResult =
| { kind: "named"; name: string; via: "arg" | "env" | "pin" }
| { kind: "vanilla"; via: "env" | "pin" }
| { kind: "multiple"; candidates: Candidate[]; via: "pin" }
| { kind: "unresolved"; message: string };

export function resolveBundleName(
Expand All @@ -39,8 +40,13 @@ export function resolveBundleName(
}
const pin = readPin(cwd, home);
if (pin) {
if (pin.kind === "vanilla") return { kind: "vanilla", via: "pin" };
return { kind: "named", name: pin.name, via: "pin" };
if (pin.candidates.length === 1) {
const c = pin.candidates[0]!;
return c.kind === "vanilla"
? { kind: "vanilla", via: "pin" }
: { kind: "named", name: c.name, via: "pin" };
}
return { kind: "multiple", candidates: pin.candidates, via: "pin" };
}
return {
kind: "unresolved",
Expand Down
12 changes: 9 additions & 3 deletions src/bundle/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export interface ListScopeDirs {
}

export interface RenderListOpts {
pinnedName?: string;
pinnedNames?: string[];
defaultName?: string;
}

export function renderList(
Expand All @@ -17,7 +18,11 @@ export function renderList(
if (entries.length === 0) {
return "no bundles found\n";
}
return formatGroups(entries, dirs, opts);
let out = formatGroups(entries, dirs, opts);
if (opts.defaultName !== undefined && entries.some((e) => e.name === opts.defaultName)) {
out += "\n * default candidate (resolved when no bundle is chosen)\n";
}
return out;
}

function formatGroups(entries: BundleEntry[], dirs: ListScopeDirs, opts: RenderListOpts): string {
Expand Down Expand Up @@ -46,11 +51,12 @@ function formatGroups(entries: BundleEntry[], dirs: ListScopeDirs, opts: RenderL

function formatTable(rows: BundleEntry[], opts: RenderListOpts): string[] {
const headers = ["NAME", "DESCRIPTION", "EXTENDS", "PINNED"];
const pinned = new Set(opts.pinnedNames ?? []);
const data: string[][] = rows.map((r) => [
r.name,
r.manifest?.description ?? "—",
(r.manifest?.extends ?? []).join(", ") || "—",
opts.pinnedName !== undefined && opts.pinnedName === r.name ? "yes" : "—",
pinned.has(r.name) ? (r.name === opts.defaultName ? "yes*" : "yes") : "—",
]);
const widths = headers.map((h, i) => Math.max(h.length, ...data.map((row) => row[i]!.length)));
const fmt = (cells: string[]): string =>
Expand Down
46 changes: 39 additions & 7 deletions src/bundle/pin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ import { findClaudeAncestor } from "../target/walk.ts";
const PIN_FILE = ".umbel-bundle";
const VANILLA_SENTINEL = "__vanilla__";

export type Candidate = { kind: "bundle"; name: string } | { kind: "vanilla" };

export type ParsedPin = { kind: "absent" } | { kind: "candidates"; candidates: Candidate[] };

/**
* Parse `.umbel-bundle` text into an ordered candidate list. Pure (no I/O).
* Owns the whole grammar: one candidate per line; `#` starts a comment
* (safe — a bundle name can never contain `#`); blank lines skipped; lines
* trimmed; duplicates dropped preserving first occurrence. Zero candidates
* (empty or all-commented) is `absent`, behaving exactly like no pin.
*/
export function parsePin(raw: string): ParsedPin {
const seen = new Set<string>();
const candidates: Candidate[] = [];
for (const line of raw.split("\n")) {
const hash = line.indexOf("#");
const text = (hash === -1 ? line : line.slice(0, hash)).trim();
if (text.length === 0 || seen.has(text)) continue;
seen.add(text);
candidates.push(
text === VANILLA_SENTINEL ? { kind: "vanilla" } : { kind: "bundle", name: text },
);
}
return candidates.length === 0 ? { kind: "absent" } : { kind: "candidates", candidates };
}

/**
* Walk to the nearest `.claude/` ancestor. Pin lookups cross `.git`
* boundaries (unlike bundle/skills discovery) so a `.claude/` at a parent
Expand All @@ -18,10 +44,13 @@ export function pinPath(cwd: string, home: string): string {
return join(findProjectRoot(cwd, home) ?? cwd, PIN_FILE);
}

export type PinRead =
| { kind: "bundle"; name: string; path: string }
| { kind: "vanilla"; path: string };
export type PinRead = { candidates: Candidate[]; path: string };

/**
* Thin file-read wrapper over parsePin. Returns null when the file is missing
* or parses to zero candidates (absent ≡ no pin). On success, candidates has
* length >= 1, in file order.
*/
export function readPin(cwd: string, home: string): PinRead | null {
const path = pinPath(cwd, home);
let raw: string;
Expand All @@ -30,10 +59,13 @@ export function readPin(cwd: string, home: string): PinRead | null {
} catch {
return null;
}
const body = raw.trim();
if (body.length === 0) return null;
if (body === VANILLA_SENTINEL) return { kind: "vanilla", path };
return { kind: "bundle", name: body, path };
const parsed = parsePin(raw);
return parsed.kind === "absent" ? null : { candidates: parsed.candidates, path };
}

export function isMultiCandidatePin(cwd: string, home: string): boolean {
const pin = readPin(cwd, home);
return pin !== null && pin.candidates.length > 1;
}

export function writePin(cwd: string, home: string, name: string): string {
Expand Down
Loading
Loading