From 7bb16befb08b840b77c8b09bd350bf42195522eb Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Mon, 25 May 2026 14:00:18 -0700 Subject: [PATCH 1/3] feat(cli): theming + per-command help expansion + migration guides + lazy AI imports * Add `dub config theme auto|dark|light|none` with COLORFGBG-based auto-detection and a global --no-color flag that overrides the configured theme. The resolved theme controls chalk's color level at program startup. * Expand every command's --help with Examples + See also sections, enforced by src/index.help.test.ts which walks the program tree and asserts both sections via outputHelp(). Required gating the auto-running main() so vitest can import the program for introspection. * Publish four new migration guides (charcoal, ghstack, sapling, spr) and expand the existing graphite guide with a 30-minute scripted migration. * Defer @ai-sdk/* + @aws-sdk/credential-providers + ai package imports to a central lib/ai-deps.ts loader that dynamic-imports the SDKs only when an AI codepath actually runs. lazy-cold-start.test.ts asserts the entrypoint import never triggers SDK loading. Completes DUB-69 --- apps/docs/content/docs/guides/meta.json | 6 +- .../docs/guides/migration-from-charcoal.mdx | 97 +++ .../docs/guides/migration-from-ghstack.mdx | 97 +++ .../docs/guides/migration-from-graphite.mdx | 131 ++- .../docs/guides/migration-from-sapling.mdx | 88 ++ .../docs/guides/migration-from-spr.mdx | 92 ++ packages/cli/src/commands/absorb.ts | 36 +- packages/cli/src/commands/ai-resolve.ts | 61 +- packages/cli/src/commands/ai.ts | 57 +- packages/cli/src/commands/config.ts | 55 ++ packages/cli/src/commands/create.ts | 25 +- packages/cli/src/commands/flow.ts | 61 +- packages/cli/src/commands/mcp.ts | 29 +- packages/cli/src/commands/ready.ts | 32 +- packages/cli/src/commands/split.ts | 23 +- packages/cli/src/commands/squash.ts | 33 +- packages/cli/src/commands/submit.ts | 23 +- packages/cli/src/index.help.test.ts | 63 ++ packages/cli/src/index.ts | 783 +++++++++++++++++- packages/cli/src/lazy-cold-start.test.ts | 14 + packages/cli/src/lib/ai-deps.test.ts | 35 + packages/cli/src/lib/ai-deps.ts | 88 ++ .../cli/src/lib/ai-prompt-decision.test.ts | 1 + packages/cli/src/lib/ai-prompt-decision.ts | 33 +- packages/cli/src/lib/config.test.ts | 18 + packages/cli/src/lib/config.ts | 16 + packages/cli/src/lib/theme.test.ts | 65 ++ packages/cli/src/lib/theme.ts | 64 ++ 28 files changed, 1803 insertions(+), 323 deletions(-) create mode 100644 apps/docs/content/docs/guides/migration-from-charcoal.mdx create mode 100644 apps/docs/content/docs/guides/migration-from-ghstack.mdx create mode 100644 apps/docs/content/docs/guides/migration-from-sapling.mdx create mode 100644 apps/docs/content/docs/guides/migration-from-spr.mdx create mode 100644 packages/cli/src/index.help.test.ts create mode 100644 packages/cli/src/lazy-cold-start.test.ts create mode 100644 packages/cli/src/lib/ai-deps.test.ts create mode 100644 packages/cli/src/lib/ai-deps.ts create mode 100644 packages/cli/src/lib/theme.test.ts create mode 100644 packages/cli/src/lib/theme.ts diff --git a/apps/docs/content/docs/guides/meta.json b/apps/docs/content/docs/guides/meta.json index ef0b4711..7dd98f08 100644 --- a/apps/docs/content/docs/guides/meta.json +++ b/apps/docs/content/docs/guides/meta.json @@ -10,6 +10,10 @@ "json-output", "multi-trunk", "github-action-retarget", - "migration-from-graphite" + "migration-from-graphite", + "migration-from-charcoal", + "migration-from-ghstack", + "migration-from-sapling", + "migration-from-spr" ] } diff --git a/apps/docs/content/docs/guides/migration-from-charcoal.mdx b/apps/docs/content/docs/guides/migration-from-charcoal.mdx new file mode 100644 index 00000000..b1cfd6ec --- /dev/null +++ b/apps/docs/content/docs/guides/migration-from-charcoal.mdx @@ -0,0 +1,97 @@ +--- +title: Migrating from Charcoal +description: Map Charcoal (`ch`) commands to DubStack equivalents and migrate an active stack in under 30 minutes. +--- + +Charcoal is the open-source descendant of Graphite that ships under the `ch` binary. If you have `ch` muscle memory, this guide is the fast path to DubStack. Charcoal shares Graphite's command surface, so most patterns map one-to-one. + +## Command Mapping + +| Charcoal | DubStack | Notes | +|---|---|---| +| `ch create` / `ch c` | `dub create` | Same flags: `-a`, `-m`, `--ai` | +| `ch modify` / `ch m` | `dub modify` / `dub m` | Amend HEAD and restack descendants | +| `ch submit` / `ch s` | `dub submit` / `dub ss` | Defaults to downstack; pass `--stack` for the full tree | +| `ch sync` | `dub sync` | `--all` covers every tracked stack | +| `ch checkout` / `ch co` | `dub checkout` / `dub co` | Interactive picker with no argument | +| `ch log` / `ch ls` | `dub log` / `dub ls` | `--stack`, `--all`, `--json` | +| `ch up` / `ch down` | `dub up` / `dub down` | Identical | +| `ch top` / `ch bottom` | `dub top` / `dub bottom` | Identical | +| `ch info` | `dub info` | `--json` available | +| `ch pr` | `dub pr` | Opens GitHub PR for the current branch | +| `ch restack` | `dub restack` | Run after `git rebase` to fix up children | +| `ch continue` | `dub continue` | `--ai` to resolve conflicts via LLM | +| `ch abort` | `dub abort` | Restack/rebase rollback | +| `ch track` / `ch trunk` | `dub track` / `dub trunk` | Multi-trunk supported | +| `ch untrack` | `dub untrack` | Keep the branch, drop metadata | +| `ch delete` | `dub delete` | `--upstack`/`--downstack`/`--force` | +| `ch absorb` | `dub absorb` | `--ai` for ambiguous fixups | +| `ch fold` | `dub fold` | `--squash` for single-commit collapse | +| `ch undo` | `dub undo` | 20-entry ring buffer + `dub redo` | + +## Conceptual Differences + +- **Local-first by design.** DubStack stores everything in `.git/dubstack/`. No external service, no telemetry, no shared cache file. +- **State mirrored into git refs.** `refs/dubstack/*` lets you rebuild state on a fresh clone with `dub init --restore-from-refs`. Charcoal relies on `.charcoal_cache` and is sensitive to manual file deletion. +- **Optional AI throughout.** `dub create --ai`, `dub submit --ai`, `dub flow`, `dub absorb --ai`. Configured per-repo with `dub config ai-provider` and friends; none of it is required to use the stacked-diff workflow. +- **Safe merge order.** `dub merge-next` (alias `dub land`) refuses to merge a PR whose ancestors are open and retargets every child PR atomically. +- **Multi-trunk support.** Register additional trunks with `dub trunk add` and `dub trunk set-default`. +- **GitHub Action for retargeting.** `dub install retarget-action` drops a workflow that retargets dependents when a stack PR merges through the GitHub UI. + +## Common Pitfalls + +- **`ch submit` defaulted to the full stack; `dub submit` defaults to downstack.** Pass `--stack` explicitly in CI scripts. +- **Charcoal's `.charcoal_user_config` is not migrated.** Re-run `dub config ai-provider`, `dub config reviewers`, and `dub config submit-default`. +- **`ch downstack edit` has no direct equivalent.** Use `dub reorder` for interactive reordering or `dub move --before/--after` for one-shot moves. +- **`ch sync --pull` is implicit.** `dub sync` fetches the trunk by default; pass `--fresh` to force a full refetch. + +## 30-Minute Migration Script + +```bash +# 1. Install DubStack +npm install -g dubstack + +# 2. Initialize alongside Charcoal — non-destructive +cd path/to/repo +dub init + +# 3. Adopt every Charcoal branch. DubStack walks ancestry to infer parents. +for branch in $(git for-each-ref --format='%(refname:short)' refs/heads/); do + if [ "$branch" != "main" ]; then + git switch "$branch" + dub track --no-interactive || true + fi +done + +# 4. Verify the tree matches what Charcoal showed +dub log --all + +# 5. (Optional) restore AI configuration +dub config ai-assistant on +dub config ai-provider anthropic +dub config ai-model --provider anthropic claude-sonnet-4-7 + +# 6. (Optional) reviewers +dub config reviewers alice,@org/backend + +# 7. Restack to confirm the chain is intact +dub restack + +# 8. Refresh PR descriptions with DubStack metadata +dub submit --downstack --no-ai + +# 9. Once stable, remove Charcoal and its caches +npm uninstall -g @stackedgit/charcoal +rm -rf .charcoal_cache .charcoal_repo_config .charcoal_user_config + +# 10. Shim the muscle memory: +# alias ch=dub +``` + +If `dub log --all` differs from `ch log`, run `dub doctor` to diagnose. State is recoverable via `dub init --restore-from-refs`. + +## See Also + +- [Stacking Workflow](/docs/guides/stacking-workflow) +- [Conflict Resolution](/docs/guides/conflict-resolution) +- [GitHub Action: retarget](/docs/guides/github-action-retarget) diff --git a/apps/docs/content/docs/guides/migration-from-ghstack.mdx b/apps/docs/content/docs/guides/migration-from-ghstack.mdx new file mode 100644 index 00000000..f12c4541 --- /dev/null +++ b/apps/docs/content/docs/guides/migration-from-ghstack.mdx @@ -0,0 +1,97 @@ +--- +title: Migrating from ghstack +description: Move from Meta's ghstack workflow (`ghstack`) to DubStack without re-creating any branches. +--- + +[ghstack](https://github.com/ezyang/ghstack) implements stacked diffs on top of GitHub by treating one local commit per PR and synthesising orphan `gh///{base,head,orig}` branches on push. DubStack takes the opposite approach: every PR has its own branch in your local checkout, mirroring how you work in `git switch`. + +This guide explains the model shift and walks through migrating an in-flight ghstack series in roughly 30 minutes. + +## Command Mapping + +| ghstack | DubStack | Notes | +|---|---|---| +| `ghstack` | `dub submit --stack` | Push everything in the stack and open/refresh PRs | +| `ghstack land $URL` | `dub merge-next` / `dub land` | Land the next safe PR; retargets dependents | +| `ghstack unlink` | `dub unlink ` | Detach a branch from its parent | +| `ghstack rage` | `dub doctor` | Diagnostic for misconfigured state | +| `ghstack checkout $URL` | `dub co ` | Charles will name the branch; pick it from the picker | +| `ghstack hash` (n/a) | `dub info` | Stack-aware branch metadata | +| Implicit rebase on push | `dub restack` | Explicit, undo-able restack | +| Implicit PR description prefix | `dub submit --ai` or templated body | DubStack does not auto-prefix; configure via templates | + +(ghstack has a narrower surface than Graphite/Charcoal; many DubStack commands have no ghstack analog because ghstack does not model stacked branches locally.) + +## Conceptual Differences + +- **One commit per PR vs. one branch per PR.** ghstack rewrites your local history so each commit is mapped to its own pseudo-branch on the remote. DubStack keeps a real branch per PR in your working copy. The DubStack model is friendlier to bisect, blame, and IDE workflows because each PR is an ordinary git branch you can check out. +- **No commit re-authoring.** DubStack does not amend or rewrite commits when you submit. ghstack rewrites the `gh-metadata` trailer on every push. +- **PR base is a real branch.** DubStack PRs target the previous branch in your stack directly. ghstack PRs target a synthesised `gh/$user/$n/base` orphan branch, which can confuse code review tooling. +- **Land semantics.** `ghstack land` squash-merges the bottom commit into `main`. `dub merge-next` (alias `dub land`) merges the next mergeable PR and retargets every dependent before deleting the branch — including in the GitHub merge queue when configured. +- **Multi-author safe.** Because each PR is a real branch, collaborators can push to your stack without your local `gh-metadata` getting in the way. +- **No remote write on read.** DubStack never pushes anything during `dub log`, `dub status`, or `dub info`. ghstack's `checkout` walks the remote to assemble the working copy. + +## Common Pitfalls + +- **Squash vs. merge.** `ghstack land` always squash-merges. DubStack's default is configurable per repo with `dub config submit-default` and per merge with `dub merge-next --method squash|merge|rebase`. +- **PR commit messages.** ghstack uses the commit subject as the PR title and the body as the PR description. DubStack uses the same convention for new PRs but does **not** rewrite the PR title on subsequent submits. Edit titles in GitHub or pass `--ai` for an LLM-suggested rewrite. +- **The `gh-metadata: Pull-Request resolved:` trailer.** DubStack does not add or rely on this trailer. You can leave it on existing commits or strip it during the migration with `git rebase -i`. +- **Submodules and worktrees.** DubStack supports git worktrees and stores per-worktree state under `.git/worktrees//dubstack/`. ghstack does not. + +## 30-Minute Migration Script + +The cleanest path: convert each in-flight ghstack PR into a real local branch, then track it. + +```bash +# 1. Install DubStack +npm install -g dubstack + +# 2. Initialize. Safe alongside ghstack — DubStack writes only under .git/dubstack/ +cd path/to/repo +dub init + +# 3. For each open ghstack PR you own, materialise a local branch. +# ghstack PR titles include "[ghstack-poisoned]" markers — these refer to +# your `orig` commits. Use the GitHub UI or `gh pr list --author @me` to +# enumerate; for each one, create a local branch off the PR's head SHA: +# +# gh pr checkout +# git switch -c feat/ # rename to something human +# +# Or, for the entire stack at once, walk from oldest to newest and chain: +git switch main +git switch -c feat/auth-base # bottom of the old ghstack +git cherry-pick # the orig commit ghstack tracked +git switch -c feat/auth-login # next one up +git cherry-pick +# ... and so on. + +# 4. Track every new branch in DubStack +git switch feat/auth-base +dub track --parent main +git switch feat/auth-login +dub track --parent feat/auth-base +# etc. + +# 5. Sanity-check the chain +dub log --stack + +# 6. Open new DubStack PRs and close the old ghstack PRs by hand. New PRs use +# real branch bases instead of the synthesised gh///base branches. +dub submit --stack + +# 7. Once the team has approved + merged the new PRs, prune ghstack: +pip uninstall ghstack +git for-each-ref --format='%(refname)' refs/heads/gh/ \ + | xargs -I {} git update-ref -d {} +git push origin --delete $(git for-each-ref \ + --format='%(refname:lstrip=3)' refs/heads/gh/) +``` + +If your team uses ghstack as a merge tool only (and not for daily local stacking), the simpler answer is: keep ghstack for landing, use DubStack for local stack manipulation, and switch fully once the last ghstack PR lands. + +## See Also + +- [Stacking Workflow](/docs/guides/stacking-workflow) +- [Conflict Resolution](/docs/guides/conflict-resolution) +- [JSON Output](/docs/guides/json-output) diff --git a/apps/docs/content/docs/guides/migration-from-graphite.mdx b/apps/docs/content/docs/guides/migration-from-graphite.mdx index 93024c6b..1ecf457e 100644 --- a/apps/docs/content/docs/guides/migration-from-graphite.mdx +++ b/apps/docs/content/docs/guides/migration-from-graphite.mdx @@ -1,38 +1,105 @@ --- -title: Graphite Migration -description: Map Graphite commands to DubStack equivalents. +title: Migrating from Graphite +description: Map Graphite (`gt`) commands to DubStack equivalents and migrate an in-flight stack in under 30 minutes. --- -If you have `gt` muscle memory, use this as a fast map. DubStack follows the same mental model. +If you have `gt` muscle memory, this guide is the fast path to DubStack. The mental model is the same: branches stacked on branches, one PR per branch, automatic restack on rebase. ## Command Mapping -| Graphite | DubStack | -|---|---| -| `gt create` | `dub create` | -| `gt modify` | `dub modify` / `dub m` | -| `gt submit` / `gt ss` | `dub submit` / `dub ss` | -| `gt sync` | `dub sync` | -| `gt checkout` / `gt co` | `dub checkout` / `dub co` | -| `gt log` / `gt ls` | `dub log` / `dub ls` | -| `gt up` / `gt down` | `dub up` / `dub down` | -| `gt top` / `gt bottom` | `dub top` / `dub bottom` | -| `gt info` | `dub info` | -| `gt pr` | `dub pr` | -| `gt restack` | `dub restack` | -| `gt continue` | `dub continue` | -| `gt abort` | `dub abort` | -| `gt track --parent` | `dub track --parent` | -| `gt untrack` | `dub untrack` | -| `gt delete` | `dub delete` | -| `gt parent` | `dub parent` | -| `gt children` | `dub children` | -| `gt trunk` | `dub trunk` | -| `gt undo` | `dub undo` | - -## Key Differences - -- **Local-first** — DubStack stores all state locally in `.git/dubstack/`. Nothing is pushed to your remote from these files. -- **AI features** — DubStack includes built-in AI for branch naming, commit messages, PR descriptions, `dub flow`, and stack-aware guidance via `dub ai ask`. -- **Agent skills** — DubStack ships packaged skills for coding assistants via `dub skills add`. -- **Safe merging** — `dub merge-next` handles retargeting child PRs before deleting merged branches. +| Graphite | DubStack | Notes | +|---|---|---| +| `gt create` | `dub create` | Same flags: `-a`, `-m`, `--ai` | +| `gt modify` / `gt m` | `dub modify` / `dub m` | Amend current branch and restack descendants | +| `gt submit` / `gt ss` | `dub submit` / `dub ss` | `--stack`, `--upstack`, `--downstack`, `--branch` | +| `gt sync` | `dub sync` | `--all` syncs every tracked stack | +| `gt checkout` / `gt co` | `dub checkout` / `dub co` | Interactive picker with no argument | +| `gt log` / `gt ls` | `dub log` / `dub ls` | `--stack`, `--all`, `--json` | +| `gt up` / `gt down` | `dub up` / `dub down` | Identical | +| `gt top` / `gt bottom` | `dub top` / `dub bottom` | Identical | +| `gt info` | `dub info` | `--json` machine-readable | +| `gt pr` | `dub pr` | Opens the PR for the current branch | +| `gt restack` | `dub restack` | Automatic when a parent changes | +| `gt continue` | `dub continue` | Add `--ai` to resolve conflicts via LLM | +| `gt abort` | `dub abort` | Rolls back restack/rebase | +| `gt track --parent` | `dub track --parent` | Adopt an untracked branch | +| `gt untrack` | `dub untrack` | Keep the branch, drop DubStack metadata | +| `gt delete` | `dub delete` | `--upstack`, `--downstack`, `--force` | +| `gt parent` / `gt children` | `dub parent` / `dub children` | `--json` outputs the relation | +| `gt trunk` | `dub trunk` / `dub trunk add` / `dub trunk set-default` | Multi-trunk supported | +| `gt undo` | `dub undo` | 20-entry ring buffer, supports `dub redo` | +| `gt absorb` | `dub absorb` | `--ai` for ambiguous fixups | +| `gt fold` | `dub fold` | Defaults to keeping commits; `--squash` to collapse | +| `gt move --before/--after` | `dub move --before/--after` | Identical semantics | +| `gt split` | `dub split` | Interactive or AI-assisted commit split | + +## Conceptual Differences + +- **Local-first.** All state lives in `.git/dubstack/` and never leaves your machine. There is no DubStack account, no remote server, and no synchronization daemon. The downside is that team coordination happens through GitHub PRs alone. +- **Stack metadata in git refs.** DubStack mirrors stack state into `refs/dubstack/*` so a wiped checkout can rebuild from `dub init --restore-from-refs`. Graphite stores the equivalent in `.graphite_cache_persist`. +- **First-class AI.** `dub create --ai`, `dub submit --ai`, `dub flow`, and `dub absorb --ai` all use a provider you configure with `dub config ai-provider`. Set keys with `dub config ai-model --provider anthropic `. +- **Safer merges.** `dub merge-next` (alias `dub land`) refuses to merge a PR whose ancestors are not yet merged, and retargets every dependent PR atomically before deleting the local branch. +- **Multi-trunk.** A single repository can have several trunks (e.g. `main` plus `release/24.x`). Configure with `dub trunk add` and `dub trunk set-default`. +- **MCP server.** `dub mcp` exposes the local stack to AI coding agents via the Model Context Protocol, gated by `dub config mcp-mode`. + +## Common Pitfalls + +- **`gt submit --stack` defaulted to the whole tree; `dub submit` defaults to downstack.** Pass `--stack` explicitly when migrating CI scripts that assumed Graphite's old default. +- **`gt sync --no-restack` does not exist in DubStack.** Use `dub sync` and then `dub restack --continue` if you wanted to defer restack. +- **`.graphite_user_config` is not read.** Re-configure with `dub config ai-provider`, `dub config reviewers`, and friends. +- **`gt downstack edit` has no direct equivalent.** Use `dub reorder` for interactive reordering, or `dub move --before/--after` for single-branch moves. + +## 30-Minute Migration Script + +The following walkthrough migrates an active Graphite stack in place without re-creating any branches or losing in-flight PRs. + +```bash +# 1. Install DubStack (assumes pnpm/npm available) +npm install -g dubstack + +# 2. From the repo root, initialize DubStack alongside Graphite +cd path/to/repo +dub init + +# 3. Adopt every Graphite-tracked branch into DubStack. DubStack walks the +# git ancestry to infer parent links — this is non-destructive. +git switch main +for branch in $(git for-each-ref --format='%(refname:short)' refs/heads/); do + if [ "$branch" != "main" ]; then + git switch "$branch" + dub track --no-interactive || true + fi +done + +# 4. Verify the stack matches what Graphite showed +dub log --all + +# 5. Optional: enable AI features. Pick one provider. +dub config ai-assistant on +dub config ai-provider anthropic +dub config ai-model --provider anthropic claude-sonnet-4-7 + +# 6. Optional: pre-fetch your default reviewers so `dub submit` requests them +dub config reviewers alice,@org/backend + +# 7. Confirm restack works on the active stack +dub restack + +# 8. Resync PR descriptions with the new metadata layout +dub submit --downstack --no-ai + +# 9. (When you are confident) remove Graphite +npm uninstall -g @withgraphite/graphite-cli +rm -rf .graphite_cache_persist .graphite_repo_config .graphite_user_config + +# 10. Add an alias so muscle memory survives: +# alias gt=dub # zsh/bash +``` + +If anything looks off after step 4, run `dub doctor` for a diagnostic report. The state is recoverable via `dub init --restore-from-refs`. + +## See Also + +- [Stacking Workflow](/docs/guides/stacking-workflow) +- [Conflict Resolution](/docs/guides/conflict-resolution) +- [Multi-trunk stacks](/docs/guides/multi-trunk) diff --git a/apps/docs/content/docs/guides/migration-from-sapling.mdx b/apps/docs/content/docs/guides/migration-from-sapling.mdx new file mode 100644 index 00000000..7392985c --- /dev/null +++ b/apps/docs/content/docs/guides/migration-from-sapling.mdx @@ -0,0 +1,88 @@ +--- +title: Migrating from Sapling +description: Map Sapling (`sl`) commands to DubStack and migrate a Sapling stack onto plain git in 30 minutes. +--- + +[Sapling](https://sapling-scm.com/) is Meta's open-source Mercurial-flavored git client. Its `sl` binary speaks to git repositories but presents an entirely different surface (commits as the unit, not branches). DubStack works on top of plain git with one branch per PR — closer to what GitHub expects. The migration is therefore largely about converting Sapling's anonymous-commit stacks into named branches. + +## Command Mapping + +| Sapling | DubStack | Notes | +|---|---|---| +| `sl pr submit` | `dub submit --stack` | DubStack defaults to downstack; pass `--stack` for full stacks | +| `sl ssl` / `sl smartlog` | `dub log` / `dub log --stack` | ASCII tree of branches; `--json` available | +| `sl next` / `sl prev` | `dub up` / `dub down` | Identical | +| `sl goto ` | `dub co ` | Sapling's bookmarks ≈ DubStack's branches | +| `sl rebase --restack` | `dub restack` | Restack everything that depends on a moved parent | +| `sl absorb` | `dub absorb` | `--ai` for ambiguous fixups | +| `sl fold` | `dub fold` | Default keeps commits; `--squash` collapses | +| `sl split` | `dub split` | Interactive commit split | +| `sl undo` | `dub undo` | Multi-step undo + `dub redo` | +| `sl pull --rebase` | `dub sync` | Fetch trunks, restack tracked stacks | +| `sl status` | `dub status` | DubStack also surfaces stack context | +| `sl uncommit` | `git reset --soft HEAD^` | DubStack does not own this primitive; use git | +| `sl debugdiff` / `sl pr` | `dub pr` | Opens GitHub PR for current branch | +| `sl land` / `sl pr land` | `dub merge-next` / `dub land` | Refuses to land out-of-order PRs | + +## Conceptual Differences + +- **Branches replace bookmarks.** Sapling's bookmarks are lightweight pointers to commits and can be detached. DubStack uses real git branches, one per PR. The migration step below names every Sapling commit you care about as a branch. +- **Restack is explicit and undoable.** Sapling's `sl rebase` auto-restacks descendants. DubStack restacks via `dub restack`, which saves an undo entry. Run `dub undo` to roll back if you misjudge a parent move. +- **Smartlog → `dub log`.** DubStack's tree is branch-rooted; it shows PR state and CI status inline (`dub log --no-prs` / `dub log --no-ci` to hide). +- **No `.sl` metadata.** DubStack keeps everything under `.git/dubstack/`. After migration, deleting `.sl` is safe. +- **Conflict resolution.** Sapling's `sl continue` advances the rebase. DubStack offers `dub continue` and `dub continue --ai`, where AI proposes resolutions for two-way merge conflicts. +- **Merge queue & auto-merge.** `dub submit --merge-when-ready --method squash` integrates with GitHub auto-merge. Sapling does not expose this directly. + +## Common Pitfalls + +- **Commits without branches won't survive the migration.** Walk the smartlog and create a branch at every Sapling commit you have an open PR for, then run `dub track --parent`. +- **`sl pr submit` keeps a stable PR identifier.** DubStack opens new PRs when it sees new branches. Plan to close the Sapling-managed PRs after the migration; do not try to retarget them onto the new branches. +- **Anonymous heads.** If you have anonymous heads in Sapling (no bookmark), give them names with `sl bookmark ` before exporting. +- **Sapling's "preserved" commits across rebase.** DubStack respects git's standard `git rebase` semantics; in-flight rebases are picked up by `dub continue`. + +## 30-Minute Migration Script + +```bash +# 1. Install DubStack +npm install -g dubstack + +# 2. Map every Sapling commit you care about to a git branch. +# Inside the Sapling-managed working copy, list every commit with an active PR: +sl ssl --template '{node|short} {desc|firstline}\n' | head -50 + +# 3. For each open PR, create a real git branch at that commit. +# Sapling already writes git refs under .sl/store, so the SHAs are valid. +git switch -c feat/auth-base +git switch -c feat/auth-login +# ... continue up the stack + +# 4. Switch to DubStack and adopt the new branches +dub init +git switch feat/auth-base +dub track --parent main +git switch feat/auth-login +dub track --parent feat/auth-base +# ... + +# 5. Verify the tree +dub log --stack + +# 6. Re-submit the stack so each branch has a real GitHub PR +dub submit --stack --ai + +# 7. After review + merge of the new PRs, close the corresponding Sapling PRs. +# Then delete .sl and uninstall Sapling. +rm -rf .sl +# Follow Sapling's uninstall instructions for your OS. + +# 8. (Optional) Wire up muscle memory: +# alias sl=dub +``` + +If your team relies on Sapling's interactive UI (`sl web`), keep it for review until everyone is on DubStack. DubStack does not ship a web UI; it relies on GitHub's PR view. + +## See Also + +- [Stacking Workflow](/docs/guides/stacking-workflow) +- [Conflict Resolution](/docs/guides/conflict-resolution) +- [Multi-trunk stacks](/docs/guides/multi-trunk) diff --git a/apps/docs/content/docs/guides/migration-from-spr.mdx b/apps/docs/content/docs/guides/migration-from-spr.mdx new file mode 100644 index 00000000..d2977ee7 --- /dev/null +++ b/apps/docs/content/docs/guides/migration-from-spr.mdx @@ -0,0 +1,92 @@ +--- +title: Migrating from spr +description: Move from spr (Stacked Pull Requests) to DubStack and recreate your active stack in 30 minutes. +--- + +[spr](https://github.com/getcord/spr) implements stacked pull requests by mapping each git commit to a GitHub PR. Like ghstack, it rewrites commit messages and uses synthesised remote branches. DubStack uses a one-branch-per-PR model that survives normal `git switch` workflows. + +## Command Mapping + +| spr | DubStack | Notes | +|---|---|---| +| `spr diff` | `dub submit --stack` | Push the stack and open/refresh PRs | +| `spr update` | `dub submit` | Refresh PR contents for the current downstack | +| `spr land` | `dub merge-next` / `dub land` | Refuses out-of-order landings | +| `spr amend` | `dub modify` | Amend HEAD and restack descendants | +| `spr list` | `dub log --stack` | Visual stack tree | +| `spr status` | `dub status` | Branch + PR state | +| `spr patch ` | `gh pr checkout ` then `dub track` | DubStack does not own this primitive | +| `spr close ` | `gh pr close ` | DubStack delegates PR close to `gh` | +| Pre-commit hook rewriting | n/a | DubStack uses real branches; no commit-msg trailer needed | + +## Conceptual Differences + +- **One commit per PR vs. one branch per PR.** spr maintains the invariant "1 commit ↔ 1 PR" by injecting a `pr-XXXX` identifier into each commit message and pushing synthesised branches. DubStack maintains "1 branch ↔ 1 PR" and never rewrites your commits. +- **No commit-msg rewriting.** spr's `commit-msg` hook appends `pr-` to every commit. DubStack never rewrites commits; the PR ↔ branch link lives in `.git/dubstack/state.json` and `refs/dubstack/*`. +- **Restack is explicit.** `spr update` rewrites every commit in the stack on every invocation. `dub restack` only rebases descendants of branches whose parents have moved, and saves an undo entry. +- **Merge queue support.** `dub submit --merge-when-ready --method squash` queues GitHub auto-merge for every PR in scope. spr leaves merging to the GitHub UI or `spr land`. +- **AI features.** `dub create --ai`, `dub submit --ai`, `dub flow`, `dub absorb --ai`. spr does not include LLM features; configure DubStack's provider with `dub config ai-provider`. +- **Multi-trunk.** DubStack supports multiple long-lived trunks (`dub trunk add`) within one repo. spr assumes a single main branch. + +## Common Pitfalls + +- **`pr-XXXX` trailers stay in your history.** spr writes those identifiers as commit-message trailers. DubStack does not strip them; you can leave them alone or rewrite history with `git filter-repo --message-callback`. Either way DubStack ignores them. +- **spr depends on a clean working tree.** DubStack tolerates dirty working trees for read-only commands but refuses to mutate state when a rebase is in progress; use `dub doctor` to diagnose. +- **PR identifiers are different.** spr-managed PRs include the trailer; DubStack-managed PRs do not. If you keep both during the transition, do not let `spr update` retarget DubStack PRs — it will rewrite their commit messages. +- **Reviewers.** `spr config.reviewers` is not migrated. Run `dub config reviewers alice,@org/team`. + +## 30-Minute Migration Script + +```bash +# 1. Install DubStack +npm install -g dubstack + +# 2. Inside the repo, initialize DubStack +cd path/to/repo +dub init + +# 3. For each open spr PR you own, create a real git branch at that commit. +# spr stores the relationship as a commit-message trailer; list them: +git log main..HEAD --format='%H %s' | grep -i 'pr-' | tac + +# 4. Walking from oldest to newest, create + name a branch at each SHA: +git switch main +git switch -c feat/auth-base +git switch -c feat/auth-login +git switch -c feat/auth-mfa + +# 5. Adopt each branch into DubStack +git switch feat/auth-base +dub track --parent main +git switch feat/auth-login +dub track --parent feat/auth-base +git switch feat/auth-mfa +dub track --parent feat/auth-login + +# 6. Verify the stack +dub log --stack + +# 7. (Optional) AI + reviewers +dub config ai-assistant on +dub config ai-provider anthropic +dub config reviewers alice,@org/backend + +# 8. Submit the new stack +dub submit --stack --ai + +# 9. After the new PRs are merged, close the matching spr-managed PRs. +# Then uninstall spr: +brew uninstall spr || cargo uninstall spr + +# 10. Remove spr's pre-commit hook +git config --unset commit.template || true +rm -f .git/hooks/commit-msg # if it was an spr hook only +``` + +If your team uses spr only as a land tool, you can hold off on closing the spr PRs and use `dub` for local stack manipulation until the last spr PR merges. + +## See Also + +- [Stacking Workflow](/docs/guides/stacking-workflow) +- [Conflict Resolution](/docs/guides/conflict-resolution) +- [JSON Output](/docs/guides/json-output) diff --git a/packages/cli/src/commands/absorb.ts b/packages/cli/src/commands/absorb.ts index c8c4838d..391a1278 100644 --- a/packages/cli/src/commands/absorb.ts +++ b/packages/cli/src/commands/absorb.ts @@ -1,14 +1,18 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; -import { createGateway, generateText } from 'ai'; +import type { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import type { createAnthropic } from '@ai-sdk/anthropic'; +import type { createGoogleGenerativeAI } from '@ai-sdk/google'; +import type { createOpenAI } from '@ai-sdk/openai'; +import type { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import type { + fromIni, + fromNodeProviderChain, +} from '@aws-sdk/credential-providers'; +import type { createGateway, generateText } from 'ai'; import { execa } from 'execa'; +import { loadAiDeps } from '../lib/ai-deps'; import { resolveAiProvider } from '../lib/ai-provider'; import { readConfig } from '../lib/config'; import { DubError } from '../lib/errors'; @@ -57,18 +61,9 @@ export interface AbsorbDependencies { readConfig: typeof readConfig; } -const DEFAULT_DEPS: AbsorbDependencies = { - generateText, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, - readConfig, -}; +async function defaultDeps(): Promise { + return { ...(await loadAiDeps()), readConfig }; +} interface CommitInfo { sha: string; @@ -118,8 +113,9 @@ const FIXUP_PREFIX_RE = /^(fixup|squash|amend)!\s+(.+)$/; export async function absorb( cwd: string, options: AbsorbOptions = {}, - deps: AbsorbDependencies = DEFAULT_DEPS, + depsArg?: AbsorbDependencies, ): Promise { + const deps = depsArg ?? (await defaultDeps()); if (options.ai && options.stack) { throw new DubError("'--ai' cannot be combined with '--stack'.", [ "Run 'dub absorb --ai' to resolve ambiguous WIP commits on the current branch.", diff --git a/packages/cli/src/commands/ai-resolve.ts b/packages/cli/src/commands/ai-resolve.ts index bcace973..d9522acd 100644 --- a/packages/cli/src/commands/ai-resolve.ts +++ b/packages/cli/src/commands/ai-resolve.ts @@ -1,13 +1,17 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; -import { createGateway, streamText } from 'ai'; +import type { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import type { createAnthropic } from '@ai-sdk/anthropic'; +import type { createGoogleGenerativeAI } from '@ai-sdk/google'; +import type { createOpenAI } from '@ai-sdk/openai'; +import type { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import type { + fromIni, + fromNodeProviderChain, +} from '@aws-sdk/credential-providers'; +import type { createGateway, streamText } from 'ai'; import chalk from 'chalk'; +import { loadAiDeps } from '../lib/ai-deps'; import { buildAiProviderOptions, type ResolvedAiProvider, @@ -62,29 +66,23 @@ export interface AiResolveDeps { runNearbyTestsForFile: typeof runNearbyTestsForFile; } -const DEFAULT_DEPS: AiResolveDeps = { - streamText, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, - readConfig, - gatherConflictContext, - renderBatchPreview, - promptBatchAction, - promptFileAction, - applyResolution, - showScopeWarning, - validateResolutionPaths, - continueCommand, - abortCommand, - promptAdjudicationChoice, - runNearbyTestsForFile, -}; +async function buildDefaultDeps(): Promise { + return { + ...(await loadAiDeps()), + readConfig, + gatherConflictContext, + renderBatchPreview, + promptBatchAction, + promptFileAction, + applyResolution, + showScopeWarning, + validateResolutionPaths, + continueCommand, + abortCommand, + promptAdjudicationChoice, + runNearbyTestsForFile, + }; +} interface AiResolveOptions { dryRun?: boolean; @@ -95,8 +93,9 @@ interface AiResolveOptions { export async function aiResolve( cwd: string, options: AiResolveOptions, - deps: AiResolveDeps = DEFAULT_DEPS, + depsArg?: AiResolveDeps, ): Promise { + const deps = depsArg ?? (await buildDefaultDeps()); const sigintHandler = () => { console.log( '\nCancelled. Conflict state preserved — resolve manually or re-run `dub ai resolve`.', diff --git a/packages/cli/src/commands/ai.ts b/packages/cli/src/commands/ai.ts index dacf729a..4ff5fd14 100644 --- a/packages/cli/src/commands/ai.ts +++ b/packages/cli/src/commands/ai.ts @@ -1,17 +1,21 @@ -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; -import { createGateway, stepCountIs, streamText } from 'ai'; -import { createBashTool } from 'bash-tool'; +import type { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import type { createAnthropic } from '@ai-sdk/anthropic'; +import type { createGoogleGenerativeAI } from '@ai-sdk/google'; +import type { createOpenAI } from '@ai-sdk/openai'; +import type { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import type { + fromIni, + fromNodeProviderChain, +} from '@aws-sdk/credential-providers'; +import type { createGateway, stepCountIs, streamText } from 'ai'; +import type { createBashTool } from 'bash-tool'; import { createLocalBashSandbox } from '../lib/ai-bash-sandbox'; import { buildAiSystemPrompt, buildAiUserPrompt, collectAiContext, } from '../lib/ai-context'; +import { loadAiDeps } from '../lib/ai-deps'; import { buildAiProviderOptions, resolveAiProvider } from '../lib/ai-provider'; import { readConfig } from '../lib/config'; import { DubError } from '../lib/errors'; @@ -24,6 +28,7 @@ interface WritableLike { interface AskAiDependencies { streamText: typeof streamText; + stepCountIs?: typeof stepCountIs; createBashTool: typeof createBashTool; createGoogleGenerativeAI: typeof createGoogleGenerativeAI; createAnthropic?: typeof createAnthropic; @@ -54,19 +59,24 @@ interface AskAiResult { webBrowsingUsed: boolean; } -const DEFAULT_DEPS: AskAiDependencies = { - streamText, - createBashTool, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, - collectAiContext, -}; +async function defaultDeps(): Promise { + const ai = await loadAiDeps(); + const { createBashTool } = await import('bash-tool'); + return { + streamText: ai.streamText, + stepCountIs: ai.stepCountIs, + createBashTool, + createGoogleGenerativeAI: ai.createGoogleGenerativeAI, + createAnthropic: ai.createAnthropic, + createGateway: ai.createGateway, + createAmazonBedrock: ai.createAmazonBedrock, + createOpenAI: ai.createOpenAI, + createOpenAICompatible: ai.createOpenAICompatible, + fromIni: ai.fromIni, + fromNodeProviderChain: ai.fromNodeProviderChain, + collectAiContext, + }; +} export async function askAi( prompt: string, @@ -88,7 +98,8 @@ export async function askAi( } const output = options.output ?? process.stdout; - const deps = options.deps ?? DEFAULT_DEPS; + const deps = options.deps ?? (await defaultDeps()); + const stepCountIsFn = deps.stepCountIs ?? (await loadAiDeps()).stepCountIs; const resolved = resolveAiProvider({ deps, providerConfig: config.ai.provider, @@ -109,7 +120,7 @@ export async function askAi( model: resolved.model, system: buildAiSystemPrompt(), prompt: contextPrompt, - stopWhen: stepCountIs(6), + stopWhen: stepCountIsFn(6), tools: { bash: bashToolkit.tools.bash, }, diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 819a77c1..8fb8dffb 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -4,6 +4,7 @@ import type { McpMode, StorageBackend, SubmitDefault, + ThemeMode, } from '../lib/config'; import { readConfig, writeConfig } from '../lib/config'; import { DubError } from '../lib/errors'; @@ -46,6 +47,11 @@ export interface ConfigSubmitDefaultResult { changed: boolean; } +export interface ConfigThemeResult { + theme: ThemeMode; + changed: boolean; +} + export interface ConfigAiPromptsResult { mode: 'auto' | 'on' | 'off'; changed: boolean; @@ -389,6 +395,36 @@ export async function configStorageBackend( }; } +export async function configTheme( + cwd: string, + theme?: string, +): Promise { + const config = await readConfig(cwd); + if (theme == null) { + return { + theme: config.theme, + changed: false, + }; + } + + const parsed = parseTheme(theme); + const changed = config.theme !== parsed; + if (changed) { + await writeConfig( + { + ...config, + theme: parsed, + }, + cwd, + ); + } + + return { + theme: parsed, + changed, + }; +} + export async function configSubmitDefault( cwd: string, mode?: string, @@ -481,6 +517,25 @@ function parseStorageBackend(value: string): StorageBackend { ]); } +function parseTheme(value: string): ThemeMode { + if ( + value === 'auto' || + value === 'dark' || + value === 'light' || + value === 'none' + ) { + return value; + } + throw new DubError( + "Theme must be one of 'auto', 'dark', 'light', or 'none'.", + [ + "Pass 'auto' to detect the terminal background (default).", + "Pass 'dark' or 'light' to force a palette.", + "Pass 'none' to disable colors entirely.", + ], + ); +} + function parseAiAssistantState(value: string): boolean { if (value === 'on') return true; if (value === 'off') return false; diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 1ac81ca3..b199129d 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,10 +1,4 @@ -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; -import { createGateway, generateText } from 'ai'; +import { loadAiDeps } from '../lib/ai-deps'; import { buildAiDiffContext } from '../lib/ai-diff-context'; import { type AiMetadataDependencies, @@ -59,18 +53,6 @@ interface CreateResult { type CreateDependencies = AiMetadataDependencies; -const DEFAULT_DEPS: CreateDependencies = { - generateText, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, -}; - /** * Creates a new branch stacked on top of the current branch. * @@ -88,7 +70,7 @@ export async function create( name: string | undefined, cwd: string, options?: CreateOptions, - deps: CreateDependencies = DEFAULT_DEPS, + deps?: CreateDependencies, ): Promise { const normalizedOptions = options ?? {}; @@ -203,13 +185,14 @@ export async function create( getDiffNumStat(cwd, true), ]); const templates = await readMetadataTemplates(cwd); + const resolvedDeps = deps ?? (await loadAiDeps()); const generated = await generateCreateMetadata( buildAiDiffContext({ rawDiff: stagedDiff, filePaths: stagedFiles, diffStats: stagedDiffStats, }), - deps, + resolvedDeps, { commitTemplate: templates.commitTemplate, }, diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts index 36329a6d..6f26c79e 100644 --- a/packages/cli/src/commands/flow.ts +++ b/packages/cli/src/commands/flow.ts @@ -1,14 +1,8 @@ import * as fs from 'node:fs'; import { stdin as input, stdout as output } from 'node:process'; import * as readline from 'node:readline/promises'; -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; -import { createGateway, generateText } from 'ai'; import { execa } from 'execa'; +import { loadAiDeps } from '../lib/ai-deps'; import { buildAiDiffContext } from '../lib/ai-diff-context'; import { type AiMetadataDependencies, @@ -88,34 +82,29 @@ interface FlowDependencies extends AiMetadataDependencies { ) => Promise<{ commitMessage: string; prDescription: string }>; } -const DEFAULT_DEPS: FlowDependencies = { - generateText, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, - generateFlowMetadata, - readMetadataTemplates, - readConfig, - getCurrentBranch, - hasStagedChanges, - stageAll, - stageUpdate, - interactiveStage, - getDiff, - getDiffFileNames, - getDiffNumStat, - create, - submit, - commitStagedFromFile, - createTerminalRenderer, - promptApproval: promptApprovalChoice, - editGeneratedContent: editGeneratedContent, -}; +async function buildDefaultDeps(): Promise { + const ai = await loadAiDeps(); + return { + ...ai, + generateFlowMetadata, + readMetadataTemplates, + readConfig, + getCurrentBranch, + hasStagedChanges, + stageAll, + stageUpdate, + interactiveStage, + getDiff, + getDiffFileNames, + getDiffNumStat, + create, + submit, + commitStagedFromFile, + createTerminalRenderer, + promptApproval: promptApprovalChoice, + editGeneratedContent: editGeneratedContent, + }; +} export async function flow( cwd: string, @@ -123,7 +112,7 @@ export async function flow( deps: Partial = {}, ): Promise { const resolvedDeps: FlowDependencies = { - ...DEFAULT_DEPS, + ...(await buildDefaultDeps()), ...deps, }; diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index d76bc9b0..f64f7eef 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -2,13 +2,7 @@ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as readline from 'node:readline/promises'; import type { Readable, Writable } from 'node:stream'; -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; -import { createGateway, generateText } from 'ai'; +import { loadAiDeps } from '../lib/ai-deps'; import { buildAiDiffContext } from '../lib/ai-diff-context'; import { type AiMetadataDependencies, @@ -108,17 +102,13 @@ const PROTOCOL_VERSION = '2025-11-25'; const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2025-11-25', '2025-06-18']); const MAX_HISTORY_ARGS_LENGTH = 500; -const DEFAULT_AI_METADATA_DEPS: AiMetadataDependencies = { - generateText, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, -}; +let cachedAiMetadataDeps: AiMetadataDependencies | null = null; +async function defaultAiMetadataDeps(): Promise { + if (!cachedAiMetadataDeps) { + cachedAiMetadataDeps = await loadAiDeps(); + } + return cachedAiMetadataDeps; +} const HISTORY_ARG_KEYS: Record = { 'dubstack.log': ['stack', 'all', 'reverse', 'prs', 'ci', 'refresh'], @@ -999,7 +989,8 @@ export async function mcp( const output = options.output ?? process.stdout; const serverVersion = options.version ?? '0.0.0'; const confirmMutating = options.confirmMutating ?? confirmMutatingTool; - const aiMetadataDeps = options.aiMetadataDeps ?? DEFAULT_AI_METADATA_DEPS; + const aiMetadataDeps = + options.aiMetadataDeps ?? (await defaultAiMetadataDeps()); let buffer = ''; let queue: Promise = Promise.resolve(); diff --git a/packages/cli/src/commands/ready.ts b/packages/cli/src/commands/ready.ts index d6fc1939..6982b0a4 100644 --- a/packages/cli/src/commands/ready.ts +++ b/packages/cli/src/commands/ready.ts @@ -1,10 +1,4 @@ -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; -import { createGateway, generateText } from 'ai'; +import { loadAiDeps } from '../lib/ai-deps'; import { type AiReadinessDependencies, type AiReadinessIssue, @@ -46,23 +40,19 @@ export interface ReadyOptions { aiSkipReview?: boolean; } -const DEFAULT_AI_DEPS: AiReadinessDependencies = { - generateText, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, -}; - export async function ready( cwd: string, options: ReadyOptions = {}, - aiDeps: AiReadinessDependencies = DEFAULT_AI_DEPS, + aiDepsArg?: AiReadinessDependencies, ): Promise { + // Only resolve AI deps when the AI review path is actually exercised. + let aiDepsResolved: AiReadinessDependencies | null = aiDepsArg ?? null; + const aiDeps = async (): Promise => { + if (!aiDepsResolved) { + aiDepsResolved = await loadAiDeps(); + } + return aiDepsResolved; + }; const scope = options.scope ?? 'downstack'; const doctorResult = await doctor(cwd); const blockers: string[] = doctorResult.issues.map((issue) => issue.code); @@ -128,7 +118,7 @@ export async function ready( commitMessages, prDescription, }, - aiDeps, + await aiDeps(), config.ai.provider, ); diff --git a/packages/cli/src/commands/split.ts b/packages/cli/src/commands/split.ts index d553fa15..bab4ff5c 100644 --- a/packages/cli/src/commands/split.ts +++ b/packages/cli/src/commands/split.ts @@ -1,11 +1,5 @@ -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; import input from '@inquirer/input'; -import { createGateway, generateText } from 'ai'; +import { loadAiDeps } from '../lib/ai-deps'; import { buildAiDiffContext } from '../lib/ai-diff-context'; import type { AiMetadataDependencies } from '../lib/ai-metadata'; import { @@ -124,18 +118,6 @@ export interface SplitResult { type SplitDependencies = AiMetadataDependencies; -const DEFAULT_DEPS: SplitDependencies = { - generateText, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, -}; - /** * Splits the current branch into the source branch plus one or more new * sibling branches sharing the same parent. @@ -154,8 +136,9 @@ const DEFAULT_DEPS: SplitDependencies = { export async function split( cwd: string, options: SplitOptions, - deps: SplitDependencies = DEFAULT_DEPS, + depsArg?: SplitDependencies, ): Promise { + const deps = depsArg ?? (await loadAiDeps()); if (!(await isWorkingTreeClean(cwd))) { throw new DubError('Working tree has uncommitted changes.', [ "Run 'git status' to see uncommitted changes.", diff --git a/packages/cli/src/commands/squash.ts b/packages/cli/src/commands/squash.ts index 3878d08f..b305289f 100644 --- a/packages/cli/src/commands/squash.ts +++ b/packages/cli/src/commands/squash.ts @@ -1,10 +1,14 @@ -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; -import { createGateway, generateText } from 'ai'; +import type { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import type { createAnthropic } from '@ai-sdk/anthropic'; +import type { createGoogleGenerativeAI } from '@ai-sdk/google'; +import type { createOpenAI } from '@ai-sdk/openai'; +import type { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import type { + fromIni, + fromNodeProviderChain, +} from '@aws-sdk/credential-providers'; +import type { createGateway, generateText } from 'ai'; +import { loadAiDeps } from '../lib/ai-deps'; import { resolveAiProvider } from '../lib/ai-provider'; import { readConfig } from '../lib/config'; import { DubError } from '../lib/errors'; @@ -55,18 +59,6 @@ interface SquashDependencies { fromNodeProviderChain?: typeof fromNodeProviderChain; } -const DEFAULT_DEPS: SquashDependencies = { - generateText, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, -}; - /** * Collapses every commit on the current branch (since its tracked parent) into * a single commit. @@ -86,8 +78,9 @@ const DEFAULT_DEPS: SquashDependencies = { export async function squash( cwd: string, options: SquashOptions = {}, - deps: SquashDependencies = DEFAULT_DEPS, + depsArg?: SquashDependencies, ): Promise { + const deps = depsArg ?? (await loadAiDeps()); if (options.ai && options.message) { throw new DubError("'--ai' cannot be combined with '-m'.", [ "Drop '--ai' to use the message you supplied.", diff --git a/packages/cli/src/commands/submit.ts b/packages/cli/src/commands/submit.ts index e53fc931..d640c01c 100644 --- a/packages/cli/src/commands/submit.ts +++ b/packages/cli/src/commands/submit.ts @@ -1,12 +1,6 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; -import { createGateway, generateText } from 'ai'; +import { loadAiDeps } from '../lib/ai-deps'; import { type AiMetadataDependencies, generatePrDescriptionSummary, @@ -121,18 +115,6 @@ export interface SubmitResult { type SubmitDependencies = AiMetadataDependencies; type SubmitLifecycle = 'ready' | 'draft' | 'publish'; -const DEFAULT_DEPS: SubmitDependencies = { - generateText, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, -}; - /** * Pushes branches in the current stack and creates/updates GitHub PRs. * @@ -144,8 +126,9 @@ export async function submit( cwd: string, dryRun: boolean, options: SubmitOptions = {}, - deps: SubmitDependencies = DEFAULT_DEPS, + depsArg?: SubmitDependencies, ): Promise { + const deps = depsArg ?? (await loadAiDeps()); if (options.ai && options.noAi) { throw new DubError("'--ai' cannot be combined with '--no-ai'.", [ "Pass '--ai' alone to force AI-generated PR descriptions.", diff --git a/packages/cli/src/index.help.test.ts b/packages/cli/src/index.help.test.ts new file mode 100644 index 00000000..8e23f822 --- /dev/null +++ b/packages/cli/src/index.help.test.ts @@ -0,0 +1,63 @@ +import type { Command } from 'commander'; +import { describe, expect, it } from 'vitest'; +import { program } from './index'; + +function walk( + cmd: Command, + prefix = '', +): Array<{ name: string; cmd: Command }> { + const here = prefix ? `${prefix} ${cmd.name()}` : cmd.name(); + // The root program's own helpInformation is not a "command tutorial"; + // skip it but still descend into its subcommands. + const self = cmd.parent == null ? [] : [{ name: here, cmd }]; + const children = cmd.commands.flatMap((sub) => walk(sub, here)); + return [...self, ...children]; +} + +function renderHelp(cmd: Command): string { + let buffer = ''; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + buffer += + typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); + return true; + }) as typeof process.stdout.write; + try { + cmd.outputHelp(); + } finally { + process.stdout.write = originalWrite; + } + return buffer; +} + +// Some commands are pure aliases or wrappers where adding Examples + See also +// is redundant or impossible (no clear sibling to point at). Keep this list +// short and document why each entry is exempt. +const HELP_EXEMPT = new Set([ + // Inquirer-driven; no flags, see flow's help instead. + 'dub help', +]); + +const allCommands = walk(program); + +describe('per-command help', () => { + it.each(allCommands)('`$name` --help includes an Examples section', ({ + name, + cmd, + }) => { + if (HELP_EXEMPT.has(name)) return; + const help = renderHelp(cmd); + expect(help, `Missing Examples in: ${name}`).toMatch(/Examples?:/); + }); + + it.each( + allCommands.filter(({ cmd }) => cmd.commands.length === 0), + )('`$name` --help includes a "See also" section (leaf commands only)', ({ + name, + cmd, + }) => { + if (HELP_EXEMPT.has(name)) return; + const help = renderHelp(cmd); + expect(help, `Missing See also in: ${name}`).toMatch(/See also:/); + }); +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 25494343..0e993aee 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -19,6 +19,7 @@ */ import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; import chalk, { Chalk } from 'chalk'; import { Command } from 'commander'; import { abortCommand } from './commands/abort'; @@ -111,6 +112,7 @@ import { parseScope, type ScopeMode } from './lib/scope'; import { getStackOverviewBatch } from './lib/stack-overview'; import { migrateStateRefsIfNeeded } from './lib/state'; import { acquireStateLock, type StateLockHandle } from './lib/state-lock'; +import { applyTheme, resolveTheme } from './lib/theme'; const require = createRequire(import.meta.url); const { version } = require('../package.json') as { version: string }; @@ -166,6 +168,10 @@ program '--verbose', 'Print each git/gh subprocess before running (sanitized of secrets)', ) + .option( + '--no-color', + 'Disable ANSI colors globally (overrides the configured theme)', + ) .addHelpText( 'after', ` @@ -186,7 +192,10 @@ program ` Examples: $ dub init Initialize DubStack, creating .git/dubstack/ and updating .gitignore - $ dub init --restore-from-refs Restore state.json from refs/dubstack/*`, + $ dub init --restore-from-refs Restore state.json from refs/dubstack/* + +See also: + dub install, dub config, dub doctor`, ) .action(async (options: { restoreFromRefs?: boolean }) => { const result = await init(process.cwd(), { @@ -221,7 +230,10 @@ Recipes: Examples: $ dub install retarget-action Write .github/workflows/dubstack-retarget.yml $ dub install retarget-action --dry-run Preview the planned write - $ dub install retarget-action --force Overwrite an existing file without confirming`, + $ dub install retarget-action --force Overwrite an existing file without confirming + +See also: + dub docs, dub init`, ) .action( async (recipe: string, options: { dryRun?: boolean; force?: boolean }) => { @@ -324,7 +336,10 @@ program 'after', ` Examples: - $ dub docs Open the DubStack docs website`, + $ dub docs Open the DubStack docs website + +See also: + dub repo, dub help`, ) .action(async () => { await docs(); @@ -337,7 +352,10 @@ program 'after', ` Examples: - $ dub repo Open the current repository GitHub page`, + $ dub repo Open the current repository GitHub page + +See also: + dub pr, dub docs`, ) .action(async () => { await repo(process.cwd()); @@ -373,7 +391,10 @@ Examples: $ dub create feat/api -m "feat: add API" Create branch + commit staged $ dub create feat/api -am "feat: add API" Stage all + create + commit $ dub create --ai AI-generate branch + commit from staged - $ dub create --no-ai feat/api Override repo AI defaults for one create`, + $ dub create --no-ai feat/api Override repo AI defaults for one create + +See also: + dub modify, dub flow, dub track, dub log`, ) .action( async ( @@ -436,7 +457,10 @@ program Examples: $ dub flow --ai -a Stage all, preview AI metadata, create, and submit $ dub flow -y -u Auto-approve after staging tracked changes - $ dub flow --dry-run Preview generated branch, commit, and PR text only`, + $ dub flow --dry-run Preview generated branch, commit, and PR text only + +See also: + dub create, dub submit, dub ai setup`, ) .action(runFlow); @@ -459,7 +483,13 @@ program 'after', ` Examples: - $ dub log Show the branch tree with current branch highlighted`, + $ dub log Show the branch tree with current branch highlighted + $ dub log --stack Only show the current stack + $ dub log --json Emit machine-readable JSON + $ dub log --refresh Bust the 30-second PR/CI overview cache + +See also: + dub ls, dub status, dub info, dub watch`, ) .action( async (options: { @@ -478,7 +508,7 @@ Examples: program .command('ls') - .description('Display an ASCII tree of the current stack') + .description('Display an ASCII tree of the current stack (alias of log)') .option('-s, --stack', 'Only show the current stack') .option('-a, --all', 'Show all stacks (default)') .option('-r, --reverse', 'Reverse stack/child ordering') @@ -490,6 +520,17 @@ program '--no-color', 'Disable ANSI colors; keep `*` (current) and `>` (ancestor) text markers, strip `~` sibling markers', ) + .addHelpText( + 'after', + ` +Examples: + $ dub ls Show every tracked stack + $ dub ls --stack Show only the current stack + $ dub ls --json Emit machine-readable JSON + +See also: + dub log, dub status, dub info`, + ) .action( async (options: { stack?: boolean; @@ -510,6 +551,17 @@ program .argument('[steps]', 'Number of checkout-history entries to go back') .option('-l, --list', 'List recent checkout history without switching') .description('Return to a previously checked-out branch') + .addHelpText( + 'after', + ` +Examples: + $ dub back Switch back to the previous branch + $ dub back 3 Jump 3 entries back in the checkout history + $ dub back --list Show recent checkout history without switching + +See also: + dub checkout, dub co, dub up, dub down`, + ) .action(async (stepsArg: string | undefined, options: { list?: boolean }) => { if (options.list) { const entries = await listBackHistory(process.cwd()); @@ -541,6 +593,17 @@ program .argument('[steps]', 'Number of levels to traverse upstack') .option('-n, --steps ', 'Number of levels to traverse upstack') .description('Checkout the child branch directly above the current branch') + .addHelpText( + 'after', + ` +Examples: + $ dub up Step one branch upstack (toward descendants) + $ dub up 2 Step two branches upstack + $ dub up --steps 3 Step three branches upstack + +See also: + dub down, dub top, dub bottom, dub co`, + ) .action(async (stepsArg: string | undefined, options: { steps?: string }) => { const steps = parseSteps(stepsArg, options.steps); const result = await upBySteps(process.cwd(), steps); @@ -556,6 +619,17 @@ program .argument('[steps]', 'Number of levels to traverse downstack') .option('-n, --steps ', 'Number of levels to traverse downstack') .description('Checkout the parent branch directly below the current branch') + .addHelpText( + 'after', + ` +Examples: + $ dub down Step one branch downstack (toward trunk) + $ dub down 2 Step two branches downstack + $ dub down --steps 3 Step three branches downstack + +See also: + dub up, dub top, dub bottom, dub co`, + ) .action(async (stepsArg: string | undefined, options: { steps?: string }) => { const steps = parseSteps(stepsArg, options.steps); const result = await downBySteps(process.cwd(), steps); @@ -571,6 +645,15 @@ program program .command('top') .description('Checkout the topmost branch in the current stack path') + .addHelpText( + 'after', + ` +Examples: + $ dub top Jump to the highest tip in the current stack path + +See also: + dub bottom, dub up, dub down`, + ) .action(async () => { const result = await top(process.cwd()); if (result.changed) { @@ -585,6 +668,15 @@ program .description( 'Checkout the first branch above the root in the current stack path', ) + .addHelpText( + 'after', + ` +Examples: + $ dub bottom Jump to the branch sitting directly on trunk + +See also: + dub top, dub up, dub down`, + ) .action(async () => { const result = await bottom(process.cwd()); if (result.changed) { @@ -601,12 +693,34 @@ program program .command('branch') .description('Show DubStack branch metadata') + .addHelpText( + 'after', + ` +Examples: + $ dub branch info Show stack info for the current branch + $ dub branch info feat/auth-login Show stack info for a specific branch + +See also: + dub info, dub log, dub parent, dub children`, + ) .addCommand( new Command('info') .description('Show tracked stack info for the current branch') .argument('[branch]', 'Branch to inspect (defaults to current branch)') .option('-d, --diff', 'Show the parent-relative git diff for the branch') .option('--json', 'Output branch info as JSON') + .addHelpText( + 'after', + ` +Examples: + $ dub branch info Show stack info for the current branch + $ dub branch info feat/auth-login Show stack info for a specific branch + $ dub branch info --diff Include parent-relative diff inline + $ dub branch info --json Emit machine-readable JSON + +See also: + dub info, dub log, dub parent, dub children`, + ) .action(showInfo), ); @@ -616,6 +730,18 @@ program .option('-d, --diff', 'Show the parent-relative git diff for the branch') .option('--json', 'Output branch info as JSON') .description('Show tracked stack info for a branch') + .addHelpText( + 'after', + ` +Examples: + $ dub info Inspect the current branch + $ dub info feat/auth-login Inspect a specific tracked branch + $ dub info --diff Show parent-relative diff inline + $ dub info --json Emit machine-readable JSON + +See also: + dub status, dub log, dub parent, dub children`, + ) .action(showInfo); program @@ -631,8 +757,11 @@ program 'after', ` Examples: - $ dub track - $ dub track feat/a --parent main`, + $ dub track Adopt the current branch (DubStack picks the parent) + $ dub track feat/a --parent main Adopt feat/a with main as the explicit parent + +See also: + dub untrack, dub create, dub log, dub doctor`, ) .action( async ( @@ -682,8 +811,11 @@ program 'after', ` Examples: - $ dub untrack - $ dub untrack feat/a --downstack`, + $ dub untrack Drop tracking metadata for the current branch + $ dub untrack feat/a --downstack Untrack feat/a and its ancestors toward trunk + +See also: + dub track, dub delete, dub prune`, ) .action( async ( @@ -722,8 +854,11 @@ program 'after', ` Examples: - $ dub delete feat/a - $ dub delete feat/a --upstack -f -q`, + $ dub delete feat/a Delete feat/a (with confirmation) + $ dub delete feat/a --upstack -f -q Delete feat/a + descendants, force, quiet + +See also: + dub untrack, dub prune, dub fold`, ) .action( async ( @@ -783,7 +918,10 @@ program Examples: $ dub fold Fold current branch into parent (keeps commits) $ dub fold --squash Collapse current branch into a single commit on parent - $ dub fold --force Skip the confirmation prompt`, + $ dub fold --force Skip the confirmation prompt + +See also: + dub squash, dub delete, dub move`, ) .action( async (options: { @@ -857,7 +995,10 @@ program ` Examples: $ dub move feat/inserted --before feat/auth-login Insert before - $ dub move feat/inserted --after feat/auth-base Insert after `, + $ dub move feat/inserted --after feat/auth-base Insert after + +See also: + dub reorder, dub unlink, dub restack`, ) .action( async (branch: string, options: { before?: string; after?: string }) => { @@ -925,7 +1066,10 @@ Examples: $ dub absorb Autosquash literal 'fixup!' / 'squash!' commits on the current branch $ dub absorb --ai Use the configured AI provider to pick targets for ambiguous WIP commits $ dub absorb --stack Move fixup commits across branches in the stack, then restack - $ dub absorb --dry-run Print the plan without mutating`, + $ dub absorb --dry-run Print the plan without mutating + +See also: + dub modify, dub squash, dub restack`, ) .action( async (options: { ai?: boolean; stack?: boolean; dryRun?: boolean }) => { @@ -990,7 +1134,10 @@ program Examples: $ dub unlink feat/auth-login Promote feat/auth-login to a new stack root $ dub unlink feat/auth-login --orphan-children Leave descendants on the original parent - $ dub unlink feat/auth-login --no-retarget Skip PR retarget (warns about drift)`, + $ dub unlink feat/auth-login --no-retarget Skip PR retarget (warns about drift) + +See also: + dub move, dub track, dub untrack`, ) .action( async ( @@ -1059,6 +1206,17 @@ program .argument('[branch]', 'Branch to inspect (defaults to current branch)') .option('--json', 'Output parent info as JSON') .description('Show the direct parent branch') + .addHelpText( + 'after', + ` +Examples: + $ dub parent Print the parent of the current branch + $ dub parent feat/auth-login Print the parent of a specific branch + $ dub parent --json Emit JSON for shell scripts + +See also: + dub children, dub info, dub log`, + ) .action(async (branch: string | undefined, options: { json?: boolean }) => { if (options.json) activateJsonMode(); const result = await parent(process.cwd(), branch); @@ -1074,6 +1232,17 @@ program .argument('[branch]', 'Branch to inspect (defaults to current branch)') .option('--json', 'Output children info as JSON') .description('Show direct child branches') + .addHelpText( + 'after', + ` +Examples: + $ dub children Print children of the current branch + $ dub children feat/auth-base Print children of a specific branch + $ dub children --json Emit JSON for shell scripts + +See also: + dub parent, dub info, dub log`, + ) .action(async (branch: string | undefined, options: { json?: boolean }) => { if (options.json) activateJsonMode(); const result = await children(process.cwd(), branch); @@ -1117,6 +1286,15 @@ Examples: trunkCommand .command('list') .description('List configured trunk branches') + .addHelpText( + 'after', + ` +Examples: + $ dub trunk list Print every configured trunk with the default marked + +See also: + dub trunk add, dub trunk set-default`, + ) .action(async () => { const result = await listTrunks(process.cwd()); for (const entry of result.trunks) { @@ -1128,6 +1306,16 @@ trunkCommand .command('add') .argument('', 'Trunk branch name to register') .description('Register a trunk branch') + .addHelpText( + 'after', + ` +Examples: + $ dub trunk add main Register 'main' as a trunk + $ dub trunk add release/24.x Register a release-line trunk + +See also: + dub trunk list, dub trunk set-default, dub trunk remove`, + ) .action(async (name: string) => { const result = await addTrunk(process.cwd(), name); if (result.status === 'already-exists') { @@ -1143,6 +1331,15 @@ trunkCommand .command('remove') .argument('', 'Trunk branch name to remove') .description('Remove a configured trunk branch') + .addHelpText( + 'after', + ` +Examples: + $ dub trunk remove release/24.x Unregister a configured trunk + +See also: + dub trunk add, dub trunk list`, + ) .action(async (name: string) => { const result = await removeTrunk(process.cwd(), name); console.log(chalk.green(`✔ Removed trunk '${result.trunk}'`)); @@ -1152,6 +1349,16 @@ trunkCommand .command('set-default') .argument('', 'Configured trunk to use by default') .description('Set the default trunk for new stacks') + .addHelpText( + 'after', + ` +Examples: + $ dub trunk set-default main Use 'main' for new untracked stacks + $ dub trunk set-default release/24.x Switch the default to a release line + +See also: + dub trunk list, dub trunk add`, + ) .action(async (name: string) => { const result = await setDefaultTrunk(process.cwd(), name); console.log(chalk.green(`✔ Default trunk is now '${result.trunk}'`)); @@ -1173,7 +1380,10 @@ program Examples: $ dub watch Start watcher with default 60s interval $ dub watch --interval 30s Poll GitHub every 30 seconds - $ dub watch --ui Render live status pane`, + $ dub watch --ui Render live status pane + +See also: + dub status, dub log, dub sync`, ) .action(async (options: { interval?: string; ui?: boolean }) => { await watch(process.cwd(), options); @@ -1197,6 +1407,19 @@ program '--fresh', 'Force a full fetch of every tracked branch (skip 5-minute freshness cache)', ) + .addHelpText( + 'after', + ` +Examples: + $ dub sync Sync the current stack with origin and restack + $ dub sync --all Sync every tracked stack across trunks + $ dub sync --no-restack Sync without restacking (manual fix-up later) + $ dub sync --fresh Force-fetch each branch even if cached + $ dub sync -f Skip prompts on reset/reconcile decisions + +See also: + dub restack, dub post-merge, dub merge-next`, + ) .action( async (options: { restack?: boolean; @@ -1217,8 +1440,11 @@ program 'after', ` Examples: - $ dub restack Rebase the current stack - $ dub restack --continue Continue after resolving conflicts`, + $ dub restack Rebase the current stack onto its updated parents + $ dub restack --continue Continue after resolving conflicts + +See also: + dub continue, dub abort, dub sync, dub post-merge`, ) .action(async (options: { continue?: boolean }) => { const result = options.continue @@ -1293,6 +1519,17 @@ program '--no-adjudicate', 'Use one configured AI provider for conflict resolution', ) + .addHelpText( + 'after', + ` +Examples: + $ dub continue Resume restack/rebase after manual conflict resolution + $ dub continue --ai Ask the AI to propose resolutions, then continue + $ dub continue --adjudicate Use two AI providers and pick the better fix + +See also: + dub restack, dub abort, dub ai resolve`, + ) .action(async (options: { ai?: boolean; adjudicate?: boolean }) => { const result = await continueCommand(process.cwd(), { ai: options.ai, @@ -1339,6 +1576,15 @@ program program .command('abort') .description('Abort the active restack or git rebase operation') + .addHelpText( + 'after', + ` +Examples: + $ dub abort Roll back the in-progress restack/rebase and restore branches + +See also: + dub continue, dub restack, dub undo`, + ) .action(async () => { const result = await abortCommand(process.cwd()); if (result.aborted === 'restack') { @@ -1371,7 +1617,10 @@ Examples: $ dub undo Roll back the last dub operation $ dub undo --steps 3 Roll back the last three operations $ dub undo --list Show recent undoable operations with timestamps - $ dub undo --clear Wipe both the undo and redo logs`, + $ dub undo --clear Wipe both the undo and redo logs + +See also: + dub redo, dub abort, dub history`, ) .action( async (options: { steps?: number; list?: boolean; clear?: boolean }) => { @@ -1419,7 +1668,10 @@ program 'after', ` Examples: - $ dub redo Re-apply the most recently undone operation`, + $ dub redo Re-apply the most recently undone operation + +See also: + dub undo, dub history`, ) .action(async () => { const result = await redo(process.cwd()); @@ -1497,13 +1749,27 @@ Examples: $ dub submit --merge-when-ready --method squash Queue GitHub auto-merge for submitted PRs $ dub submit --rerequest-review - Re-request review on updated PRs`, + Re-request review on updated PRs + +See also: + dub ss, dub ready, dub merge-next, dub merge-check, dub pr`, ) .action(runSubmit); program .command('ss') .description('Submit the current stack (alias for submit)') + .addHelpText( + 'after', + ` +Examples: + $ dub ss Submit downstack (current branch + ancestors) + $ dub ss --stack Submit the full stack tree + $ dub ss --dry-run Preview the push/PR plan without executing + +See also: + dub submit, dub ready, dub merge-next`, + ) .option('--dry-run', 'Print what would happen without executing') .option('-i, --ai', 'AI-generate a PR description for this invocation') .option('--no-ai', 'Disable AI PR description generation for this invocation') @@ -1571,7 +1837,10 @@ Examples: $ dub merge-check --scope downstack Check current branch + ancestors $ dub merge-check --scope stack Check every branch in the stack $ dub merge-check --pr 123 Check a specific PR (scope ignored) - $ dub merge-check --json Emit structured JSON (exits 1 on failure)`, + $ dub merge-check --json Emit structured JSON (exits 1 on failure) + +See also: + dub merge-next, dub ready, dub submit`, ) .action( async (options: { @@ -1631,6 +1900,18 @@ program 'Submit refreshed stack updates (disable with --no-submit)', true, ) + .addHelpText( + 'after', + ` +Examples: + $ dub post-merge Repair the current stack after a merge + $ dub post-merge --all Process every tracked stack + $ dub post-merge --dry-run Preview cleanup + retarget without mutating + $ dub post-merge --no-restack Skip restacking remaining branches + +See also: + dub merge-next, dub sync, dub restack`, + ) .action( async (options: { all?: boolean; @@ -1667,6 +1948,19 @@ program .command('merge-next') .alias('land') .description('Merge the next safe PR in your current stack path') + .addHelpText( + 'after', + ` +Examples: + $ dub merge-next Merge the next eligible PR + run post-merge + $ dub land Alias for 'dub merge-next' + $ dub merge-next --method squash Squash-merge (default) + $ dub merge-next --queue Enqueue in the GitHub merge queue + $ dub merge-next --dry-run Preview the merge + retarget plan + +See also: + dub submit, dub merge-check, dub post-merge, dub ready`, + ) .option('--dry-run', 'Preview merge + post-merge actions') .option('--queue', 'Use GitHub native merge queue when merging') .option('--no-queue', 'Force direct merge even when merge queue is enabled') @@ -1779,6 +2073,18 @@ program .option('-a, --all', 'Check all stacks instead of only the current stack') .option('--no-fetch', 'Skip remote fetch before remote drift checks') .option('--json', 'Output doctor results as JSON') + .addHelpText( + 'after', + ` +Examples: + $ dub doctor Health-check the current stack + $ dub doctor --all Health-check every tracked stack + $ dub doctor --no-fetch Skip the remote fetch (offline / fast path) + $ dub doctor --json Emit machine-readable results + +See also: + dub status, dub prune, dub sync`, + ) .action( async (options: { all?: boolean; fetch?: boolean; json?: boolean }) => { if (options.json) activateJsonMode(); @@ -1830,7 +2136,10 @@ Examples: $ dub status Print a one-line status (cache-only, fast) $ dub status --json Emit structured JSON $ dub status --live Refresh PR/CI data via gh - $ dub status --no-pr Skip the PR fetch (shell prompts without gh)`, + $ dub status --no-pr Skip the PR fetch (shell prompts without gh) + +See also: + dub log, dub info, dub doctor, dub watch`, ) .action(async (options: { json?: boolean; live?: boolean; pr?: boolean }) => { if (options.json) activateJsonMode(); @@ -1871,7 +2180,10 @@ Examples: $ dub ready --scope current Check just the current branch $ dub ready --scope stack Check every branch in the stack $ dub ready --ai Run AI review-readiness checks - $ dub ready --ai --scope stack Run AI checks for every branch in the stack`, + $ dub ready --ai --scope stack Run AI checks for every branch in the stack + +See also: + dub doctor, dub submit, dub merge-check`, ) .action( async (options: { @@ -1928,6 +2240,18 @@ program .option('--apply', 'Apply pruning changes (default is preview only)') .option('-a, --all', 'Prune stale tracked branches across all stacks') .option('--no-fetch', 'Skip remote fetch before pruning checks') + .addHelpText( + 'after', + ` +Examples: + $ dub prune Preview stale tracked branches + $ dub prune --apply Apply the prune and remove stale metadata + $ dub prune --all Scan every tracked stack + $ dub prune --no-fetch Skip the remote fetch (offline / fast path) + +See also: + dub doctor, dub untrack, dub delete`, + ) .action( async (options: { apply?: boolean; all?: boolean; fetch?: boolean }) => { const result = await prune(process.cwd(), options); @@ -1985,6 +2309,20 @@ program ) .option('--no-color', 'Disable ANSI colors in the picker') .description('Checkout a branch (interactive picker if no name given)') + .addHelpText( + 'after', + ` +Examples: + $ dub co Open the interactive branch picker + $ dub co feat/auth-login Checkout a specific branch by name + $ dub co --trunk Jump back to the current trunk + $ dub co --stack Picker scoped to ancestors + descendants + $ dub co --all Picker showing every tracked stack + $ dub co --show-untracked Include untracked git branches in the picker + +See also: + dub back, dub up, dub down, dub top, dub bottom`, + ) .action( async ( branch: string | undefined, @@ -2022,12 +2360,35 @@ program program .command('skills') .description('Manage DubStack agent skills') + .addHelpText( + 'after', + ` +Examples: + $ dub skills add Install every agent skill into this repo + $ dub skills add dubstack Install just the dubstack skill + $ dub skills remove Remove every installed skill + +See also: + dub config ai-assistant, dub ai setup`, + ) .addCommand( new Command('add') .description('Install agent skills (e.g. dubstack, dub-flow)') .argument('[skills...]', 'Names of skills to install (default: all)') .option('-g, --global', 'Install skills globally') .option('--dry-run', 'Preview actions without installing') + .addHelpText( + 'after', + ` +Examples: + $ dub skills add Install every available skill into this repo + $ dub skills add dubstack Install just the dubstack skill + $ dub skills add --global Install skills to your user-level Claude dir + $ dub skills add --dry-run Preview which files would be written + +See also: + dub skills remove`, + ) .action(async (skills, options) => { const { addSkills } = await import('./commands/skills'); await addSkills(skills, options); @@ -2039,6 +2400,17 @@ program .argument('[skills...]', 'Names of skills to remove (default: all)') .option('-g, --global', 'Remove skills globally') .option('--dry-run', 'Preview actions without removing') + .addHelpText( + 'after', + ` +Examples: + $ dub skills remove Remove every installed skill from this repo + $ dub skills remove dub-flow Remove a specific skill + $ dub skills remove --global Remove user-level installed skills + +See also: + dub skills add`, + ) .action(async (skills, options) => { const { removeSkills } = await import('./commands/skills'); await removeSkills(skills, options); @@ -2048,10 +2420,34 @@ program program .command('config') .description('Manage DubStack configuration') + .addHelpText( + 'after', + ` +Examples: + $ dub config theme dark Pin to a dark-background palette + $ dub config ai-assistant on Enable AI features for this repo + $ dub config reviewers alice,bob Set repo-default PR reviewers + $ dub config storage-backend sqlite Switch to the SQLite backend + $ dub config submit-default draft Open new submit PRs as drafts + +See also: + dub ai setup, dub migrate storage, dub init`, + ) .addCommand( new Command('ai-assistant') .argument('[state]', 'Set to on/off (omit to inspect current value)') .description('Enable or disable the repo-local AI assistant') + .addHelpText( + 'after', + ` +Examples: + $ dub config ai-assistant Show the current setting + $ dub config ai-assistant on Enable AI features for this repo + $ dub config ai-assistant off Disable AI features for this repo + +See also: + dub config ai-provider, dub config ai-defaults`, + ) .action(async (state?: string) => { const { configAiAssistant } = await import('./commands/config'); const result = await configAiAssistant(process.cwd(), state); @@ -2085,6 +2481,17 @@ program .description('Manage repo-local AI defaults for DubStack commands') .argument('', 'One of: create, submit, flow') .argument('[state]', 'Set to on/off (omit to inspect current value)') + .addHelpText( + 'after', + ` +Examples: + $ dub config ai-defaults create on AI-generate branch + commit by default + $ dub config ai-defaults submit on AI-generate PR descriptions by default + $ dub config ai-defaults flow on Default \`dub flow\` to AI mode + +See also: + dub config ai-assistant, dub config ai-prompts`, + ) .action(async (target: 'create' | 'submit' | 'flow', state?: string) => { const { configAiDefaults } = await import('./commands/config'); const result = await configAiDefaults(process.cwd(), target, state); @@ -2117,6 +2524,17 @@ program new Command('ai-prompts') .argument('[mode]', 'Set to auto/on/off (omit to inspect current value)') .description('Manage AI choices in interactive prompts') + .addHelpText( + 'after', + ` +Examples: + $ dub config ai-prompts auto Show AI choices when the assistant is enabled + $ dub config ai-prompts on Always offer AI choices in prompts + $ dub config ai-prompts off Never offer AI choices in prompts + +See also: + dub config ai-prompts-auto-accept, dub config ai-assistant`, + ) .action(async (mode?: string) => { const { configAiPrompts } = await import('./commands/config'); const result = await configAiPrompts(process.cwd(), mode); @@ -2145,6 +2563,16 @@ program new Command('ai-prompts-auto-accept') .argument('[level]', 'Set to off/high (omit to inspect current value)') .description('Manage AI prompt recommendation auto-accept behavior') + .addHelpText( + 'after', + ` +Examples: + $ dub config ai-prompts-auto-accept off Always confirm AI recommendations + $ dub config ai-prompts-auto-accept high Auto-apply high-confidence picks + +See also: + dub config ai-prompts`, + ) .action(async (level?: string) => { const { configAiPromptsAutoAccept } = await import('./commands/config'); const result = await configAiPromptsAutoAccept(process.cwd(), level); @@ -2180,6 +2608,18 @@ program 'Set to auto/gemini/anthropic/gateway/bedrock/openai/ollama (omit to inspect current value)', ) .description('Manage the repo-local AI provider selection') + .addHelpText( + 'after', + ` +Examples: + $ dub config ai-provider auto Pick a provider from configured env keys + $ dub config ai-provider anthropic Force the Anthropic provider + $ dub config ai-provider gemini Force Google's Gemini provider + $ dub config ai-provider ollama Use a local Ollama server + +See also: + dub config ai-model, dub ai setup, dub ai env`, + ) .action(async (provider?: string) => { const { configAiProvider } = await import('./commands/config'); const result = await configAiProvider(process.cwd(), provider); @@ -2211,6 +2651,17 @@ program .description( 'Manage the security model for mutating MCP tool calls (default: interactive)', ) + .addHelpText( + 'after', + ` +Examples: + $ dub config mcp-mode read-only Disable mutating MCP tools entirely + $ dub config mcp-mode interactive Require terminal confirmation (default) + $ dub config mcp-mode trusted Let mutating MCP tools run without prompts + +See also: + dub mcp`, + ) .action(async (mode?: string) => { const { configMcpMode } = await import('./commands/config'); const result = await configMcpMode(process.cwd(), mode); @@ -2234,6 +2685,17 @@ program .argument('[list]', 'Comma-separated GitHub users or teams') .option('--clear', 'Remove repo-default reviewers') .description('Manage repo-default PR reviewers') + .addHelpText( + 'after', + ` +Examples: + $ dub config reviewers Show repo-default reviewers + $ dub config reviewers alice,@org/backend Set default reviewers + $ dub config reviewers --clear Remove all repo-default reviewers + +See also: + dub submit --reviewers, dub submit --no-reviewers`, + ) .action( async (list: string | undefined, options: { clear?: boolean }) => { const { configReviewers } = await import('./commands/config'); @@ -2280,6 +2742,17 @@ program 'Set to json/sqlite (omit to inspect current value)', ) .description('Manage the repo-local state storage backend') + .addHelpText( + 'after', + ` +Examples: + $ dub config storage-backend Show the current backend + $ dub config storage-backend json Switch to the JSON backend (default) + $ dub config storage-backend sqlite Switch to the SQLite backend + +See also: + dub migrate storage`, + ) .action(async (backend?: string) => { const { configStorageBackend } = await import('./commands/config'); const result = await configStorageBackend(process.cwd(), backend); @@ -2311,6 +2784,17 @@ program 'Set to auto/draft/publish (omit to inspect current value)', ) .description('Manage the repo-local submit PR lifecycle default') + .addHelpText( + 'after', + ` +Examples: + $ dub config submit-default auto Auto-pick draft when CI workflows exist + $ dub config submit-default draft Open new PRs as drafts by default + $ dub config submit-default publish Promote existing draft PRs by default + +See also: + dub submit --draft, dub submit --publish`, + ) .action(async (mode?: string) => { const { configSubmitDefault } = await import('./commands/config'); const result = await configSubmitDefault(process.cwd(), mode); @@ -2333,6 +2817,46 @@ program } }), ) + .addCommand( + new Command('theme') + .argument( + '[mode]', + 'Set to auto/dark/light/none (omit to inspect current value)', + ) + .description( + 'Manage the terminal color theme used in log, status, and sync output', + ) + .addHelpText( + 'after', + ` +Examples: + $ dub config theme Show the configured theme + $ dub config theme auto Auto-detect light/dark from COLORFGBG (default) + $ dub config theme dark Pin to a dark-background palette + $ dub config theme light Pin to a light-background palette + $ dub config theme none Disable colors (equivalent to --no-color) + +See also: + dub log, dub status, dub sync`, + ) + .action(async (mode?: string) => { + const { configTheme } = await import('./commands/config'); + const result = await configTheme(process.cwd(), mode); + + if (!mode) { + console.log( + chalk.blue(`Theme is '${result.theme}' for this repository.`), + ); + return; + } + + if (result.changed) { + console.log(chalk.green(`✔ Theme set to '${result.theme}'`)); + } else { + console.log(chalk.yellow(`⚠ Theme is already '${result.theme}'`)); + } + }), + ) .addCommand( new Command('ai-model') .argument('[model]', 'Set repo-local model override (omit to inspect)') @@ -2342,6 +2866,17 @@ program ) .option('--clear', 'Clear the repo-local model override') .description('Manage repo-local AI model overrides by provider') + .addHelpText( + 'after', + ` +Examples: + $ dub config ai-model --provider anthropic claude-sonnet-4-7 + $ dub config ai-model --provider gemini gemini-2.5-pro + $ dub config ai-model --provider openai --clear + +See also: + dub config ai-provider, dub ai env, dub ai setup`, + ) .action( async ( model: string | undefined, @@ -2395,6 +2930,16 @@ program program .command('migrate') .description('Migrate DubStack repo-local data between storage formats') + .addHelpText( + 'after', + ` +Examples: + $ dub migrate storage --to sqlite Move state.json into state.sqlite + $ dub migrate storage --to json Move state.sqlite back to state.json + +See also: + dub config storage-backend`, + ) .addCommand( new Command('storage') .requiredOption('--to ', 'Storage backend: json or sqlite') @@ -2404,7 +2949,10 @@ program ` Examples: $ dub migrate storage --to sqlite Copy state.json into state.sqlite and opt in - $ dub migrate storage --to json Copy state.sqlite back to state.json`, + $ dub migrate storage --to json Copy state.sqlite back to state.json + +See also: + dub config storage-backend, dub init`, ) .action(async (options: { to: string }) => { const result = await migrateStorage(process.cwd(), options.to); @@ -2431,9 +2979,30 @@ program .description( 'Use DubStack AI assistant utilities (or shortcut with: dub PROMPT)', ) + .addHelpText( + 'after', + ` +Examples: + $ dub ai setup Interactive provider + model setup + $ dub ai ask "summarize this stack" Ask the assistant a question + $ dub ai env --anthropic-key sk-... Write Anthropic env exports + $ dub ai resolve Resolve in-progress conflicts via AI + +See also: + dub config ai-assistant, dub flow`, + ) .addCommand( new Command('setup') .description('Guided setup for DubStack AI providers and model defaults') + .addHelpText( + 'after', + ` +Examples: + $ dub ai setup Interactively pick a provider and write env exports + +See also: + dub ai env, dub config ai-provider, dub config ai-model`, + ) .action(async () => { const { aiSetup } = await import('./commands/ai-setup'); const result = await aiSetup(process.cwd()); @@ -2463,6 +3032,17 @@ program new Command('ask') .argument('', 'Prompt text to send to the AI assistant') .description('Ask DubStack AI assistant a question (explicit mode)') + .addHelpText( + 'after', + ` +Examples: + $ dub ai ask "what changed in this stack?" + $ dub ai ask "draft a PR description for this branch" + $ dub ai ask "is this stack ready to land?" + +See also: + dub ai resolve, dub ai setup, dub flow`, + ) .action(async (promptParts: string[]) => { const { askAi } = await import('./commands/ai'); if (!invocationMetadata.invocationMode) { @@ -2499,6 +3079,17 @@ program '--shell ', 'Shell name used for profile detection (zsh or bash)', ) + .addHelpText( + 'after', + ` +Examples: + $ dub ai env --anthropic-key sk-... + $ dub ai env --gemini-key ... --gemini-model gemini-2.5-pro + $ dub ai env --ollama-base-url http://localhost:11434 --ollama-model qwen2.5-coder + +See also: + dub ai setup, dub config ai-provider`, + ) .action( async (options: { geminiKey?: string; @@ -2560,6 +3151,17 @@ program 'Resolve conflicts with two configured AI providers', ) .option('--no-adjudicate', 'Use one configured AI provider') + .addHelpText( + 'after', + ` +Examples: + $ dub ai resolve Resolve in-progress rebase/restack conflicts + $ dub ai resolve --dry-run Show proposed resolutions without applying + $ dub ai resolve --adjudicate Pit two providers against each other + +See also: + dub continue --ai, dub abort, dub restack`, + ) .action( async (options: { dryRun?: boolean; @@ -2585,6 +3187,17 @@ program parsePositiveInt, ) .option('--json', 'Output history as JSON') + .addHelpText( + 'after', + ` +Examples: + $ dub history Show the 20 most recent dub commands + $ dub history -n 50 Show the last 50 entries + $ dub history --json Emit JSON (for downstream tooling) + +See also: + dub status, dub doctor`, + ) .action(async (options: { limit?: number; json?: boolean }) => { if (options.json) activateJsonMode(); const { formatHistory, history } = await import('./commands/history'); @@ -2605,6 +3218,15 @@ program .description( 'Start the DubStack MCP server over stdio (mutating tools gated by `dub config mcp-mode`)', ) + .addHelpText( + 'after', + ` +Examples: + $ dub mcp Speak Model Context Protocol over stdio (used by AI agents) + +See also: + dub config mcp-mode`, + ) .action(async () => { await mcp(process.cwd(), { version }); }); @@ -2639,6 +3261,18 @@ program // .option("--into ", "Amend staged changes to the specified branch") // TODO: Implement --into // .option("--reset-author", "Set the author to the current user") // TODO: Implement --reset-author // .option("-v, --verbose", "Show unified diff") // TODO: Implement verbose + .addHelpText( + 'after', + ` +Examples: + $ dub modify -a Amend HEAD with all working-tree changes + $ dub modify -c -a -m "feat: foo" Add a new commit (stage all) on this branch + $ dub modify -p Pick hunks to amend with + $ dub modify --interactive-rebase Interactively rebase the branch commits + +See also: + dub create, dub squash, dub split, dub absorb`, + ) .action(async (options) => { const { modify } = await import('./commands/modify'); const normalizedOptions = { @@ -2667,7 +3301,10 @@ program Examples: $ dub squash Squash and concatenate original messages $ dub squash -m "feat: rewrite api" Squash with a custom commit message - $ dub squash --ai Squash with an AI-generated summary`, + $ dub squash --ai Squash with an AI-generated summary + +See also: + dub fold, dub modify, dub absorb`, ) .action(async (options: { message?: string; ai?: boolean }) => { const result = await squash(process.cwd(), { @@ -2757,7 +3394,10 @@ PR handling: linking to the new branches. After the split, 'dub restack' runs automatically so any descendants follow -the source branch's new tip. Pass '--no-restack' to skip that step.`, +the source branch's new tip. Pass '--no-restack' to skip that step. + +See also: + dub reorder, dub modify, dub absorb, dub move`, ) .action(runSplit); @@ -2777,7 +3417,10 @@ program Examples: $ dub pop Pop last commit into staged changes $ dub pop --steps 3 Squash last 3 commits into staged changes - $ dub pop && dub m -a -m "..." Pop, edit, re-commit (descendants restack lazily)`, + $ dub pop && dub m -a -m "..." Pop, edit, re-commit (descendants restack lazily) + +See also: + dub modify, dub split, dub undo`, ) .action(async (options: { steps?: number }) => { const result = await pop(process.cwd(), { steps: options.steps }); @@ -2803,7 +3446,10 @@ program 'after', ` Examples: - $ dub reorder Open the picker for the current branch's commits`, + $ dub reorder Open the picker for the current branch's commits + +See also: + dub modify, dub split, dub move`, ) .action(async () => { const result = await reorder(process.cwd()); @@ -2873,6 +3519,17 @@ program .command('pr') .argument('[branch]', 'Branch name or PR number to open') .description('Open a branch PR in your browser') + .addHelpText( + 'after', + ` +Examples: + $ dub pr Open the PR for the current branch + $ dub pr feat/auth-login Open the PR for a specific branch + $ dub pr 123 Open PR #123 in the GitHub UI + +See also: + dub submit, dub repo, dub status`, + ) .action(async (branch?: string) => { await pr(process.cwd(), branch); }); @@ -2892,7 +3549,10 @@ Examples: $ dub freeze Freeze the current branch $ dub freeze feat/auth-login Freeze a specific tracked branch $ dub freeze feat/auth-login --downstack Freeze the branch and its ancestors - $ dub freeze --upstack Freeze the current branch and its descendants`, + $ dub freeze --upstack Freeze the current branch and its descendants + +See also: + dub unfreeze, dub restack`, ) .action( async ( @@ -2917,7 +3577,10 @@ program ` Examples: $ dub unfreeze Unfreeze the current branch - $ dub unfreeze feat/auth-login --upstack Unfreeze a branch and its descendants`, + $ dub unfreeze feat/auth-login --upstack Unfreeze a branch and its descendants + +See also: + dub freeze, dub restack`, ) .action( async ( @@ -2943,7 +3606,10 @@ program Examples: $ dub rename feat/new-name Rename the current tracked branch $ dub rename feat/old feat/new Rename a specific tracked branch - $ dub rename --no-push feat/new-name Rename without pushing the renamed branch`, + $ dub rename --no-push feat/new-name Rename without pushing the renamed branch + +See also: + dub track, dub submit, dub pr`, ) .action( async ( @@ -3000,7 +3666,10 @@ Examples: $ dub revert 123 Revert merged PR #123 onto trunk $ dub revert abc1234 Revert commit abc1234 onto trunk $ dub revert 123 --submit Revert + push + open a PR - $ dub revert 123 -b revert/api-rollback Use a custom branch name`, + $ dub revert 123 -b revert/api-rollback Use a custom branch name + +See also: + dub submit, dub merge-next, dub log`, ) .action( async ( @@ -3056,7 +3725,10 @@ Examples: $ dub stash pop Pop most recent (same branch only) $ dub stash pop --on feat/other Checkout feat/other, then pop $ dub stash pop --force Pop onto current branch regardless - $ dub stash list Show recorded stashes with branch context`, + $ dub stash list Show recorded stashes with branch context + +See also: + dub stash pop, dub stash list, git stash`, ) .action(async (options: { message?: string; list?: boolean }) => { if (options.list) { @@ -3091,7 +3763,10 @@ stashCommand.addCommand( Examples: $ dub stash pop Pop most recent (same branch only) $ dub stash pop --on feat/other Checkout feat/other, then pop - $ dub stash pop --force Pop onto current branch regardless`, + $ dub stash pop --force Pop onto current branch regardless + +See also: + dub stash, dub stash list`, ) .action(async (options: { on?: string; force?: boolean }) => { const result = await stashPop(process.cwd(), { @@ -3113,6 +3788,15 @@ Examples: stashCommand.addCommand( new Command('list') .description('Show recorded dub stashes with branch context') + .addHelpText( + 'after', + ` +Examples: + $ dub stash list Show recorded dub stashes with branch context + +See also: + dub stash, dub stash pop`, + ) .action(runStashList), ); @@ -3671,6 +4355,10 @@ async function main() { historyArgsForCapture = rawArgs; const knownCommands = collectKnownTopLevelCommands(program.commands); const config = await readConfig(process.cwd()).catch(() => null); + const noColor = + rawArgs.includes('--no-color') || process.env.NO_COLOR != null; + const resolvedTheme = resolveTheme(config?.theme ?? 'auto', { noColor }); + applyTheme(resolvedTheme); const shortcutEnabled = config?.ai.shortcutFallback.enabled ?? true; const preprocessed = shortcutEnabled || rawArgs[0] === '--ai' @@ -3923,4 +4611,21 @@ function _normalizeHistoryLine(line: string): string { return visible.trim().length === 0 ? '' : visible; } -main(); +export { program }; + +// Only auto-run when this file is the entrypoint — when vitest (or any other +// harness) imports it for introspection we leave the program constructed but +// dormant. +function isCliEntrypoint(): boolean { + const entry = process.argv[1]; + if (!entry) return false; + try { + return import.meta.url === pathToFileURL(entry).href; + } catch { + return false; + } +} + +if (isCliEntrypoint()) { + main(); +} diff --git a/packages/cli/src/lazy-cold-start.test.ts b/packages/cli/src/lazy-cold-start.test.ts new file mode 100644 index 00000000..a838fc66 --- /dev/null +++ b/packages/cli/src/lazy-cold-start.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +describe('cold-start lazy-loading', () => { + it('does not import @ai-sdk/* modules until AI is actually used', async () => { + // Importing the CLI entry point loads every command module via static + // imports. With the lazy refactor those modules use `import type` for the + // AI SDK, so the underlying provider packages should not have been + // evaluated yet — `peekAiDeps()` should still report `null`. + await import('./index'); + const { peekAiDeps, _resetAiDepsForTests } = await import('./lib/ai-deps'); + _resetAiDepsForTests(); + expect(peekAiDeps()).toBeNull(); + }); +}); diff --git a/packages/cli/src/lib/ai-deps.test.ts b/packages/cli/src/lib/ai-deps.test.ts new file mode 100644 index 00000000..13ba5f4b --- /dev/null +++ b/packages/cli/src/lib/ai-deps.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { _resetAiDepsForTests, loadAiDeps, peekAiDeps } from './ai-deps'; + +describe('loadAiDeps', () => { + it('resolves all provider constructors lazily', async () => { + _resetAiDepsForTests(); + expect(peekAiDeps()).toBeNull(); + const deps = await loadAiDeps(); + expect(typeof deps.generateText).toBe('function'); + expect(typeof deps.streamText).toBe('function'); + expect(typeof deps.stepCountIs).toBe('function'); + expect(typeof deps.createGoogleGenerativeAI).toBe('function'); + expect(typeof deps.createAnthropic).toBe('function'); + expect(typeof deps.createGateway).toBe('function'); + expect(typeof deps.createAmazonBedrock).toBe('function'); + expect(typeof deps.createOpenAI).toBe('function'); + expect(typeof deps.createOpenAICompatible).toBe('function'); + expect(typeof deps.fromIni).toBe('function'); + expect(typeof deps.fromNodeProviderChain).toBe('function'); + }); + + it('returns the same instance on a second call (caches)', async () => { + _resetAiDepsForTests(); + const a = await loadAiDeps(); + const b = await loadAiDeps(); + expect(a).toBe(b); + }); + + it('peekAiDeps reflects the cache state', async () => { + _resetAiDepsForTests(); + expect(peekAiDeps()).toBeNull(); + await loadAiDeps(); + expect(peekAiDeps()).not.toBeNull(); + }); +}); diff --git a/packages/cli/src/lib/ai-deps.ts b/packages/cli/src/lib/ai-deps.ts new file mode 100644 index 00000000..b5a7b1c9 --- /dev/null +++ b/packages/cli/src/lib/ai-deps.ts @@ -0,0 +1,88 @@ +/** + * Lazy loader for the AI SDK constructors used across commands. + * + * The AI provider modules (`@ai-sdk/anthropic`, `@ai-sdk/google`, `ai`, + * `@aws-sdk/credential-providers`, …) each cost a measurable amount of + * cold-start to import — combined they add ~50ms even when the user runs an + * AI-free command like `dub log`. We defer the import to first use here so + * the read-only fast path stays cheap. + * + * Returned dependencies are cached for the lifetime of the process. Tests + * that need a different shape pass their own object instead of calling this + * helper. + */ + +import type { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import type { createAnthropic } from '@ai-sdk/anthropic'; +import type { createGoogleGenerativeAI } from '@ai-sdk/google'; +import type { createOpenAI } from '@ai-sdk/openai'; +import type { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import type { + fromIni, + fromNodeProviderChain, +} from '@aws-sdk/credential-providers'; +import type { createGateway, generateText, stepCountIs, streamText } from 'ai'; + +export interface AiSdkDeps { + generateText: typeof generateText; + streamText: typeof streamText; + stepCountIs: typeof stepCountIs; + createGoogleGenerativeAI: typeof createGoogleGenerativeAI; + createAnthropic: typeof createAnthropic; + createGateway: typeof createGateway; + createAmazonBedrock: typeof createAmazonBedrock; + createOpenAI: typeof createOpenAI; + createOpenAICompatible: typeof createOpenAICompatible; + fromIni: typeof fromIni; + fromNodeProviderChain: typeof fromNodeProviderChain; +} + +let cached: AiSdkDeps | null = null; +let cachedPromise: Promise | null = null; + +export async function loadAiDeps(): Promise { + if (cached) return cached; + if (cachedPromise) return cachedPromise; + cachedPromise = (async () => { + const [bedrock, anthropic, google, openai, openaiCompatible, awsCreds, ai] = + await Promise.all([ + import('@ai-sdk/amazon-bedrock'), + import('@ai-sdk/anthropic'), + import('@ai-sdk/google'), + import('@ai-sdk/openai'), + import('@ai-sdk/openai-compatible'), + import('@aws-sdk/credential-providers'), + import('ai'), + ]); + cached = { + generateText: ai.generateText, + streamText: ai.streamText, + stepCountIs: ai.stepCountIs, + createGoogleGenerativeAI: google.createGoogleGenerativeAI, + createAnthropic: anthropic.createAnthropic, + createGateway: ai.createGateway, + createAmazonBedrock: bedrock.createAmazonBedrock, + createOpenAI: openai.createOpenAI, + createOpenAICompatible: openaiCompatible.createOpenAICompatible, + fromIni: awsCreds.fromIni, + fromNodeProviderChain: awsCreds.fromNodeProviderChain, + }; + return cached; + })(); + return cachedPromise; +} + +/** + * Synchronous accessor for the cached deps. Returns `null` until the first + * `loadAiDeps()` resolves. Useful for tests asserting that no AI module is + * loaded yet on a cold path. + */ +export function peekAiDeps(): AiSdkDeps | null { + return cached; +} + +/** Reset internal caches (test-only). */ +export function _resetAiDepsForTests(): void { + cached = null; + cachedPromise = null; +} diff --git a/packages/cli/src/lib/ai-prompt-decision.test.ts b/packages/cli/src/lib/ai-prompt-decision.test.ts index 54210b1b..8c8a0321 100644 --- a/packages/cli/src/lib/ai-prompt-decision.test.ts +++ b/packages/cli/src/lib/ai-prompt-decision.test.ts @@ -13,6 +13,7 @@ const baseConfig: DubConfig = { reviewers: [], storageBackend: 'json', submitDefault: 'auto', + theme: 'auto', ai: { defaults: { createMetadata: false, diff --git a/packages/cli/src/lib/ai-prompt-decision.ts b/packages/cli/src/lib/ai-prompt-decision.ts index 925f04db..5e81444b 100644 --- a/packages/cli/src/lib/ai-prompt-decision.ts +++ b/packages/cli/src/lib/ai-prompt-decision.ts @@ -1,13 +1,8 @@ import { stdin as input, stdout as output } from 'node:process'; import * as readline from 'node:readline/promises'; -import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; -import { createGateway, streamText } from 'ai'; +import type { streamText } from 'ai'; import chalk from 'chalk'; +import { loadAiDeps } from './ai-deps'; import { buildAiProviderOptions, type ResolveAiProviderDeps, @@ -37,20 +32,18 @@ export interface AiPromptDecisionDeps extends ResolveAiProviderDeps { writePreview: (text: string) => void; } -const DEFAULT_DEPS: AiPromptDecisionDeps = { +// Lightweight defaults that need no AI SDK — used by callers that only +// inspect config (e.g. the program startup branch). +const DEFAULT_NON_AI_DEPS = { readConfig, - streamText, - createGoogleGenerativeAI, - createAnthropic, - createGateway, - createAmazonBedrock, - createOpenAI, - createOpenAICompatible, - fromIni, - fromNodeProviderChain, confirmRecommendation, writePreview: (text: string) => process.stdout.write(text), -}; +} as const; + +async function loadDefaultDeps(): Promise { + const ai = await loadAiDeps(); + return { ...DEFAULT_NON_AI_DEPS, ...ai }; +} export function aiPromptOptionsEnabled(config: DubConfig): boolean { return config.aiAssistantEnabled && config.ai.prompts.mode !== 'off'; @@ -58,7 +51,7 @@ export function aiPromptOptionsEnabled(config: DubConfig): boolean { export async function isAiPromptOptionEnabled( cwd: string, - deps: Pick = DEFAULT_DEPS, + deps: Pick = DEFAULT_NON_AI_DEPS, ): Promise { const config = await deps.readConfig(cwd); return aiPromptOptionsEnabled(config); @@ -73,7 +66,7 @@ export async function resolveAiPromptDecision(input: { fallbackPrompt: () => Promise; deps?: AiPromptDecisionDeps; }): Promise { - const deps = input.deps ?? DEFAULT_DEPS; + const deps = input.deps ?? (await loadDefaultDeps()); const config = await deps.readConfig(input.cwd); if (!aiPromptOptionsEnabled(config)) { return input.fallbackPrompt(); diff --git a/packages/cli/src/lib/config.test.ts b/packages/cli/src/lib/config.test.ts index c46494f5..c18a3e0b 100644 --- a/packages/cli/src/lib/config.test.ts +++ b/packages/cli/src/lib/config.test.ts @@ -27,6 +27,7 @@ describe('readConfig', () => { reviewers: [], storageBackend: 'json', submitDefault: 'auto', + theme: 'auto', ai: { defaults: { createMetadata: false, @@ -203,6 +204,23 @@ describe('writeConfig', () => { expect(config.reviewers).toEqual(['alice', '@org/team']); }); + it('persists theme settings', async () => { + await writeConfig({ theme: 'dark' }, dir); + expect((await readConfig(dir)).theme).toBe('dark'); + await writeConfig({ theme: 'none' }, dir); + expect((await readConfig(dir)).theme).toBe('none'); + }); + + it('normalizes invalid theme values back to auto', async () => { + const dubDir = path.join(dir, '.git', 'dubstack'); + fs.mkdirSync(dubDir, { recursive: true }); + fs.writeFileSync( + path.join(dubDir, 'config.json'), + JSON.stringify({ theme: 'sepia' }), + ); + expect((await readConfig(dir)).theme).toBe('auto'); + }); + it('persists submit lifecycle defaults', async () => { await writeConfig( { diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index ceb3ffef..6dd5e1ee 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -6,6 +6,7 @@ import { getDubDir } from './state'; export type McpMode = 'read-only' | 'interactive' | 'trusted'; export type StorageBackend = 'json' | 'sqlite'; export type SubmitDefault = 'auto' | 'draft' | 'publish'; +export type ThemeMode = 'auto' | 'dark' | 'light' | 'none'; export interface DubConfig { aiAssistantEnabled: boolean; @@ -13,6 +14,7 @@ export interface DubConfig { reviewers: string[]; storageBackend: StorageBackend; submitDefault: SubmitDefault; + theme: ThemeMode; ai: { defaults: { createMetadata: boolean; @@ -72,6 +74,7 @@ const DEFAULT_CONFIG: DubConfig = { reviewers: [], storageBackend: 'json', submitDefault: 'auto', + theme: 'auto', ai: { defaults: { createMetadata: false, @@ -164,6 +167,7 @@ function normalizeConfig(config: DeepPartial): DubConfig { reviewers: normalizeReviewers(config.reviewers), storageBackend: normalizeStorageBackend(config.storageBackend), submitDefault: normalizeSubmitDefault(config.submitDefault), + theme: normalizeTheme(config.theme), ai: { defaults: { createMetadata: @@ -296,6 +300,18 @@ function normalizeSubmitDefault(value: unknown): SubmitDefault { return DEFAULT_CONFIG.submitDefault; } +function normalizeTheme(value: unknown): ThemeMode { + if ( + value === 'auto' || + value === 'dark' || + value === 'light' || + value === 'none' + ) { + return value; + } + return DEFAULT_CONFIG.theme; +} + function normalizeAiProviderModel(value: unknown): string | null { if (typeof value !== 'string') return null; const model = value.trim(); diff --git a/packages/cli/src/lib/theme.test.ts b/packages/cli/src/lib/theme.test.ts new file mode 100644 index 00000000..4e67725d --- /dev/null +++ b/packages/cli/src/lib/theme.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { detectTerminalTheme, resolveTheme, themedChalk } from './theme'; + +describe('detectTerminalTheme', () => { + it('returns "dark" for COLORFGBG with a dark background slot', () => { + expect(detectTerminalTheme({ COLORFGBG: '15;0' })).toBe('dark'); + expect(detectTerminalTheme({ COLORFGBG: '7;8' })).toBe('dark'); + }); + + it('returns "light" for COLORFGBG with a light background slot', () => { + expect(detectTerminalTheme({ COLORFGBG: '0;15' })).toBe('light'); + expect(detectTerminalTheme({ COLORFGBG: '0;7' })).toBe('light'); + }); + + it('returns "unknown" when COLORFGBG is missing or malformed', () => { + expect(detectTerminalTheme({})).toBe('unknown'); + expect(detectTerminalTheme({ COLORFGBG: 'oops' })).toBe('unknown'); + expect(detectTerminalTheme({ COLORFGBG: '7' })).toBe('unknown'); + }); +}); + +describe('resolveTheme', () => { + it('--no-color always wins over the configured theme', () => { + expect(resolveTheme('dark', { noColor: true })).toBe('none'); + expect(resolveTheme('light', { noColor: true })).toBe('none'); + expect(resolveTheme('auto', { noColor: true })).toBe('none'); + }); + + it('returns "none" when configured as none', () => { + expect(resolveTheme('none')).toBe('none'); + }); + + it('passes through explicit dark/light', () => { + expect(resolveTheme('dark')).toBe('dark'); + expect(resolveTheme('light')).toBe('light'); + }); + + it('auto-detects from COLORFGBG', () => { + expect(resolveTheme('auto', { env: { COLORFGBG: '15;0' } })).toBe('dark'); + expect(resolveTheme('auto', { env: { COLORFGBG: '0;15' } })).toBe('light'); + }); + + it('falls back to dark when auto cannot detect', () => { + expect(resolveTheme('auto', { env: {} })).toBe('dark'); + }); +}); + +describe('themedChalk', () => { + it('returns a chalk instance with level 0 for "none"', () => { + const c = themedChalk('none'); + expect(c.level).toBe(0); + expect(c.red('hello')).toBe('hello'); + }); + + it('returns a Chalk instance for dark/light that respects the terminal level', () => { + // Under vitest stdout is not a TTY, so chalk's auto-detected level may be 0. + // We only assert the instance is wired up and matches the global chalk default. + const dark = themedChalk('dark'); + const light = themedChalk('light'); + expect(typeof dark.red).toBe('function'); + expect(typeof light.red).toBe('function'); + expect(dark.level).toBeGreaterThanOrEqual(0); + expect(light.level).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/packages/cli/src/lib/theme.ts b/packages/cli/src/lib/theme.ts new file mode 100644 index 00000000..aca8de72 --- /dev/null +++ b/packages/cli/src/lib/theme.ts @@ -0,0 +1,64 @@ +import chalk, { Chalk, type ChalkInstance } from 'chalk'; +import type { ThemeMode } from './config'; + +export type ResolvedTheme = 'dark' | 'light' | 'none'; + +export interface ResolveThemeOptions { + noColor?: boolean; + env?: NodeJS.ProcessEnv; +} + +/** + * Detect terminal background from `COLORFGBG` (e.g. "15;0" → bg=0 → dark). + * Returns 'unknown' when the env var is missing or unparseable. + */ +export function detectTerminalTheme( + env: NodeJS.ProcessEnv = process.env, +): 'dark' | 'light' | 'unknown' { + const colorFgBg = env.COLORFGBG; + if (!colorFgBg) return 'unknown'; + const parts = colorFgBg.split(';'); + if (parts.length < 2) return 'unknown'; + const bgRaw = parts[parts.length - 1]; + const bg = Number.parseInt(bgRaw, 10); + if (!Number.isFinite(bg)) return 'unknown'; + // Standard ANSI palette: 0-6 + 8 are dark; 7 + 9-15 are light. + if ((bg >= 0 && bg <= 6) || bg === 8) return 'dark'; + return 'light'; +} + +/** + * Resolve the user-facing theme. `none` disables colors entirely (chalk.level=0). + * `--no-color` always wins. `auto` consults COLORFGBG; falls back to 'dark'. + */ +export function resolveTheme( + configured: ThemeMode, + options: ResolveThemeOptions = {}, +): ResolvedTheme { + if (options.noColor) return 'none'; + if (configured === 'none') return 'none'; + if (configured === 'dark' || configured === 'light') return configured; + const detected = detectTerminalTheme(options.env); + return detected === 'unknown' ? 'dark' : detected; +} + +/** + * Apply the resolved theme globally by adjusting chalk's color level. + * Returns a themed Chalk instance whose `.level` matches the resolved theme so + * callers can rely on a single instance instead of mutating the global one. + */ +export function applyTheme(theme: ResolvedTheme): ChalkInstance { + if (theme === 'none') { + chalk.level = 0; + return new Chalk({ level: 0 }); + } + return chalk; +} + +/** + * Build a themed chalk instance without mutating the global `chalk`. Useful in + * tests and in code paths that want to coexist with other chalk consumers. + */ +export function themedChalk(theme: ResolvedTheme): ChalkInstance { + return theme === 'none' ? new Chalk({ level: 0 }) : new Chalk(); +} From 96ff4438f8cc5131f8ef19b4c4dccb40ec4e73f0 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Mon, 25 May 2026 14:17:19 -0700 Subject: [PATCH 2/3] fix(cli): defer AI loads, harden tests, correct guides - submit/squash/split/absorb: defer loadAiDeps() to the AI branch so non-AI runs keep the fast cold-start path. submit() now passes a `getDeps` thunk into the PR-body helpers. - index.ts: use realpathSync when comparing import.meta.url against process.argv[1] so the published `dub` bin works through pnpm/npm's symlinked node_modules/.bin/dub shim. - index.help.test.ts: replace the global process.stdout.write override with Commander's per-command configureOutput() to avoid cross-worker races. - lazy-cold-start.test.ts: reset the AI-deps cache BEFORE importing the entrypoint so the assertion is meaningful. - lib/theme.ts: document that dark/light/auto produce identical output today and only `none` is observably different; the API surface is reserved for a future palette swap. - migration-from-charcoal.mdx: use the real `gt` binary (both Charcoal forks ship as `gt`) and the right cache file names (.graphite_cache_persist). - migration-from-ghstack.mdx: drop the fabricated "Charles" sentence and the unverifiable `ghstack rage` mapping. - migration-from-sapling.mdx: drop the invented `sl debugdiff` mapping. - migration-from-spr.mdx: spr's reviewer key is `defaultReviewers` in .spr.yml, not `spr config.reviewers`; spr does not install a commit-msg hook so don't tell users to remove one. Refs DUB-69 --- .../docs/guides/migration-from-charcoal.mdx | 70 ++++++++++--------- .../docs/guides/migration-from-ghstack.mdx | 5 +- .../docs/guides/migration-from-sapling.mdx | 2 +- .../docs/guides/migration-from-spr.mdx | 8 +-- packages/cli/src/commands/absorb.ts | 10 ++- packages/cli/src/commands/split.ts | 3 +- packages/cli/src/commands/squash.ts | 2 +- packages/cli/src/commands/submit.ts | 20 ++++-- packages/cli/src/index.help.test.ts | 16 +++-- packages/cli/src/index.ts | 7 +- packages/cli/src/lazy-cold-start.test.ts | 7 +- packages/cli/src/lib/theme.ts | 18 ++++- 12 files changed, 101 insertions(+), 67 deletions(-) diff --git a/apps/docs/content/docs/guides/migration-from-charcoal.mdx b/apps/docs/content/docs/guides/migration-from-charcoal.mdx index b1cfd6ec..a02368f4 100644 --- a/apps/docs/content/docs/guides/migration-from-charcoal.mdx +++ b/apps/docs/content/docs/guides/migration-from-charcoal.mdx @@ -1,38 +1,42 @@ --- title: Migrating from Charcoal -description: Map Charcoal (`ch`) commands to DubStack equivalents and migrate an active stack in under 30 minutes. +description: Map Charcoal (`gt`) commands to DubStack equivalents and migrate an active stack in under 30 minutes. --- -Charcoal is the open-source descendant of Graphite that ships under the `ch` binary. If you have `ch` muscle memory, this guide is the fast path to DubStack. Charcoal shares Graphite's command surface, so most patterns map one-to-one. +[Charcoal](https://github.com/danerwilliams/charcoal) is the community-maintained fork of Graphite's CLI that ships under the same `gt` binary. If you have `gt` muscle memory, this guide is the fast path to DubStack — the mental model is identical, and most command names map one-to-one. + +If you are migrating from Graphite proper (the SaaS-backed `gt`), the [Graphite migration guide](/docs/guides/migration-from-graphite) covers the same surface plus the cloud-features wind-down. ## Command Mapping | Charcoal | DubStack | Notes | |---|---|---| -| `ch create` / `ch c` | `dub create` | Same flags: `-a`, `-m`, `--ai` | -| `ch modify` / `ch m` | `dub modify` / `dub m` | Amend HEAD and restack descendants | -| `ch submit` / `ch s` | `dub submit` / `dub ss` | Defaults to downstack; pass `--stack` for the full tree | -| `ch sync` | `dub sync` | `--all` covers every tracked stack | -| `ch checkout` / `ch co` | `dub checkout` / `dub co` | Interactive picker with no argument | -| `ch log` / `ch ls` | `dub log` / `dub ls` | `--stack`, `--all`, `--json` | -| `ch up` / `ch down` | `dub up` / `dub down` | Identical | -| `ch top` / `ch bottom` | `dub top` / `dub bottom` | Identical | -| `ch info` | `dub info` | `--json` available | -| `ch pr` | `dub pr` | Opens GitHub PR for the current branch | -| `ch restack` | `dub restack` | Run after `git rebase` to fix up children | -| `ch continue` | `dub continue` | `--ai` to resolve conflicts via LLM | -| `ch abort` | `dub abort` | Restack/rebase rollback | -| `ch track` / `ch trunk` | `dub track` / `dub trunk` | Multi-trunk supported | -| `ch untrack` | `dub untrack` | Keep the branch, drop metadata | -| `ch delete` | `dub delete` | `--upstack`/`--downstack`/`--force` | -| `ch absorb` | `dub absorb` | `--ai` for ambiguous fixups | -| `ch fold` | `dub fold` | `--squash` for single-commit collapse | -| `ch undo` | `dub undo` | 20-entry ring buffer + `dub redo` | +| `gt create` / `gt c` | `dub create` | Same flags: `-a`, `-m`, `--ai` | +| `gt modify` / `gt m` | `dub modify` / `dub m` | Amend HEAD and restack descendants | +| `gt submit` / `gt s` | `dub submit` / `dub ss` | Defaults to downstack; pass `--stack` for the full tree | +| `gt sync` | `dub sync` | `--all` covers every tracked stack | +| `gt checkout` / `gt co` | `dub checkout` / `dub co` | Interactive picker with no argument | +| `gt log` / `gt ls` | `dub log` / `dub ls` | `--stack`, `--all`, `--json` | +| `gt up` / `gt down` | `dub up` / `dub down` | Identical | +| `gt top` / `gt bottom` | `dub top` / `dub bottom` | Identical | +| `gt info` | `dub info` | `--json` available | +| `gt pr` | `dub pr` | Opens GitHub PR for the current branch | +| `gt restack` | `dub restack` | Run after `git rebase` to fix up children | +| `gt continue` | `dub continue` | `--ai` to resolve conflicts via LLM | +| `gt abort` | `dub abort` | Restack/rebase rollback | +| `gt track` / `gt trunk` | `dub track` / `dub trunk` | Multi-trunk supported | +| `gt untrack` | `dub untrack` | Keep the branch, drop metadata | +| `gt delete` | `dub delete` | `--upstack`/`--downstack`/`--force` | +| `gt absorb` | `dub absorb` | `--ai` for ambiguous fixups | +| `gt fold` | `dub fold` | `--squash` for single-commit collapse | +| `gt undo` | `dub undo` | 20-entry ring buffer + `dub redo` | + +Charcoal inherits Graphite's CLI surface, so anything in the [Graphite mapping](/docs/guides/migration-from-graphite#command-mapping) that is not listed above maps the same way to DubStack. ## Conceptual Differences - **Local-first by design.** DubStack stores everything in `.git/dubstack/`. No external service, no telemetry, no shared cache file. -- **State mirrored into git refs.** `refs/dubstack/*` lets you rebuild state on a fresh clone with `dub init --restore-from-refs`. Charcoal relies on `.charcoal_cache` and is sensitive to manual file deletion. +- **State mirrored into git refs.** `refs/dubstack/*` lets you rebuild state on a fresh clone with `dub init --restore-from-refs`. Charcoal relies on its own cache files and is sensitive to manual file deletion. - **Optional AI throughout.** `dub create --ai`, `dub submit --ai`, `dub flow`, `dub absorb --ai`. Configured per-repo with `dub config ai-provider` and friends; none of it is required to use the stacked-diff workflow. - **Safe merge order.** `dub merge-next` (alias `dub land`) refuses to merge a PR whose ancestors are open and retargets every child PR atomically. - **Multi-trunk support.** Register additional trunks with `dub trunk add` and `dub trunk set-default`. @@ -40,10 +44,10 @@ Charcoal is the open-source descendant of Graphite that ships under the `ch` bin ## Common Pitfalls -- **`ch submit` defaulted to the full stack; `dub submit` defaults to downstack.** Pass `--stack` explicitly in CI scripts. -- **Charcoal's `.charcoal_user_config` is not migrated.** Re-run `dub config ai-provider`, `dub config reviewers`, and `dub config submit-default`. -- **`ch downstack edit` has no direct equivalent.** Use `dub reorder` for interactive reordering or `dub move --before/--after` for one-shot moves. -- **`ch sync --pull` is implicit.** `dub sync` fetches the trunk by default; pass `--fresh` to force a full refetch. +- **`gt submit` defaulted to the full stack; `dub submit` defaults to downstack.** Pass `--stack` explicitly in CI scripts. +- **Charcoal's local config is not migrated.** Re-run `dub config ai-provider`, `dub config reviewers`, and `dub config submit-default`. +- **`gt downstack edit` has no direct equivalent.** Use `dub reorder` for interactive reordering or `dub move --before/--after` for one-shot moves. +- **`gt sync --pull` is implicit.** `dub sync` fetches the trunk by default; pass `--fresh` to force a full refetch. ## 30-Minute Migration Script @@ -80,15 +84,15 @@ dub restack # 8. Refresh PR descriptions with DubStack metadata dub submit --downstack --no-ai -# 9. Once stable, remove Charcoal and its caches -npm uninstall -g @stackedgit/charcoal -rm -rf .charcoal_cache .charcoal_repo_config .charcoal_user_config - -# 10. Shim the muscle memory: -# alias ch=dub +# 9. Once stable, remove Charcoal. (The exact uninstall depends on how you +# installed it — Homebrew tap, source build, or volta-managed npm bin.) +# Then remove the per-repo cache. Charcoal inherits Graphite's file names: +rm -rf .graphite_cache_persist .graphite_repo_config 2>/dev/null || true +# (If your Charcoal fork renamed these, check the fork's docs for its actual +# cache path before deleting.) ``` -If `dub log --all` differs from `ch log`, run `dub doctor` to diagnose. State is recoverable via `dub init --restore-from-refs`. +If `dub log --all` differs from `gt log`, run `dub doctor` to diagnose. State is recoverable via `dub init --restore-from-refs`. ## See Also diff --git a/apps/docs/content/docs/guides/migration-from-ghstack.mdx b/apps/docs/content/docs/guides/migration-from-ghstack.mdx index f12c4541..f819c844 100644 --- a/apps/docs/content/docs/guides/migration-from-ghstack.mdx +++ b/apps/docs/content/docs/guides/migration-from-ghstack.mdx @@ -14,9 +14,8 @@ This guide explains the model shift and walks through migrating an in-flight ghs | `ghstack` | `dub submit --stack` | Push everything in the stack and open/refresh PRs | | `ghstack land $URL` | `dub merge-next` / `dub land` | Land the next safe PR; retargets dependents | | `ghstack unlink` | `dub unlink ` | Detach a branch from its parent | -| `ghstack rage` | `dub doctor` | Diagnostic for misconfigured state | -| `ghstack checkout $URL` | `dub co ` | Charles will name the branch; pick it from the picker | -| `ghstack hash` (n/a) | `dub info` | Stack-aware branch metadata | +| `ghstack checkout $URL` | `dub co ` | ghstack materialises a local branch from the PR's synthesised remote refs; with DubStack the PR's branch already exists locally, so just `dub co` it | +| (no equivalent) | `dub info` | Stack-aware branch metadata is implicit in ghstack's commit-per-PR model | | Implicit rebase on push | `dub restack` | Explicit, undo-able restack | | Implicit PR description prefix | `dub submit --ai` or templated body | DubStack does not auto-prefix; configure via templates | diff --git a/apps/docs/content/docs/guides/migration-from-sapling.mdx b/apps/docs/content/docs/guides/migration-from-sapling.mdx index 7392985c..cf5c4bef 100644 --- a/apps/docs/content/docs/guides/migration-from-sapling.mdx +++ b/apps/docs/content/docs/guides/migration-from-sapling.mdx @@ -21,7 +21,7 @@ description: Map Sapling (`sl`) commands to DubStack and migrate a Sapling stack | `sl pull --rebase` | `dub sync` | Fetch trunks, restack tracked stacks | | `sl status` | `dub status` | DubStack also surfaces stack context | | `sl uncommit` | `git reset --soft HEAD^` | DubStack does not own this primitive; use git | -| `sl debugdiff` / `sl pr` | `dub pr` | Opens GitHub PR for current branch | +| `sl pr` | `dub pr` | Opens GitHub PR for current branch | | `sl land` / `sl pr land` | `dub merge-next` / `dub land` | Refuses to land out-of-order PRs | ## Conceptual Differences diff --git a/apps/docs/content/docs/guides/migration-from-spr.mdx b/apps/docs/content/docs/guides/migration-from-spr.mdx index d2977ee7..0604b91e 100644 --- a/apps/docs/content/docs/guides/migration-from-spr.mdx +++ b/apps/docs/content/docs/guides/migration-from-spr.mdx @@ -33,7 +33,7 @@ description: Move from spr (Stacked Pull Requests) to DubStack and recreate your - **`pr-XXXX` trailers stay in your history.** spr writes those identifiers as commit-message trailers. DubStack does not strip them; you can leave them alone or rewrite history with `git filter-repo --message-callback`. Either way DubStack ignores them. - **spr depends on a clean working tree.** DubStack tolerates dirty working trees for read-only commands but refuses to mutate state when a rebase is in progress; use `dub doctor` to diagnose. - **PR identifiers are different.** spr-managed PRs include the trailer; DubStack-managed PRs do not. If you keep both during the transition, do not let `spr update` retarget DubStack PRs — it will rewrite their commit messages. -- **Reviewers.** `spr config.reviewers` is not migrated. Run `dub config reviewers alice,@org/team`. +- **Reviewers.** spr's reviewer defaults live in `.spr.yml` (e.g. `defaultReviewers`) and are not migrated automatically. Run `dub config reviewers alice,@org/team`. ## 30-Minute Migration Script @@ -78,9 +78,9 @@ dub submit --stack --ai # Then uninstall spr: brew uninstall spr || cargo uninstall spr -# 10. Remove spr's pre-commit hook -git config --unset commit.template || true -rm -f .git/hooks/commit-msg # if it was an spr hook only +# 10. spr does not install git hooks of its own; the `pr-` trailers +# are written by `spr diff` itself. If you previously added a manual +# commit-msg hook, audit .git/hooks/commit-msg and remove it. ``` If your team uses spr only as a land tool, you can hold off on closing the spr PRs and use `dub` for local stack manipulation until the last spr PR merges. diff --git a/packages/cli/src/commands/absorb.ts b/packages/cli/src/commands/absorb.ts index 391a1278..0a603dcc 100644 --- a/packages/cli/src/commands/absorb.ts +++ b/packages/cli/src/commands/absorb.ts @@ -115,7 +115,6 @@ export async function absorb( options: AbsorbOptions = {}, depsArg?: AbsorbDependencies, ): Promise { - const deps = depsArg ?? (await defaultDeps()); if (options.ai && options.stack) { throw new DubError("'--ai' cannot be combined with '--stack'.", [ "Run 'dub absorb --ai' to resolve ambiguous WIP commits on the current branch.", @@ -167,7 +166,14 @@ export async function absorb( return runAutoMode(cwd, originalBranch, state, stack, options); } if (mode === 'ai') { - return runAiMode(cwd, originalBranch, state, stack, options, deps); + return runAiMode( + cwd, + originalBranch, + state, + stack, + options, + depsArg ?? (await defaultDeps()), + ); } return runStackMode(cwd, originalBranch, state, stack, options); } diff --git a/packages/cli/src/commands/split.ts b/packages/cli/src/commands/split.ts index bab4ff5c..6280a692 100644 --- a/packages/cli/src/commands/split.ts +++ b/packages/cli/src/commands/split.ts @@ -138,7 +138,6 @@ export async function split( options: SplitOptions, depsArg?: SplitDependencies, ): Promise { - const deps = depsArg ?? (await loadAiDeps()); if (!(await isWorkingTreeClean(cwd))) { throw new DubError('Working tree has uncommitted changes.', [ "Run 'git status' to see uncommitted changes.", @@ -232,7 +231,7 @@ export async function split( parentBranch, parentTip, sourceTip: sourceTipBefore, - deps, + deps: depsArg ?? (await loadAiDeps()), providerConfig: config.ai.provider, }); if (options.dryRun) { diff --git a/packages/cli/src/commands/squash.ts b/packages/cli/src/commands/squash.ts index b305289f..c273de85 100644 --- a/packages/cli/src/commands/squash.ts +++ b/packages/cli/src/commands/squash.ts @@ -80,7 +80,6 @@ export async function squash( options: SquashOptions = {}, depsArg?: SquashDependencies, ): Promise { - const deps = depsArg ?? (await loadAiDeps()); if (options.ai && options.message) { throw new DubError("'--ai' cannot be combined with '-m'.", [ "Drop '--ai' to use the message you supplied.", @@ -134,6 +133,7 @@ export async function squash( `Rerun 'dub squash -m ""' without '--ai'.`, ]); } + const deps = depsArg ?? (await loadAiDeps()); message = await generateAiSquashMessage( { branch, originalMessages }, deps, diff --git a/packages/cli/src/commands/submit.ts b/packages/cli/src/commands/submit.ts index d640c01c..80d3e76a 100644 --- a/packages/cli/src/commands/submit.ts +++ b/packages/cli/src/commands/submit.ts @@ -128,7 +128,13 @@ export async function submit( options: SubmitOptions = {}, depsArg?: SubmitDependencies, ): Promise { - const deps = depsArg ?? (await loadAiDeps()); + // Resolve AI deps lazily — non-AI submits (the common case) must not pay + // the @ai-sdk/* import cost. + let depsCache: SubmitDependencies | null = depsArg ?? null; + const getDeps = async (): Promise => { + if (!depsCache) depsCache = await loadAiDeps(); + return depsCache; + }; if (options.ai && options.noAi) { throw new DubError("'--ai' cannot be combined with '--no-ai'.", [ "Pass '--ai' alone to force AI-generated PR descriptions.", @@ -308,7 +314,7 @@ export async function submit( cwd, { useAi, - deps, + getDeps, summaryOverrides: options.summaryOverrides, prTemplate: templates?.prTemplate ?? null, providerConfig: config.ai.provider, @@ -401,7 +407,7 @@ export async function submit( cwd, { useAi, - deps, + getDeps, summaryOverrides: options.summaryOverrides, prTemplate: templates?.prTemplate ?? null, providerConfig: config.ai.provider, @@ -515,7 +521,7 @@ async function buildWebCreatePrBody( cwd: string, options: { useAi: boolean; - deps: SubmitDependencies; + getDeps: () => Promise; summaryOverrides?: Map; prTemplate: string | null; providerConfig: NonNullable< @@ -555,7 +561,7 @@ async function buildWebCreatePrBody( cwd, ), }, - options.deps, + await options.getDeps(), { prTemplate: options.prTemplate, }, @@ -986,7 +992,7 @@ async function updateAllPrBodies( cwd: string, options: { useAi: boolean; - deps: SubmitDependencies; + getDeps: () => Promise; summaryOverrides?: Map; prTemplate: string | null; providerConfig: NonNullable< @@ -1083,7 +1089,7 @@ async function updateAllPrBodies( cwd, ), }, - options.deps, + await options.getDeps(), { prTemplate: options.prTemplate, }, diff --git a/packages/cli/src/index.help.test.ts b/packages/cli/src/index.help.test.ts index 8e23f822..274f55a6 100644 --- a/packages/cli/src/index.help.test.ts +++ b/packages/cli/src/index.help.test.ts @@ -15,17 +15,19 @@ function walk( } function renderHelp(cmd: Command): string { + // Use Commander's per-command `configureOutput` to redirect help output into + // a local buffer. Avoids mutating `process.stdout.write` globally, which + // would race with other vitest workers running in parallel. let buffer = ''; - const originalWrite = process.stdout.write.bind(process.stdout); - process.stdout.write = ((chunk: string | Uint8Array) => { - buffer += - typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); - return true; - }) as typeof process.stdout.write; + const writer = (text: string) => { + buffer += text; + }; + const previous = cmd.configureOutput(); + cmd.configureOutput({ writeOut: writer, writeErr: writer }); try { cmd.outputHelp(); } finally { - process.stdout.write = originalWrite; + cmd.configureOutput(previous); } return buffer; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0e993aee..11479d60 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -18,6 +18,7 @@ * @packageDocumentation */ +import { realpathSync } from 'node:fs'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import chalk, { Chalk } from 'chalk'; @@ -4615,12 +4616,14 @@ export { program }; // Only auto-run when this file is the entrypoint — when vitest (or any other // harness) imports it for introspection we leave the program constructed but -// dormant. +// dormant. Compare via `realpathSync` so the published bin works: pnpm/npm +// installs `node_modules/.bin/dub` as a symlink to `dubstack/dist/index.js`, +// and `import.meta.url` always resolves through the symlink. function isCliEntrypoint(): boolean { const entry = process.argv[1]; if (!entry) return false; try { - return import.meta.url === pathToFileURL(entry).href; + return import.meta.url === pathToFileURL(realpathSync(entry)).href; } catch { return false; } diff --git a/packages/cli/src/lazy-cold-start.test.ts b/packages/cli/src/lazy-cold-start.test.ts index a838fc66..24a70f2f 100644 --- a/packages/cli/src/lazy-cold-start.test.ts +++ b/packages/cli/src/lazy-cold-start.test.ts @@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest'; describe('cold-start lazy-loading', () => { it('does not import @ai-sdk/* modules until AI is actually used', async () => { + // The cache must be cleared BEFORE importing the entrypoint — otherwise a + // post-import reset would mask a real lazy-load regression by clearing + // whatever the import populated and leaving `peekAiDeps()` null trivially. + const { peekAiDeps, _resetAiDepsForTests } = await import('./lib/ai-deps'); + _resetAiDepsForTests(); // Importing the CLI entry point loads every command module via static // imports. With the lazy refactor those modules use `import type` for the // AI SDK, so the underlying provider packages should not have been // evaluated yet — `peekAiDeps()` should still report `null`. await import('./index'); - const { peekAiDeps, _resetAiDepsForTests } = await import('./lib/ai-deps'); - _resetAiDepsForTests(); expect(peekAiDeps()).toBeNull(); }); }); diff --git a/packages/cli/src/lib/theme.ts b/packages/cli/src/lib/theme.ts index aca8de72..bf8c002b 100644 --- a/packages/cli/src/lib/theme.ts +++ b/packages/cli/src/lib/theme.ts @@ -43,9 +43,21 @@ export function resolveTheme( } /** - * Apply the resolved theme globally by adjusting chalk's color level. - * Returns a themed Chalk instance whose `.level` matches the resolved theme so - * callers can rely on a single instance instead of mutating the global one. + * Apply the resolved theme by mutating the process-wide `chalk.level` when the + * theme is `none`. The mutation is intentional — every CLI module imports the + * global `chalk` directly, so flipping the level once at program startup is the + * only practical way to disable colors across all of them without a refactor. + * + * `dark` and `light` currently leave chalk's auto-detected level untouched. + * The `ThemeMode` API accepts both for forward-compat: a future change can + * introduce a palette swap (e.g. `themeColorFor('dark', { dark, light })`) + * without breaking the on-disk config or the `dub config theme` UX. Until + * that lands, `dark`/`light`/`auto` produce identical output and only `none` + * has an observable effect — flagged here so reviewers know the limitation + * is recorded rather than overlooked. + * + * Tests must never call this function: it leaks across the worker. Use + * `themedChalk()` instead when you need a non-global Chalk instance. */ export function applyTheme(theme: ResolvedTheme): ChalkInstance { if (theme === 'none') { From 3ff5a5386400ce633f6322e074d342740483698f Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Mon, 25 May 2026 14:24:43 -0700 Subject: [PATCH 3/3] fix(cli): add See also help footers for completion and man The DUB-69 help-test invariant walks every command and asserts an Examples plus See also section. The new `dub completion` and `dub man` commands from main only had Examples, so the test failed after rebase. Add the missing See also footers. Refs DUB-69 --- packages/cli/src/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 11479d60..33dea5bd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -310,7 +310,10 @@ program Examples: $ dub completion bash >> ~/.bashrc $ dub completion zsh > "\${fpath[1]}/_dub" - $ dub completion fish > ~/.config/fish/completions/dub.fish`, + $ dub completion fish > ~/.config/fish/completions/dub.fish + +See also: + dub man, dub docs`, ) .action((shell: string) => { process.stdout.write(completion(program, shell)); @@ -324,7 +327,10 @@ program ` Examples: $ dub man > ~/.local/share/man/man1/dub.1 - $ mandb --user-db # then 'man dub' renders the page`, + $ mandb --user-db # then 'man dub' renders the page + +See also: + dub completion, dub docs, dub help`, ) .action(() => { process.stdout.write(man(program, { version }));