From 9d40bc7d46acbd31fc8d2b526e23506e7a56dde4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Tue, 9 Jun 2026 14:31:14 +0200 Subject: [PATCH 01/12] chore: gitignore .local for delivery-superpowers artifacts Finish adopting the delivery-superpowers method on this repo. Its delivery-superpowers-locations hook redirects superpowers' specs/plans to a gitignored .local/superpowers/ root (the vendored skill default would commit them under docs/superpowers/). Ignore .local so those artifacts never get committed; the SessionStart gate (`git check-ignore -q .local`) now passes. No trailing slash, so the check matches before the directory exists. Tier (.repo-visibility=public), beads, ADRs, and PR template are already in place from the discovery/delivery-base setup. Pinning delivery-superpowers alongside discovery awaits multi-candidate pins (#15); until then the local pin stays on discovery and delivery runs via UMBEL_BUNDLE=delivery-superpowers. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ebb10a4..48d4983 100644 --- a/.gitignore +++ b/.gitignore @@ -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, From 38d4b1e191e76f9d6bb19072397d697354383c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Tue, 9 Jun 2026 23:42:51 +0200 Subject: [PATCH 02/12] feat(pin): add pure parsePin candidate-list parser --- src/bundle/pin.ts | 26 +++++++++++ test/unit/bundle/pin.test.ts | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/bundle/pin.ts b/src/bundle/pin.ts index 6fd5722..307bf68 100644 --- a/src/bundle/pin.ts +++ b/src/bundle/pin.ts @@ -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(); + 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 diff --git a/test/unit/bundle/pin.test.ts b/test/unit/bundle/pin.test.ts index 5c28b3e..e3d04a8 100644 --- a/test/unit/bundle/pin.test.ts +++ b/test/unit/bundle/pin.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { findProjectRoot, + parsePin, readPin, removePin, writePin, @@ -91,3 +92,86 @@ describe("pin file", () => { } }); }); + +describe("parsePin", () => { + it("single name is one bundle candidate (byte-compatible with old pins)", () => { + expect(parsePin("data-science\n")).toEqual({ + kind: "candidates", + candidates: [{ kind: "bundle", name: "data-science" }], + }); + }); + + it("empty / whitespace-only file is absent", () => { + expect(parsePin("")).toEqual({ kind: "absent" }); + expect(parsePin(" \n\t\n")).toEqual({ kind: "absent" }); + }); + + it("multiple lines become an ordered candidate list", () => { + expect(parsePin("discovery\ndelivery\n")).toEqual({ + kind: "candidates", + candidates: [ + { kind: "bundle", name: "discovery" }, + { kind: "bundle", name: "delivery" }, + ], + }); + }); + + it("strips full-line and inline trailing # comments", () => { + expect(parsePin("# why these two\ndiscovery # primary\ndelivery # secondary\n")).toEqual({ + kind: "candidates", + candidates: [ + { kind: "bundle", name: "discovery" }, + { kind: "bundle", name: "delivery" }, + ], + }); + }); + + it("skips blank and whitespace-only lines, trims each candidate", () => { + expect(parsePin("\n discovery \n\n \n delivery\n")).toEqual({ + kind: "candidates", + candidates: [ + { kind: "bundle", name: "discovery" }, + { kind: "bundle", name: "delivery" }, + ], + }); + }); + + it("dedupes preserving first occurrence", () => { + expect(parsePin("a\nb\na\n")).toEqual({ + kind: "candidates", + candidates: [ + { kind: "bundle", name: "a" }, + { kind: "bundle", name: "b" }, + ], + }); + }); + + it("__vanilla__ is a candidate among bundles", () => { + expect(parsePin("discovery\n__vanilla__\ndelivery\n")).toEqual({ + kind: "candidates", + candidates: [ + { kind: "bundle", name: "discovery" }, + { kind: "vanilla" }, + { kind: "bundle", name: "delivery" }, + ], + }); + }); + + it("__vanilla__ as the sole candidate (direct vanilla)", () => { + expect(parsePin("__vanilla__\n")).toEqual({ + kind: "candidates", + candidates: [{ kind: "vanilla" }], + }); + }); + + it("all-commented-out parses to absent", () => { + expect(parsePin("# discovery\n# delivery\n")).toEqual({ kind: "absent" }); + }); + + it("keeps a source-qualified candidate name intact", () => { + expect(parsePin("myrepo/data-science\n")).toEqual({ + kind: "candidates", + candidates: [{ kind: "bundle", name: "myrepo/data-science" }], + }); + }); +}); From e04a2ac7cbebbc0df5149f88f94065a4875a3842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Tue, 9 Jun 2026 23:46:42 +0200 Subject: [PATCH 03/12] feat(pin): readPin returns candidate list; add isMultiCandidatePin --- src/bundle/pin.ts | 20 +++++++++----- test/unit/bundle/pin.test.ts | 51 ++++++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/bundle/pin.ts b/src/bundle/pin.ts index 307bf68..e8209fb 100644 --- a/src/bundle/pin.ts +++ b/src/bundle/pin.ts @@ -44,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; @@ -56,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 { diff --git a/test/unit/bundle/pin.test.ts b/test/unit/bundle/pin.test.ts index e3d04a8..75f7d15 100644 --- a/test/unit/bundle/pin.test.ts +++ b/test/unit/bundle/pin.test.ts @@ -1,8 +1,9 @@ -import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { findProjectRoot, + isMultiCandidatePin, parsePin, readPin, removePin, @@ -31,11 +32,21 @@ describe("pin file", () => { expect(readFileSync(path, "utf8")).toBe("data-science\n"); }); - it("reads back the pinned name", () => { + it("reads back a single bundle pin as one candidate", () => { writePin(project, home, "x"); expect(readPin(project, home)).toEqual({ - kind: "bundle", - name: "x", + candidates: [{ kind: "bundle", name: "x" }], + path: join(project, ".umbel-bundle"), + }); + }); + + it("reads a hand-authored multi-candidate pin in order", () => { + writeFileSync(join(project, ".umbel-bundle"), "discovery\ndelivery\n"); + expect(readPin(project, home)).toEqual({ + candidates: [ + { kind: "bundle", name: "discovery" }, + { kind: "bundle", name: "delivery" }, + ], path: join(project, ".umbel-bundle"), }); }); @@ -49,22 +60,34 @@ describe("pin file", () => { expect(readPin(project, home)).toBeNull(); }); + it("reads a vanilla pin as one vanilla candidate", () => { + const path = writeVanillaPin(project, home); + expect(readFileSync(path, "utf8")).toBe("__vanilla__\n"); + expect(readPin(project, home)).toEqual({ + candidates: [{ kind: "vanilla" }], + path, + }); + }); + + it("returns null for an all-commented-out pin (≡ absent)", () => { + writeFileSync(join(project, ".umbel-bundle"), "# discovery\n# delivery\n"); + expect(readPin(project, home)).toBeNull(); + }); + it("walks up from a subdirectory to the project root for read", () => { writePin(project, home, "x"); const sub = join(project, "src", "deep"); mkdirSync(sub, { recursive: true }); - const r = readPin(sub, home); - expect(r?.kind === "bundle" ? r.name : null).toBe("x"); + expect(readPin(sub, home)?.candidates).toEqual([{ kind: "bundle", name: "x" }]); }); - it("writeVanillaPin writes the sentinel and readPin returns vanilla", () => { - const path = writeVanillaPin(project, home); - expect(path).toBe(join(project, ".umbel-bundle")); - expect(readFileSync(path, "utf8")).toBe("__vanilla__\n"); - expect(readPin(project, home)).toEqual({ - kind: "vanilla", - path, - }); + it("isMultiCandidatePin is true only for >1 candidate", () => { + writePin(project, home, "solo"); + expect(isMultiCandidatePin(project, home)).toBe(false); + writeFileSync(join(project, ".umbel-bundle"), "a\nb\n"); + expect(isMultiCandidatePin(project, home)).toBe(true); + removePin(project, home); + expect(isMultiCandidatePin(project, home)).toBe(false); }); it("removePin removes the file and returns true", () => { From 139211638fe1baa98db3f64c5c79acedb9c2d840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Tue, 9 Jun 2026 23:50:12 +0200 Subject: [PATCH 04/12] feat(exec): resolveBundleName returns multiple for multi-candidate pins --- src/bundle/exec.ts | 12 +++++++++--- test/unit/bundle/exec.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/bundle/exec.ts b/src/bundle/exec.ts index 853f171..82abd08 100644 --- a/src/bundle/exec.ts +++ b/src/bundle/exec.ts @@ -14,7 +14,7 @@ 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__"; @@ -22,6 +22,7 @@ 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( @@ -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", diff --git a/test/unit/bundle/exec.test.ts b/test/unit/bundle/exec.test.ts index a7c3f29..d7cdfd4 100644 --- a/test/unit/bundle/exec.test.ts +++ b/test/unit/bundle/exec.test.ts @@ -181,4 +181,34 @@ describe("resolveBundleName", () => { via: "pin", }); }); + + it("multi-candidate pin resolves to multiple via pin, in order", () => { + writeFileSync(join(cwd, ".umbel-bundle"), "discovery\ndelivery\n"); + expect(resolveBundleName([], {}, cwd, home)).toEqual({ + kind: "multiple", + via: "pin", + candidates: [ + { kind: "bundle", name: "discovery" }, + { kind: "bundle", name: "delivery" }, + ], + }); + }); + + it("arg still overrides a multi-candidate pin (bypasses the picker)", () => { + writeFileSync(join(cwd, ".umbel-bundle"), "discovery\ndelivery\n"); + expect(resolveBundleName(["wanted"], {}, cwd, home)).toEqual({ + kind: "named", + name: "wanted", + via: "arg", + }); + }); + + it("UMBEL_BUNDLE still overrides a multi-candidate pin", () => { + writeFileSync(join(cwd, ".umbel-bundle"), "discovery\ndelivery\n"); + expect(resolveBundleName([], { UMBEL_BUNDLE: "fromEnv" }, cwd, home)).toEqual({ + kind: "named", + name: "fromEnv", + via: "env", + }); + }); }); From 3f5c7db1becff23b48469c572286270e1442b2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Tue, 9 Jun 2026 23:54:29 +0200 Subject: [PATCH 05/12] feat(ui): scoped picker option-builder for pinned candidates --- src/ui/bundle-picker.ts | 43 +++++++++++++++++++++++++++ test/unit/ui/bundle-picker.test.ts | 47 +++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/ui/bundle-picker.ts b/src/ui/bundle-picker.ts index 559a517..245bee4 100644 --- a/src/ui/bundle-picker.ts +++ b/src/ui/bundle-picker.ts @@ -1,5 +1,6 @@ import { select } from "@clack/prompts"; import type { BundleEntry } from "../bundle/discover.ts"; +import type { Candidate } from "../bundle/pin.ts"; import { PICKER_MAX_VISIBLE, assertSelected } from "./prompt.ts"; export const VANILLA_PICK = "__vanilla__"; @@ -48,3 +49,45 @@ export function formatBundleLabel(e: BundleEntry, pinned: boolean): string { if (e.shadowed) tags.push("[shadowed]"); return [e.name, desc, tags.join(" ")].filter((s) => s.length > 0).join(" "); } + +export interface ScopedPickerOptions { + options: { label: string; value: string }[]; + initialValue: string; +} + +/** + * Pure option-builder for the scoped picker: renders exactly the pinned + * candidates (no injected vanilla row), an explicit `__vanilla__` candidate as + * a `(vanilla)` row, and pre-selects the default (first) candidate. + */ +export function scopedPickerOptions( + candidates: Candidate[], + entries: BundleEntry[], +): ScopedPickerOptions { + const byName = new Map(entries.filter((e) => !e.malformed).map((e) => [e.name, e])); + const options = candidates.map((c) => { + if (c.kind === "vanilla") { + return { label: "(vanilla) Run claude with no bundle", value: VANILLA_PICK }; + } + const e = byName.get(c.name); + return { label: e ? formatBundleLabel(e, false) : c.name, value: c.name }; + }); + const first = candidates[0]!; + return { options, initialValue: first.kind === "vanilla" ? VANILLA_PICK : first.name }; +} + +export async function pickScopedBundle(opts: { + candidates: Candidate[]; + entries: BundleEntry[]; + message?: string; +}): Promise { + const { options, initialValue } = scopedPickerOptions(opts.candidates, opts.entries); + return assertSelected( + await select({ + message: opts.message ?? "Select bundle:", + options, + initialValue, + maxItems: Math.min(PICKER_MAX_VISIBLE, options.length), + }), + ); +} diff --git a/test/unit/ui/bundle-picker.test.ts b/test/unit/ui/bundle-picker.test.ts index ca7622b..bae6b84 100644 --- a/test/unit/ui/bundle-picker.test.ts +++ b/test/unit/ui/bundle-picker.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { BundleEntry } from "../../../src/bundle/discover.ts"; -import { formatBundleLabel } from "../../../src/ui/bundle-picker.ts"; +import type { Candidate } from "../../../src/bundle/pin.ts"; +import { + VANILLA_PICK, + formatBundleLabel, + scopedPickerOptions, +} from "../../../src/ui/bundle-picker.ts"; function entry( partial: Partial & { name: string; scope: BundleEntry["scope"] }, @@ -39,3 +44,43 @@ describe("formatBundleLabel", () => { expect(label).toContain("[shadowed]"); }); }); + +describe("scopedPickerOptions", () => { + it("renders exactly the candidate set in order, no injected vanilla row", () => { + const candidates: Candidate[] = [ + { kind: "bundle", name: "discovery" }, + { kind: "bundle", name: "delivery" }, + ]; + const entries = [ + entry({ + name: "discovery", + scope: "user", + manifest: { name: "discovery", sourcePath: "/x", body: "", description: "Find work" }, + }), + entry({ name: "delivery", scope: "user" }), + ]; + const { options, initialValue } = scopedPickerOptions(candidates, entries); + expect(options.map((o) => o.value)).toEqual(["discovery", "delivery"]); + expect(options[0]!.label).toContain("Find work"); + expect(options.some((o) => o.value === VANILLA_PICK)).toBe(false); + expect(initialValue).toBe("discovery"); + }); + + it("renders an explicit __vanilla__ candidate as a (vanilla) row", () => { + const candidates: Candidate[] = [{ kind: "bundle", name: "discovery" }, { kind: "vanilla" }]; + const { options } = scopedPickerOptions(candidates, []); + const vanillaRow = options.find((o) => o.value === VANILLA_PICK)!; + expect(vanillaRow.label).toContain("(vanilla)"); + }); + + it("pre-selects the default (first) candidate, even when vanilla", () => { + const candidates: Candidate[] = [{ kind: "vanilla" }, { kind: "bundle", name: "discovery" }]; + expect(scopedPickerOptions(candidates, []).initialValue).toBe(VANILLA_PICK); + }); + + it("falls back to the bare name when a candidate has no discovered entry", () => { + const candidates: Candidate[] = [{ kind: "bundle", name: "ghost" }]; + const { options } = scopedPickerOptions(candidates, []); + expect(options).toEqual([{ label: "ghost", value: "ghost" }]); + }); +}); From 876a9ca2318062d3a5226720591e0f629dd3ce69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Tue, 9 Jun 2026 23:59:37 +0200 Subject: [PATCH 06/12] feat(run): scoped picker on TTY, default candidate on non-TTY for multi-candidate pins --- src/run.ts | 38 +++++++++++++++++++++++++------- test/unit/run.bundle-run.test.ts | 30 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/run.ts b/src/run.ts index 8e4c29d..45cd851 100644 --- a/src/run.ts +++ b/src/run.ts @@ -24,7 +24,13 @@ import { resolveBundleName, } from "./bundle/exec.ts"; import { renderList } from "./bundle/list.ts"; -import { readPin, removePin, writePin, writeVanillaPin } from "./bundle/pin.ts"; +import { + isMultiCandidatePin, + readPin, + removePin, + writePin, + writeVanillaPin, +} from "./bundle/pin.ts"; import { renderShow } from "./bundle/show.ts"; import { detectCapabilities } from "./config.ts"; import { CliError, UsageError } from "./errors.ts"; @@ -37,7 +43,7 @@ import { probeAll } from "./state/probe.ts"; import { resolveInteractiveTargets, targetFromOverride } from "./target/resolve.ts"; import type { Capabilities, Options, Target } from "./types.ts"; import { runInitWizard } from "./ui/bundle-init.ts"; -import { VANILLA_PICK, pickBundle } from "./ui/bundle-picker.ts"; +import { VANILLA_PICK, pickBundle, pickScopedBundle } from "./ui/bundle-picker.ts"; import { askCustomPath, confirmApply } from "./ui/confirm.ts"; import { pickSkills } from "./ui/picker.ts"; import { promptTarget } from "./ui/target-prompt.ts"; @@ -200,12 +206,12 @@ async function pickBundleOrError( process.stderr.write(`umbel ${verb}: no bundles found\n`); return 3; } - const pin = readPin(cwd, homedir()); + const def = readPin(cwd, homedir())?.candidates[0]; const pickOpts: Parameters[0] = { entries: index.entries, message: `Select bundle (${verb}):`, }; - if (pin && pin.kind === "bundle") pickOpts.pinnedName = pin.name; + if (def?.kind === "bundle") pickOpts.pinnedName = def.name; const picked = await pickBundle(pickOpts); return picked ?? 2; } @@ -224,14 +230,14 @@ async function pickBundleOrVanilla( cwd: string, verb: "run" | "apply", ): Promise { - const pin = readPin(cwd, homedir()); + const def = readPin(cwd, homedir())?.candidates[0]; const pickOpts: Parameters[0] = { entries: index.entries, message: `Select bundle (${verb}):`, includeVanilla: true, }; - if (pin?.kind === "bundle") pickOpts.pinnedName = pin.name; - if (pin?.kind === "vanilla") pickOpts.pinnedVanilla = true; + if (def?.kind === "bundle") pickOpts.pinnedName = def.name; + if (def?.kind === "vanilla") pickOpts.pinnedVanilla = true; const picked = await pickBundle(pickOpts); if (picked === null) return { kind: "exit", code: 2 }; if (picked === VANILLA_PICK) return { kind: "vanilla" }; @@ -272,7 +278,23 @@ async function runBundleRun(rest: string[], env: NodeJS.ProcessEnv, cwd: string) let resolvedName: string; let pickedIndex: BundleIndex | undefined; - if (resolved.kind === "unresolved") { + if (resolved.kind === "multiple") { + if (!isInteractive(env)) { + const def = resolved.candidates[0]!; + if (def.kind === "vanilla") return execVanilla(claudeArgs, env); + resolvedName = def.name; + } else { + pickedIndex = loadBundleIndex(env, cwd); + const picked = await pickScopedBundle({ + candidates: resolved.candidates, + entries: pickedIndex.entries, + message: "Select bundle (run):", + }); + if (picked === null) return 2; + if (picked === VANILLA_PICK) return execVanilla(claudeArgs, env); + resolvedName = picked; + } + } else if (resolved.kind === "unresolved") { if (!isInteractive(env)) { return execVanilla(claudeArgs, env); } diff --git a/test/unit/run.bundle-run.test.ts b/test/unit/run.bundle-run.test.ts index ec3d850..d88ab3c 100644 --- a/test/unit/run.bundle-run.test.ts +++ b/test/unit/run.bundle-run.test.ts @@ -120,4 +120,34 @@ describe("run() bundle run", () => { ); expect(code).toBe(0); }); + + it("multi-candidate pin (non-TTY) resolves to the first/default candidate and builds it", async () => { + mkdirSync(join(cwd, ".claude"), { recursive: true }); + bundleFile("alpha"); + bundleFile("beta"); + writeFileSync(join(cwd, ".umbel-bundle"), "alpha\nbeta\n"); + const code = await run(["run"], envWith({ NO_TTY: "1", PATH: "/" }), cwd); + expect(code).toBe(1); // claude not on PATH=/ + expect(stderr.join("")).toMatch(/building bundle 'alpha'…/); + expect(stderr.join("")).not.toMatch(/building bundle 'beta'/); + }); + + it("candidate order is semantic — reordering changes the non-TTY default", async () => { + mkdirSync(join(cwd, ".claude"), { recursive: true }); + bundleFile("alpha"); + bundleFile("beta"); + writeFileSync(join(cwd, ".umbel-bundle"), "beta\nalpha\n"); + await run(["run"], envWith({ NO_TTY: "1", PATH: "/" }), cwd); + expect(stderr.join("")).toMatch(/building bundle 'beta'…/); + }); + + it("multi-candidate pin whose default is __vanilla__ (non-TTY) execs vanilla", async () => { + mkdirSync(join(cwd, ".claude"), { recursive: true }); + bundleFile("alpha"); + writeFileSync(join(cwd, ".umbel-bundle"), "__vanilla__\nalpha\n"); + const code = await run(["run"], envWith({ NO_TTY: "1", PATH: "/" }), cwd); + expect(code).toBe(1); + expect(stderr.join("")).toMatch(/'claude' not found on PATH/); + expect(stderr.join("")).not.toMatch(/building bundle/); + }); }); From e8056c54b149aea1e4210341aa01e66f0fee76b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Wed, 10 Jun 2026 00:04:00 +0200 Subject: [PATCH 07/12] feat(apply): refuse to overwrite a multi-candidate pin, hint unpin --- src/run.ts | 6 +++++ test/unit/run.bundle-apply.test.ts | 36 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/run.ts b/src/run.ts index 45cd851..1a00e9f 100644 --- a/src/run.ts +++ b/src/run.ts @@ -333,6 +333,12 @@ async function runBundleApply( env: NodeJS.ProcessEnv, cwd: string, ): Promise { + if (isMultiCandidatePin(cwd, homedir())) { + process.stderr.write( + "umbel apply: refusing to overwrite a multi-candidate pin; run 'umbel unpin' first\n", + ); + return 2; + } const wantsVanilla = rest.includes("--vanilla"); const positional = rest.filter((a) => !a.startsWith("--")); diff --git a/test/unit/run.bundle-apply.test.ts b/test/unit/run.bundle-apply.test.ts index 08693b5..3aeb570 100644 --- a/test/unit/run.bundle-apply.test.ts +++ b/test/unit/run.bundle-apply.test.ts @@ -78,4 +78,40 @@ describe("run() bundle apply", () => { expect(code).toBe(2); expect(stderr.join("")).toMatch(/name required/); }); + + function multiPin(): void { + writeFileSync(join(cwd, ".umbel-bundle"), "discovery\ndelivery\n"); + } + + it("apply refuses to overwrite a multi-candidate pin (exit 2, hints unpin)", async () => { + bundleFile("demo"); + multiPin(); + const code = await run(["apply", "demo"], envWith({ NO_TTY: "1" }), cwd); + expect(code).toBe(2); + expect(stderr.join("")).toMatch(/unpin/); + expect(readFileSync(join(cwd, ".umbel-bundle"), "utf8")).toBe("discovery\ndelivery\n"); + }); + + it("apply --vanilla refuses to overwrite a multi-candidate pin (exit 2)", async () => { + multiPin(); + const code = await run(["apply", "--vanilla"], envWith({ NO_TTY: "1" }), cwd); + expect(code).toBe(2); + expect(stderr.join("")).toMatch(/unpin/); + expect(readFileSync(join(cwd, ".umbel-bundle"), "utf8")).toBe("discovery\ndelivery\n"); + }); + + it("apply (no name, non-TTY) over a multi-candidate pin refuses with the guard, not the name-required error", async () => { + multiPin(); + const code = await run(["apply"], envWith({ NO_TTY: "1" }), cwd); + expect(code).toBe(2); + expect(stderr.join("")).toMatch(/unpin/); + }); + + it("apply still overwrites a single-candidate pin", async () => { + bundleFile("demo"); + writeFileSync(join(cwd, ".umbel-bundle"), "old\n"); + const code = await run(["apply", "demo"], envWith({ NO_TTY: "1" }), cwd); + expect(code).toBe(0); + expect(readFileSync(join(cwd, ".umbel-bundle"), "utf8")).toBe("demo\n"); + }); }); From d15283143e8002a6c4eb50adcadb7da455c1bce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Wed, 10 Jun 2026 00:08:51 +0200 Subject: [PATCH 08/12] fix(list): mark all pinned candidates, distinguish default (closes #5) --- src/bundle/list.ts | 12 +++++++++--- src/run.ts | 12 ++++++++++-- test/unit/bundle/list.test.ts | 24 ++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/bundle/list.ts b/src/bundle/list.ts index b91956e..49d1672 100644 --- a/src/bundle/list.ts +++ b/src/bundle/list.ts @@ -6,7 +6,8 @@ export interface ListScopeDirs { } export interface RenderListOpts { - pinnedName?: string; + pinnedNames?: string[]; + defaultName?: string; } export function renderList( @@ -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 { @@ -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 => diff --git a/src/run.ts b/src/run.ts index 1a00e9f..e8e4016 100644 --- a/src/run.ts +++ b/src/run.ts @@ -23,7 +23,7 @@ import { resolveBundle, resolveBundleName, } from "./bundle/exec.ts"; -import { renderList } from "./bundle/list.ts"; +import { type RenderListOpts, renderList } from "./bundle/list.ts"; import { isMultiCandidatePin, readPin, @@ -398,8 +398,16 @@ function runBundleUnpin(cwd: string): number { function runBundleList(env: NodeJS.ProcessEnv, cwd: string): number { const index = loadBundleIndex(env, cwd); + const pin = readPin(cwd, homedir()); + const opts: RenderListOpts = {}; + if (pin) { + opts.pinnedNames = pin.candidates.flatMap((c) => (c.kind === "bundle" ? [c.name] : [])); + if (pin.candidates.length > 1 && pin.candidates[0]!.kind === "bundle") { + opts.defaultName = pin.candidates[0]!.name; + } + } process.stdout.write( - renderList(index.entries, { userDir: index.userDir, projectDir: index.projectDir }), + renderList(index.entries, { userDir: index.userDir, projectDir: index.projectDir }, opts), ); return 0; } diff --git a/test/unit/bundle/list.test.ts b/test/unit/bundle/list.test.ts index f430b81..cb580b2 100644 --- a/test/unit/bundle/list.test.ts +++ b/test/unit/bundle/list.test.ts @@ -62,16 +62,36 @@ describe("renderList", () => { expect(out).toContain("base, lang-py"); }); - it("marks pinned bundle in PINNED column", () => { + it("marks a single pinned bundle with yes (no footnote)", () => { const entries = [ entry({ name: "data-science", scope: "project" }), entry({ name: "base", scope: "user" }), ]; - const out = renderList(entries, SCOPE_DIRS, { pinnedName: "data-science" }); + const out = renderList(entries, SCOPE_DIRS, { pinnedNames: ["data-science"] }); const dsLine = out.split("\n").find((l) => l.includes("data-science"))!; const baseLine = out.split("\n").find((l) => l.includes("base "))!; expect(dsLine).toMatch(/yes/); expect(baseLine).not.toMatch(/yes/); + expect(out).not.toContain("default candidate"); + }); + + it("marks every candidate, distinguishing the default with yes* + a footnote", () => { + const entries = [ + entry({ name: "discovery", scope: "project" }), + entry({ name: "delivery", scope: "project" }), + entry({ name: "other", scope: "user" }), + ]; + const out = renderList(entries, SCOPE_DIRS, { + pinnedNames: ["discovery", "delivery"], + defaultName: "discovery", + }); + const discoveryLine = out.split("\n").find((l) => l.includes("discovery"))!; + const deliveryLine = out.split("\n").find((l) => l.includes("delivery"))!; + const otherLine = out.split("\n").find((l) => l.includes("other"))!; + expect(discoveryLine).toMatch(/yes\*/); + expect(deliveryLine).toMatch(/yes(?!\*)/); + expect(otherLine).not.toMatch(/yes/); + expect(out).toMatch(/\* default candidate/); }); it("omits a scope group entirely when it has no rows", () => { From f0cb8cec93a00ede85b923959ac227f4cb054ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Wed, 10 Jun 2026 00:14:48 +0200 Subject: [PATCH 09/12] docs: describe multi-candidate pin list format and scoped picker --- README.md | 72 ++++++++++++++++++++++------ docs/bundles-spec.md | 109 ++++++++++++++++++++++++++++++++----------- 2 files changed, 139 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 916f9b4..dd64e80 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,18 @@ 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 single-select picker: + +- **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 `` 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. + +`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) @@ -204,32 +213,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 -`/.umbel-bundle` is plain text, one line: +`/.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 (byte-identical to old 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 ` 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 ` 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 `` arg 2. `UMBEL_BUNDLE` env var (set to `__vanilla__` to force vanilla) -3. `/.umbel-bundle` pin file (name or `__vanilla__`) -4. On TTY → picker with `(vanilla)` row; on non-TTY → silent vanilla. +3. `/.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) diff --git a/docs/bundles-spec.md b/docs/bundles-spec.md index 796590c..b501f7d 100644 --- a/docs/bundles-spec.md +++ b/docs/bundles-spec.md @@ -336,11 +336,12 @@ Resolution order for ``: 1. Explicit positional arg. 2. `UMBEL_BUNDLE` env var (literal `__vanilla__` resolves to vanilla; see below). -3. Pin file `/.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 `/.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. @@ -372,18 +373,48 @@ The wrapper is the only umbel path that runs claude. There is no /.umbel-bundle ``` -Plain text, one line. Three meaningful states: +Plain text, one **candidate** per line. Example: -| Content | Meaning | -|---------------|---------------------------------------------------------------| -| `` | 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 `` line | Resolves directly; no picker. Byte-identical to the old single-line format. | +| `__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 ` 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 ` 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 @@ -391,29 +422,53 @@ 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 `` 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` (no pin or single-line pin where the name is already resolved), +`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] + data-science Tools for data science work [user] [pinned*] base Universal baseline [user] ds-no-mcp DS without DuckDB MCP [project] [extends: data-science] ``` -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 multi-candidate 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` shows only the current pin (if any) for confirmation; absent pin → no-op. ## PATH shim From b26179d094facfbc23f8acc7fc6421133be1ddab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Wed, 10 Jun 2026 00:20:31 +0200 Subject: [PATCH 10/12] docs: correct picker/unpin behaviour in bundles-spec --- docs/bundles-spec.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/bundles-spec.md b/docs/bundles-spec.md index b501f7d..39381c2 100644 --- a/docs/bundles-spec.md +++ b/docs/bundles-spec.md @@ -432,21 +432,20 @@ Behavior on non-TTY varies by verb: ### Full picker -Used by `run` (no pin or single-line pin where the name is already resolved), -`apply`, `show`, and `build` when invoked without an arg on a TTY. Shows every +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*] + 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] ``` The current pin (or vanilla pin) is pre-selected. `show` and `build` use the -full picker but pre-select the default candidate when a multi-candidate pin is -present. +full picker but pre-select the default candidate when a pin is present. ### Scoped picker @@ -468,7 +467,9 @@ distinguishing the default candidate (`yes*`) with a footnote. ### `unpin` -`unpin` shows only the current pin (if any) for confirmation; absent pin → no-op. +`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 From 45f73c29a4f6f61cff0f0967bf67bbb49fd9b74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Wed, 10 Jun 2026 00:25:55 +0200 Subject: [PATCH 11/12] docs: clarify picker variants and soften pin back-compat wording --- README.md | 7 ++++--- docs/bundles-spec.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dd64e80..3308beb 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ 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: +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 @@ -195,6 +195,7 @@ open a single-select picker: - **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 @@ -234,7 +235,7 @@ delivery # also relevant on this repo # __vanilla__ # parked: uncomment to offer plain claude too ``` -- **One candidate** → resolves directly and launches (byte-identical to old single-line pins). +- **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. @@ -269,7 +270,7 @@ and [`CONTEXT.md`](CONTEXT.md) for rationale and terminology. - 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. +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. diff --git a/docs/bundles-spec.md b/docs/bundles-spec.md index 39381c2..a8e2d72 100644 --- a/docs/bundles-spec.md +++ b/docs/bundles-spec.md @@ -394,7 +394,7 @@ delivery # also relevant on this repo | Pin content | Meaning | |----------------------------------|---------------------------------------------------------------------------------| -| One `` line | Resolves directly; no picker. Byte-identical to the old single-line format. | +| One `` 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. | From 9b0cb945991b8887d3323af9f04a2bd0e75314a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Wed, 10 Jun 2026 00:32:24 +0200 Subject: [PATCH 12/12] refactor(run): drop unreachable scoped-pick null guard; cover unknown-default exit 3 --- src/run.ts | 1 - src/ui/bundle-picker.ts | 2 +- test/unit/run.bundle-run.test.ts | 9 +++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/run.ts b/src/run.ts index e8e4016..8381923 100644 --- a/src/run.ts +++ b/src/run.ts @@ -290,7 +290,6 @@ async function runBundleRun(rest: string[], env: NodeJS.ProcessEnv, cwd: string) entries: pickedIndex.entries, message: "Select bundle (run):", }); - if (picked === null) return 2; if (picked === VANILLA_PICK) return execVanilla(claudeArgs, env); resolvedName = picked; } diff --git a/src/ui/bundle-picker.ts b/src/ui/bundle-picker.ts index 245bee4..7aff8fa 100644 --- a/src/ui/bundle-picker.ts +++ b/src/ui/bundle-picker.ts @@ -80,7 +80,7 @@ export async function pickScopedBundle(opts: { candidates: Candidate[]; entries: BundleEntry[]; message?: string; -}): Promise { +}): Promise { const { options, initialValue } = scopedPickerOptions(opts.candidates, opts.entries); return assertSelected( await select({ diff --git a/test/unit/run.bundle-run.test.ts b/test/unit/run.bundle-run.test.ts index d88ab3c..ca519e8 100644 --- a/test/unit/run.bundle-run.test.ts +++ b/test/unit/run.bundle-run.test.ts @@ -141,6 +141,15 @@ describe("run() bundle run", () => { expect(stderr.join("")).toMatch(/building bundle 'beta'…/); }); + it("multi-candidate pin (non-TTY) whose default is an unknown bundle exits 3", async () => { + mkdirSync(join(cwd, ".claude"), { recursive: true }); + bundleFile("alpha"); + writeFileSync(join(cwd, ".umbel-bundle"), "ghost\nalpha\n"); + const code = await run(["run"], envWith({ NO_TTY: "1", PATH: "/" }), cwd); + expect(code).toBe(3); + expect(stderr.join("")).toMatch(/ghost.*not found/); + }); + it("multi-candidate pin whose default is __vanilla__ (non-TTY) execs vanilla", async () => { mkdirSync(join(cwd, ".claude"), { recursive: true }); bundleFile("alpha");