diff --git a/README.md b/README.md index a361d39d..2d7eec33 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ picked up automatically the first time you run an `rt` command inside it: ```bash cd ~/code/my-repo -rt status # or rt cd, rt branch switch, anything repo-aware +rt status # or rt cd, anything repo-aware ``` On first invocation rt will: @@ -110,7 +110,7 @@ On first invocation rt will: If the daemon was already running, the next refresh cycle picks it up (MR data refreshes every 5 min, port scans every ~30s). From then on `rt status`, -`rt runner`, ticket lookup, port scanning, and MR notifications all work from +`rt run`, ticket lookup, port scanning, and MR notifications all work from anywhere on your machine. ### Optional per-repo config @@ -118,7 +118,6 @@ anywhere on your machine. | Command | When you need it | |---|---| | `rt hooks` | Repo uses husky and you want a quick on/off toggle | -| `rt workspace sync` | Repo has a `.code-workspace` file you want synced across worktrees | | `rt settings extension` | Install the `rt-context` status-bar extension into local editors | ### Global settings that affect every repo @@ -127,8 +126,8 @@ Set these once; they apply to all repos: ```bash rt settings gitlab token # required for rt status, MR actions, notifications -rt settings linear token # required for ticket lookup in rt status / branch names -rt settings linear team # only needed if you use `rt branch create` to file new tickets +rt settings linear token # required for ticket lookup in rt status +rt settings linear team # Set default Linear team rt settings notifications # pick which events fire native macOS notifications ``` @@ -146,9 +145,10 @@ rt [subcommand] [args] ```bash rt cd # Fuzzy worktree/repo directory picker -rt code # Open a worktree in your preferred editor ``` +In `rt nav`, `ctrl-o` opens the selected folder in your preferred editor. + Shell alias added by install: ```bash rtcd # cd into a picked worktree (wraps rt cd) @@ -160,16 +160,6 @@ rtcd # cd into a picked worktree (wraps rt cd) rt run # Interactive script runner: repo → worktree → package → script ``` -### Branch - -```bash -rt branch switch # Checkout with automatic stash handling -rt branch create # Create from a Linear ticket or scratch -rt branch clean # Interactively delete stale branches -``` - -`rt branch switch` and `rt branch create` are also available as `rt git branch switch/create`. - ### Git ```bash @@ -200,23 +190,6 @@ rt port # Port scanner + killer (daemon-powered, zero-config) ``` -### Open - -```bash -rt open mr # Open the current branch's GitLab MR -rt open pipeline # Open GitLab CI pipelines (alias: rt open ci) -rt open repo # Open the repository page -rt open ticket # Open the Linear ticket for this branch -``` - -### Workspace - -```bash -rt workspace sync # Auto-sync a .code-workspace file across all worktrees -``` - -Keeps per-worktree settings (`peacock.color`, etc.) while syncing shared config. - ### Daemon The daemon runs in the background, caching MR data, scanning ports, and guarding git hooks. @@ -245,8 +218,6 @@ rt settings dev-mode # Toggle between local source and the installed bi ### Other ```bash -rt x # Script runner with setup/teardown lifecycle -rt build # Interactive turbo build selector rt hooks # Toggle git hooks on/off rt verify # Installation verification rt version # Print version + mode (dev/prod) @@ -373,7 +344,6 @@ From the menu you can restart the daemon, stop it, toggle launch-at-login, and c | macOS | Required (Apple Silicon or Intel) | | `fzf` | 0.71.0 or newer (`--listen` and `--id-nth`, used by `rt nav`'s live refresh); `brew install fzf` | | `tmux` | `brew install tmux` | -| `zellij` | Optional, only needed for `rt x --zellij` mode (`brew install zellij`) | | `chafa` | Optional, renders image previews in `rt nav` as colored character art (`brew install chafa`) | | `kitten` | Optional, upgrades `rt nav` image previews to true pixels on Kitty-protocol terminals such as Ghostty. Ships with Kitty (`brew install --cask kitty`) | diff --git a/cli.ts b/cli.ts index c59e4480..d839f7d3 100755 --- a/cli.ts +++ b/cli.ts @@ -9,9 +9,9 @@ * * Usage: * rt interactive menu - * rt branch switch direct subcommand - * rt branch subcommand picker - * rt build direct command + * rt daemon status direct subcommand + * rt daemon subcommand picker + * rt run direct command */ import { dispatch } from "./lib/command-tree.ts"; diff --git a/commands/__tests__/agent.test.ts b/commands/__tests__/agent.test.ts deleted file mode 100644 index debdc623..00000000 --- a/commands/__tests__/agent.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - resolveAgentTargetPath, - type AgentTargetDeps, -} from "../agent.ts"; -import type { KnownRepo, RepoIdentity } from "../../lib/repo.ts"; - -function identity(repoRoot: string): RepoIdentity { - return { - repoName: "repo", - repoRoot, - dataDir: "/tmp/.mattstack/rt/repo", - remoteUrl: "git@example.com:org/repo.git", - baseUrl: "https://example.com/org/repo", - }; -} - -function repo(worktrees: string[]): KnownRepo { - return { - repoName: "repo", - dataDir: "/tmp/.mattstack/rt/repo", - worktrees: worktrees.map((path, index) => ({ - path, - branch: index === 0 ? "main" : `feature-${index}`, - isBare: false, - })), - }; -} - -function deps(overrides: Partial): AgentTargetDeps { - return { - cwd: "/cwd", - repos: [], - identity: null, - repoRoot: null, - pickWorktreeWithSwitch: async () => { - throw new Error("pickWorktreeWithSwitch should not be called"); - }, - pickFromAllRepos: async () => { - throw new Error("pickFromAllRepos should not be called"); - }, - ...overrides, - }; -} - -describe("resolveAgentTargetPath", () => { - test("uses the current repo root without a picker when there are no linked worktrees", async () => { - const target = await resolveAgentTargetPath([], deps({ - identity: identity("/repo"), - repos: [repo(["/repo"])], - })); - - expect(target).toBe("/repo"); - }); - - test("uses the current git root without a picker when the repo has no rt identity", async () => { - const target = await resolveAgentTargetPath([], deps({ - identity: null, - repoRoot: "/local-repo", - repos: [], - })); - - expect(target).toBe("/local-repo"); - }); - - test("uses the current worktree without a picker when the repo has linked worktrees", async () => { - const target = await resolveAgentTargetPath([], deps({ - identity: identity("/repo-feature"), - repos: [repo(["/repo", "/repo-feature"])], - })); - - expect(target).toBe("/repo-feature"); - }); - - test("shows the all-repos picker when outside a repo", async () => { - let called = false; - const target = await resolveAgentTargetPath([], deps({ - repos: [repo(["/repo"])], - pickFromAllRepos: async () => { - called = true; - return "/repo"; - }, - })); - - expect(called).toBe(true); - expect(target).toBe("/repo"); - }); - - test("-p forces a picker inside a repo with one known worktree", async () => { - let called = false; - const target = await resolveAgentTargetPath(["-p"], deps({ - identity: identity("/repo"), - repos: [repo(["/repo"])], - pickFromAllRepos: async () => { - called = true; - return "/other"; - }, - })); - - expect(called).toBe(true); - expect(target).toBe("/other"); - }); - - test("--pick uses the current repo worktree picker when multiple worktrees exist", async () => { - let called = false; - const target = await resolveAgentTargetPath(["--pick"], deps({ - identity: identity("/repo-feature"), - repos: [repo(["/repo", "/repo-feature"])], - pickWorktreeWithSwitch: async () => { - called = true; - return "/repo"; - }, - })); - - expect(called).toBe(true); - expect(target).toBe("/repo"); - }); - - test("--here keeps the exact current directory", async () => { - const target = await resolveAgentTargetPath(["--here"], deps({ - cwd: "/repo-feature/packages/app", - identity: identity("/repo-feature"), - repos: [repo(["/repo", "/repo-feature"])], - })); - - expect(target).toBe("/repo-feature/packages/app"); - }); -}); diff --git a/commands/agent.ts b/commands/agent.ts deleted file mode 100644 index 4c3f82ec..00000000 --- a/commands/agent.ts +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bun - -/** - * rt agent — Launch a CLI coding agent in a worktree. - * - * Target selection: - * - Default: use the current git repo/worktree when already inside one. - * - --pick/-p: force the repo/worktree picker before launching. - * - --here/-h: use the exact current directory. - * Then pick an agent (always asks — no preference saved). - * - * Execs the agent CLI with cwd set to the selected path, inheriting stdio. - */ - -import { execSync, spawn } from "child_process"; -import { homedir } from "os"; -import { dim, green, red, reset } from "../lib/tui.ts"; -import { getRepoRoot, getRepoIdentity, getKnownRepos, type KnownRepo, type RepoIdentity } from "../lib/repo.ts"; -import { pickWorktreeWithSwitch, pickFromAllRepos, isSwitchRepo } from "../lib/pickers.ts"; - -// ─── Agent detection ───────────────────────────────────────────────────────── - -interface AgentOption { - command: string; - label: string; -} - -const KNOWN_AGENTS: AgentOption[] = [ - { command: "claude", label: "Claude Code" }, - { command: "cursor-agent", label: "Cursor CLI" }, - { command: "codex", label: "OpenAI Codex" }, - { command: "gemini", label: "Gemini CLI" }, - { command: "aider", label: "Aider" }, - { command: "goose", label: "Goose" }, - { command: "opencode", label: "OpenCode" }, - { command: "amp", label: "Amp" }, -]; - -function detectInstalledAgents(): AgentOption[] { - return KNOWN_AGENTS.filter((a) => { - try { - execSync(`command -v ${a.command}`, { stdio: "pipe" }); - return true; - } catch { - return false; - } - }); -} - -async function pickAgent(repoName: string): Promise { - const installed = detectInstalledAgents(); - if (installed.length === 0) { - console.log(`\n ${red}No supported agent CLI found.${reset}`); - console.log(` ${dim}Install one of: ${KNOWN_AGENTS.map(a => a.command).join(", ")}${reset}\n`); - process.exit(1); - } - - if (installed.length === 1) return installed[0]!.command; - - const { filterableSelect } = await import("../lib/rt-render.tsx"); - const picked = await filterableSelect({ - message: `Agent for ${repoName}`, - options: installed.map(a => ({ - value: a.command, - label: a.label, - hint: a.command, - })), - }); - if (!picked) process.exit(0); - return picked; -} - -// ─── Entry ─────────────────────────────────────────────────────────────────── - -export interface AgentTargetDeps { - cwd: string; - repos: KnownRepo[]; - identity: RepoIdentity | null; - repoRoot: string | null; - pickWorktreeWithSwitch: typeof pickWorktreeWithSwitch; - pickFromAllRepos: typeof pickFromAllRepos; -} - -export async function resolveAgentTargetPath( - args: string[], - deps?: Partial, -): Promise { - const here = args.includes("--here") || args.includes("-h"); - const pickMode = args.includes("--pick") || args.includes("-p"); - const cwd = deps?.cwd ?? process.cwd(); - - if (here) return cwd; - - const identity = deps && "identity" in deps ? deps.identity ?? null : getRepoIdentity(); - const repoRoot = deps && "repoRoot" in deps - ? deps.repoRoot ?? identity?.repoRoot ?? null - : identity?.repoRoot ?? getRepoRoot(); - const repos = deps?.repos ?? getKnownRepos(); - const currentRepo = identity - ? repos.find(r => r.repoName === identity.repoName) ?? null - : null; - - if (!pickMode && repoRoot) return repoRoot; - - if (pickMode && currentRepo && currentRepo.worktrees.length > 1) { - const result = await (deps?.pickWorktreeWithSwitch ?? pickWorktreeWithSwitch)(currentRepo, identity!.repoRoot); - return isSwitchRepo(result) - ? await (deps?.pickFromAllRepos ?? pickFromAllRepos)(repos) - : result; - } - - return (deps?.pickFromAllRepos ?? pickFromAllRepos)(repos); -} - -export async function launchAgent(args: string[]): Promise { - const selectedPath = await resolveAgentTargetPath(args); - - const freshRepos = getKnownRepos(); - const selectedRepo = freshRepos.find(r => - r.worktrees.some(wt => wt.path === selectedPath), - ); - const repoName = selectedRepo?.repoName || selectedPath.split("/").pop() || "unknown"; - - const agent = await pickAgent(repoName); - const agentLabel = KNOWN_AGENTS.find(a => a.command === agent)?.label || agent; - - console.log(`\n ${green}→${reset} ${agentLabel} in ${dim}${selectedPath.replace(homedir(), "~")}${reset}\n`); - - const child = spawn(agent, [], { - cwd: selectedPath, - stdio: "inherit", - env: process.env, - }); - - child.on("exit", (code) => { - process.exit(code ?? 0); - }); - child.on("error", (err) => { - console.log(`\n ${red}Failed to launch ${agentLabel}: ${err.message}${reset}\n`); - process.exit(1); - }); -} diff --git a/commands/branch-clean.ts b/commands/branch-clean.ts deleted file mode 100644 index 85c57ecf..00000000 --- a/commands/branch-clean.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * rt branch clean — Interactive stale branch cleanup. - * - * Sorts branches by last commit date, highlights merged/closed MRs, - * and lets you bulk-delete with safety checks. - * - * Safety: - * - Can't delete the current branch - * - Can't delete default branches (main, master, develop, etc.) - * - Warns before deleting branches with open MRs - * - --dry-run/-n previews without deleting - * - --force/-f skips the open-MR warning and force-deletes (-D) directly - */ - -import { execFileSync } from "child_process"; -import { bold, cyan, dim, green, yellow, red, reset, blue } from "../lib/tui.ts"; -import { - listAllBranches, - getWorktreeBranches, - getCurrentBranch, - type BranchInfo, -} from "../lib/git-ops.ts"; -import { daemonQuery } from "../lib/daemon-client.ts"; -import type { MRDashboardProps } from "../lib/enrich.ts"; -import type { CommandContext } from "../lib/command-tree.ts"; - -// ─── Constants ────────────────────────────────────────────────────────────── - -const DEFAULT_BRANCHES = new Set([ - "main", "master", "develop", "development", "staging", "production", "dev", -]); - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function timeAgo(epochSec: number): string { - const ms = Date.now() - epochSec * 1000; - const mins = Math.floor(ms / 60000); - if (mins < 60) return `${mins}m`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h`; - const days = Math.floor(hrs / 24); - if (days < 30) return `${days}d`; - const months = Math.floor(days / 30); - return `${months}mo`; -} - -function deleteBranch(cwd: string, branch: string, force: boolean): boolean { - const flag = force ? "-D" : "-d"; - try { - execFileSync("git", ["branch", flag, branch], { cwd, stdio: "pipe" }); - return true; - } catch { - return false; - } -} - -// ─── Entry ────────────────────────────────────────────────────────────────── - -export async function cleanBranches(args: string[], ctx: CommandContext): Promise { - const cwd = process.cwd(); - const forceDelete = args.includes("--force") || args.includes("-f"); - const dryRun = args.includes("--dry-run") || args.includes("-n"); - - const currentBranch = getCurrentBranch(cwd); - const worktreeBranches = getWorktreeBranches(cwd); - const allBranches = listAllBranches(cwd); - - // Get enrichment data from daemon cache - let enrichment: Record = {}; - const daemonResult = await daemonQuery("cache:read"); - if (daemonResult?.ok && daemonResult.data) { - enrichment = daemonResult.data; - } - - // Filter to cleanable local branches (not current, not default, not in worktree) - const candidates = allBranches.filter((b) => { - if (!b.isLocal) return false; - if (b.name === currentBranch) return false; - if (DEFAULT_BRANCHES.has(b.name)) return false; - if (worktreeBranches.has(b.name)) return false; - return true; - }); - - if (candidates.length === 0) { - console.log(`\n ${dim}no cleanable branches${reset}\n`); - return; - } - - // Sort by commit date: oldest first - candidates.sort((a, b) => a.commitEpoch - b.commitEpoch); - - // Build fzf options with staleness + MR status hints - const { filterableMultiselect } = await import("../lib/rt-render.tsx"); - - const options = candidates.map((b) => { - const age = timeAgo(b.commitEpoch); - const mr = enrichment[b.name]?.mr; - - let statusHint = ""; - if (mr) { - if (mr.state === "merged") statusHint = `${blue}merged${reset}`; - else if (mr.state === "closed") statusHint = `${dim}closed${reset}`; - else if (mr.state === "opened") statusHint = `${green}open MR${reset}`; - } - - const hint = [age, statusHint].filter(Boolean).join(" "); - - return { - value: b.name, - label: b.name, - hint, - }; - }); - - // Pre-select merged/closed branches (safe to delete) - const safeToDelete = candidates - .filter((b) => { - const mr = enrichment[b.name]?.mr; - return mr && (mr.state === "merged" || mr.state === "closed"); - }) - .map((b) => b.name); - - const selected = await filterableMultiselect({ - message: `Clean branches${dryRun ? " (dry run)" : ""}`, - options, - initialValues: safeToDelete.length > 0 ? safeToDelete : undefined, - }); - - if (!selected || selected.length === 0) { - console.log(`\n ${dim}nothing selected${reset}\n`); - return; - } - - // Check for open MRs in the selection - const withOpenMR = selected.filter((name) => { - const mr = enrichment[name]?.mr; - return mr && mr.state === "opened"; - }); - - if (withOpenMR.length > 0 && !forceDelete) { - const { confirm } = await import("../lib/rt-render.tsx"); - console.log(""); - for (const name of withOpenMR) { - console.log(` ${yellow}⚠${reset} ${name} has an open MR`); - } - const ok = await confirm({ - message: `Delete ${withOpenMR.length} branch${withOpenMR.length > 1 ? "es" : ""} with open MRs?`, - initialValue: false, - }); - if (!ok) { - console.log(`\n ${dim}cancelled${reset}\n`); - return; - } - } - - // Delete - console.log(""); - let deleted = 0; - let failed = 0; - - for (const name of selected) { - if (dryRun) { - console.log(` ${dim}would delete${reset} ${name}`); - deleted++; - continue; - } - - const success = deleteBranch(cwd, name, forceDelete); - if (success) { - console.log(` ${green}✓${reset} ${dim}deleted${reset} ${name}`); - deleted++; - } else { - // Try force delete - if (!forceDelete) { - const forceSuccess = deleteBranch(cwd, name, true); - if (forceSuccess) { - console.log(` ${yellow}✓${reset} ${dim}force-deleted${reset} ${name}`); - deleted++; - continue; - } - } - console.log(` ${red}✗${reset} ${dim}failed${reset} ${name}`); - failed++; - } - } - - console.log(""); - if (dryRun) { - console.log(` ${dim}dry run: ${deleted} branch${deleted !== 1 ? "es" : ""} would be deleted${reset}`); - console.log(` ${dim}re-run without --dry-run to delete${reset}`); - } else { - console.log(` ${green}✓${reset} ${deleted} deleted${failed > 0 ? ` ${red}${failed} failed${reset}` : ""}`); - } - console.log(""); -} diff --git a/commands/branch.ts b/commands/branch.ts deleted file mode 100644 index 88c60a00..00000000 --- a/commands/branch.ts +++ /dev/null @@ -1,631 +0,0 @@ -/** - * rt branch — GitHub Desktop-inspired branch management. - * - * Subcommands: - * switch — Switch branches with enriched picker, stash handling, stash restore - * create — Create branch from Linear ticket (existing or new) - * - * Team configuration has moved to `rt settings linear team`. - * Uses the rt daemon for instant cache reads when available. - */ - -import { execSync, execFileSync } from "child_process"; -import { green, yellow, red, reset, bold, dim, cyan } from "../lib/tui.ts"; -import { - loadSecrets, - getTeamConfig, - createIssue, - fetchMyTodoTickets, - searchTickets, - claimTicket, - extractLinearId, - fetchTicket, - type LinearTicket, -} from "../lib/linear.ts"; -import { - listAllBranches, - getWorktreeBranches, - getCurrentBranch, - hasUncommittedChanges, - stashChanges, - findDesktopStash, - popStash, - dropStash, - checkoutBranch, - createBranch, - fetchRemoteBranch, - getRemoteDefaultBranch, - type BranchInfo, -} from "../lib/git-ops.ts"; -import { daemonQuery } from "../lib/daemon-client.ts"; -import { resolveBranchName, loadBranchNamingConfig } from "../lib/branch-naming.ts"; -import { getRepoIdentity } from "../lib/repo.ts"; - -const DEFAULT_BRANCH_NAMES = new Set(["master", "main", "develop", "development", "staging", "production"]); - -// ─── Exported handlers (called by command tree dispatcher) ─────────────────── -// The dispatcher handles: screen clearing, breadcrumbs, requireIdentity, pickers - -// ─── Rename branch ──────────────────────────────────────────────────────────── - -export async function renameBranch(): Promise { - const cwd = process.cwd(); - const currentBranch = getCurrentBranch(cwd); - - if (!currentBranch) { - console.log(`\n ${yellow}not on a branch${reset}\n`); - return; - } - - // Try to resolve a template-based default from the current branch's ticket - let defaultName = currentBranch; - const linearId = extractLinearId(currentBranch); - if (linearId) { - const linearApiKey = loadSecrets().linearApiKey; - if (linearApiKey) { - const { withInlineSpinner } = await import("../lib/tui/inline-spinner.ts"); - - try { - const ticket = await withInlineSpinner( - `looking up ticket ${linearId}…`, - () => fetchTicket(linearApiKey, linearId), - ); - - if (ticket) { - const identity = getRepoIdentity(); - const namingConfig = identity ? loadBranchNamingConfig(identity.dataDir) : null; - - defaultName = await withInlineSpinner( - "generating branch name…", - () => resolveBranchName(ticket, namingConfig), - ); - } - } catch { - // Ticket fetch or template resolution failed — keep current name as default - } - } - } - - const { textInput } = await import("../lib/rt-render.tsx"); - - let newName: string; - try { - newName = await textInput({ - message: "Rename branch", - defaultValue: defaultName, - placeholder: "feature/new-name", - }); - } catch { - return; - } - - if (!newName.trim() || newName.trim() === currentBranch) return; - - try { - execFileSync("git", ["branch", "-m", newName.trim()], { cwd, stdio: "pipe" }); - console.log(`\n ${green}✓${reset} renamed ${dim}${currentBranch}${reset} → ${bold}${newName.trim()}${reset}\n`); - daemonQuery("cache:refresh").catch(() => {}); - } catch (err) { - console.log(`\n ${red}✗${reset} failed: ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -// ─── Switch branch ─────────────────────────────────────────────────────────── - -export async function switchBranch(): Promise { - const cwd = process.cwd(); - const currentBranch = getCurrentBranch(cwd); - - // 1. Get branches (excluding worktree-occupied ones) - const allBranches = listAllBranches(cwd); - const worktreeBranches = getWorktreeBranches(cwd); - - const branches = allBranches.filter( - (b) => b.name === currentBranch || !worktreeBranches.has(b.name), - ); - - if (branches.length === 0) { - console.log(`\n ${yellow}no branches found${reset}\n`); - return; - } - - // 2. Try to get enrichment data from daemon cache - // Request all entries — the cache is small (~20-30 entries), much cheaper - // than sending 3000+ branch names over the socket - let enrichmentData: Record = {}; - const daemonResult = await daemonQuery("cache:read"); - if (daemonResult?.ok && daemonResult.data) { - enrichmentData = daemonResult.data; - } - - // 3. Build fzf picker items - const { filterableSelect } = await import("../lib/rt-render.tsx"); - - // Group: current, local (hoisted defaults first), remote - const currentInfo = branches.find((b) => b.name === currentBranch); - const localBranches = branches.filter((b) => b.isLocal && b.name !== currentBranch); - const remoteBranches = branches.filter((b) => !b.isLocal && b.name !== currentBranch); - - // Hoist main/master to front of local - const hoisted = localBranches.filter((b) => DEFAULT_BRANCH_NAMES.has(b.name)); - const rest = localBranches.filter((b) => !DEFAULT_BRANCH_NAMES.has(b.name)); - const sortedLocal = [...hoisted, ...rest]; - - function formatBranchOption(b: BranchInfo, isCurrent = false): { value: string; label: string; hint: string } { - const enriched = enrichmentData[b.name]; - const parts: string[] = []; - - if (isCurrent) parts.push("(current)"); - - // Linear ticket info — title only (ID is already in the branch name) - if (enriched?.ticket) { - const title = enriched.ticket.title.length > 60 - ? enriched.ticket.title.slice(0, 59) + "…" - : enriched.ticket.title; - const status = enriched.ticket.stateName ? ` [${enriched.ticket.stateName}]` : ""; - parts.push(`${title}${status}`); - } - - // MR indicator - if (enriched?.mr?.webUrl) parts.push("MR"); - - const info = parts.join(" "); - - return { - value: b.name, - label: info ? `${b.name} ${cyan}${info}${reset}` : b.name, - hint: "", - }; - } - - const options: Array<{ value: string; label: string; hint: string }> = []; - - if (currentInfo) { - options.push(formatBranchOption(currentInfo, true)); - } - - for (const b of sortedLocal) { - options.push(formatBranchOption(b)); - } - - for (const b of remoteBranches) { - options.push(formatBranchOption(b)); - } - - const targetBranch = await filterableSelect({ - message: "Switch branch", - options, - exact: true, - }); - - if (!targetBranch || targetBranch === currentBranch) return; - - // 4. Check if working tree is dirty - const dirty = hasUncommittedChanges(cwd); - - if (dirty && currentBranch) { - const { select } = await import("../lib/rt-render.tsx"); - - const existingStash = findDesktopStash(cwd, currentBranch); - - console.clear(); - console.log(` ${bold}${cyan}rt branch switch${reset} → ${bold}${targetBranch}${reset}\n`); - - const action = await select({ - message: "You have uncommitted changes", - options: [ - { - value: "stash", - label: `Leave my changes on ${currentBranch}`, - hint: "stash and switch", - }, - { - value: "bring", - label: `Bring my changes to ${targetBranch}`, - hint: "carry uncommitted work", - }, - ], - }); - - if (action === "stash") { - // Warn before overwriting existing Desktop stash - if (existingStash) { - const { confirm } = await import("../lib/rt-render.tsx"); - const overwrite = await confirm({ - message: `Overwrite existing stash on '${currentBranch}'?`, - initialValue: true, - }); - if (!overwrite) return; - - try { - dropStash(cwd, existingStash.name); - } catch { /* continue anyway */ } - } - - try { - stashChanges(cwd, currentBranch); - console.log(` ${green}✓${reset} stashed changes on ${currentBranch}`); - } catch (err) { - console.log(` ${red}✗${reset} failed to stash: ${err instanceof Error ? err.message : String(err)}`); - return; - } - } - // "bring" — just fall through to checkout, let git carry the changes - } - - // 5. Checkout - const isRemoteOnly = !allBranches.find((b) => b.name === targetBranch)?.isLocal; - - try { - if (isRemoteOnly) { - // Fetch and create local tracking branch - const remoteName = "origin"; - fetchRemoteBranch(cwd, remoteName, targetBranch); - // checkout -b creates a local branch tracking the remote - execFileSync("git", ["checkout", "-b", targetBranch, `${remoteName}/${targetBranch}`], { - cwd, stdio: "pipe", - }); - } else { - checkoutBranch(cwd, targetBranch); - } - console.log(` ${green}✓${reset} switched to ${bold}${targetBranch}${reset}`); - } catch (err) { - console.log(` ${red}✗${reset} failed to checkout: ${err instanceof Error ? err.message : String(err)}`); - return; - } - - // 6. Check for stashed changes on target branch (non-blocking restore prompt) - const targetStash = findDesktopStash(cwd, targetBranch); - if (targetStash) { - const { confirm } = await import("../lib/rt-render.tsx"); - const restore = await confirm({ - message: `Restore stashed changes on '${targetBranch}'?`, - initialValue: true, - }); - - if (restore) { - try { - popStash(cwd, targetStash.name); - console.log(` ${green}✓${reset} restored stashed changes`); - } catch (err) { - console.log(` ${yellow}!${reset} failed to restore stash: ${err instanceof Error ? err.message : String(err)}`); - console.log(` ${dim}your stash is still saved${reset}`); - } - } - } - - // 7. Notify daemon to refresh - daemonQuery("cache:refresh").catch(() => {}); -} - -// ─── Create branch ─────────────────────────────────────────────────────────── - -export async function createBranchFlow(args: string[]): Promise { - // Direct mode: rt branch create [--from ] - if (args.length > 0 && !args[0]!.startsWith("-")) { - const branchName = args[0]!; - const fromIdx = args.indexOf("--from"); - const startPoint = fromIdx !== -1 ? args[fromIdx + 1] : undefined; - return createBranchDirect(branchName, startPoint); - } - - const secrets = loadSecrets(); - if (!secrets.linearApiKey) { - console.log(`\n ${yellow}Linear API key not configured${reset}`); - console.log(` ${dim}run: rt settings linear token${reset}\n`); - return; - } - - const { select } = await import("../lib/rt-render.tsx"); - const mode = await select({ - message: "Create branch", - options: [ - { value: "existing", label: "From existing Linear ticket", hint: "pick from your team's active tickets" }, - { value: "new", label: "Create new ticket + branch", hint: "create ticket on your team, then branch" }, - { value: "scratch", label: "From scratch", hint: "just enter a branch name" }, - ], - }); - - if (mode === "existing") await createFromExistingTicket(secrets.linearApiKey); - else if (mode === "new") await createNewTicketAndBranch(secrets.linearApiKey); - else if (mode === "scratch") await createFromScratch(); -} - -async function createFromExistingTicket(apiKey: string): Promise { - const teamConfig = getTeamConfig(); - if (!teamConfig) { - console.log(`\n ${yellow}Linear team not configured${reset}`); - console.log(` ${dim}run: rt settings linear team${reset}\n`); - return; - } - const { teamId } = teamConfig; - - const { runNavPicker } = await import("../lib/navigate.ts"); - - const REFRESH = "__refresh__"; - const SEARCH = "__search__"; - const SEARCH_AGAIN = "__search_again__"; - - const truncate = (s: string) => (s.length > 50 ? s.slice(0, 49) + "…" : s); - const ticketOption = (t: LinearTicket) => ({ - value: t.identifier, - label: `${t.identifier} ${cyan}${truncate(t.title)}${t.stateName ? ` [${t.stateName}]` : ""}${reset}`, - hint: "", - }); - - /** Run an async fetch behind an inline stderr spinner that erases itself. */ - async function withSpinner(label: string, fn: () => Promise, fallback: T): Promise { - const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠣", "⠏"]; - let fi = 0; - const timer = setInterval(() => { - process.stderr.write(`\r ${dim}${frames[fi++ % frames.length]} ${label}${reset}`); - }, 80); - try { - return await fn(); - } catch { - return fallback; - } finally { - clearInterval(timer); - process.stderr.write(`\r\x1b[K`); // erase spinner line - } - } - - const loadTickets = () => withSpinner("fetching your tickets…", () => fetchMyTodoTickets(apiKey, teamId), []); - - /** Create a branch from the picked ticket. Only claim (assign + In Progress) for your own tickets. */ - async function branchFromTicket(ticket: LinearTicket, opts: { claim: boolean }): Promise { - const identity = getRepoIdentity(); - const namingConfig = identity ? loadBranchNamingConfig(identity.dataDir) : null; - const branchName = await resolveBranchName(ticket, namingConfig); - - const finalName = await confirmBranchName(branchName); - if (!finalName) return; - await createWithBaseRef(finalName, ticket); - // Claim only after the branch actually exists — cancelling the name prompt - // must not reassign the ticket or move it to In Progress. - if (opts.claim) claimTicket(apiKey, ticket.id, teamId).catch(() => {/* best-effort */}); - } - - /** Live search across all of Linear; returns the picked ticket (any team/assignee/state) or null. */ - async function searchAndPick(seedTerm: string): Promise { - let term = seedTerm.trim(); - while (true) { - if (!term) { - const { textInput } = await import("../lib/rt-render.tsx"); - try { - term = (await textInput({ message: "Search Linear", placeholder: "ticket number or keywords" })).trim(); - } catch { - return null; // cancelled - } - if (!term) return null; - } - - const results = await withSpinner(`searching “${term}”…`, () => searchTickets(apiKey, term), [] as LinearTicket[]); - if (results.length === 0) { - console.log(` ${yellow}no tickets match “${term}”${reset}`); - term = ""; // re-prompt - continue; - } - - const res = await runNavPicker({ - message: `Results for “${term}”`, - options: [ - { value: SEARCH_AGAIN, label: `${dim}🔎 Search again…${reset}`, hint: "" }, - ...results.map(ticketOption), - ], - captureQueryOnNoMatch: true, - }); - - if (!res || res.key === "ctrl-up") return null; // ESC / back to the ticket list - if (!res.value) { - // Typed a fresh query in the results box that matched nothing → search it. - if (res.query.trim()) { term = res.query.trim(); continue; } - return null; - } - if (res.value === SEARCH_AGAIN) { term = ""; continue; } - - const picked = results.find((t) => t.identifier === res.value); - if (picked) return picked; - return null; - } - } - - let tickets = await loadTickets(); - - while (true) { - const res = await runNavPicker({ - message: tickets.length ? "Select a ticket" : "No tickets assigned to you — type to search", - options: [ - { value: SEARCH, label: `${dim}🔎 Search all tickets…${reset}`, hint: "branch off any ticket by number or keyword" }, - { value: REFRESH, label: `${dim}↻ Refresh${reset}`, hint: "" }, - ...tickets.map(ticketOption), - ], - captureQueryOnNoMatch: true, - }); - - if (!res || res.key === "ctrl-up") return; // ESC / back to the mode picker - - // Typed a query that matched none of your tickets → fall through to live search. - if (!res.value) { - const q = res.query.trim(); - if (!q) return; - const ticket = await searchAndPick(q); - if (ticket) { await branchFromTicket(ticket, { claim: false }); return; } - continue; - } - - if (res.value === REFRESH) { tickets = await loadTickets(); continue; } - - if (res.value === SEARCH) { - const ticket = await searchAndPick(res.query.trim()); - if (ticket) { await branchFromTicket(ticket, { claim: false }); return; } - continue; - } - - const ticket = tickets.find((t) => t.identifier === res.value); - if (!ticket) return; - await branchFromTicket(ticket, { claim: true }); - return; - } -} - -async function createNewTicketAndBranch(apiKey: string): Promise { - // Ensure team is configured - const teamConfig = getTeamConfig(); - if (!teamConfig) { - console.log(`\n ${yellow}no default team configured${reset}`); - console.log(` ${dim}run: rt settings linear team${reset}\n`); - return; - } - - const { textInput } = await import("../lib/rt-render.tsx"); - - let title: string; - try { - title = await textInput({ message: "Ticket title", placeholder: "What are you working on?" }); - } catch { return; } // user cancelled - - if (!title.trim()) { - console.log(` ${yellow}title is required${reset}`); - return; - } - - let description: string | undefined; - try { - description = await textInput({ message: "Description (optional)", placeholder: "Enter to skip" }); - } catch { /* skipped */ } - - console.log(`\n ${dim}creating ticket on ${teamConfig.teamKey}…${reset}`); - - try { - const ticket = await createIssue(apiKey, teamConfig.teamId, title.trim(), description?.trim()); - if (!ticket) { - console.log(` ${red}✗${reset} failed to create ticket\n`); - return; - } - - console.log(` ${green}✓${reset} created ${bold}${ticket.identifier}${reset}: ${ticket.title}`); - - const identity = getRepoIdentity(); - const namingConfig = identity ? loadBranchNamingConfig(identity.dataDir) : null; - const branchName = await resolveBranchName(ticket, namingConfig); - - const finalName = await confirmBranchName(branchName); - if (!finalName) return; - - await createWithBaseRef(finalName, ticket); - } catch (err) { - console.log(` ${red}✗${reset} ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -async function createFromScratch(): Promise { - const { textInput } = await import("../lib/rt-render.tsx"); - - let branchName: string; - try { - branchName = await textInput({ message: "Branch name", placeholder: "feature/my-branch" }); - } catch { return; } - - if (!branchName.trim()) return; - - const fromIdx = process.argv.indexOf("--from"); - const startPoint = fromIdx !== -1 ? process.argv[fromIdx + 1] : undefined; - - await createBranchDirect(branchName.trim(), startPoint); -} -// ─── Shared helpers ────────────────────────────────────────────────────────── - -async function confirmBranchName(defaultName: string): Promise { - const { textInput } = await import("../lib/rt-render.tsx"); - try { - const name = await textInput({ - message: "Branch name", - defaultValue: defaultName, - placeholder: "feature/my-branch", - }); - return name.trim() || null; - } catch { - return null; - } -} - -async function createWithBaseRef(branchName: string, ticket?: LinearTicket): Promise { - const cwd = process.cwd(); - - // Pick base ref - const currentBranch = getCurrentBranch(cwd); - const remoteDefault = getRemoteDefaultBranch(cwd); - - const { select } = await import("../lib/rt-render.tsx"); - - let startPoint: string | undefined; - - if (remoteDefault) { - const base = await select({ - message: "Base branch", - options: [ - { value: "remote", label: `Remote default (${remoteDefault})`, hint: "recommended" }, - { value: "current", label: `Current branch (${currentBranch ?? "HEAD"})`, hint: "" }, - ], - }); - - if (base === "remote") { - const remoteName = remoteDefault.split("/")[0]!; - const remoteBranch = remoteDefault.split("/").slice(1).join("/"); - - // Animated spinner — git fetch can take 3–8s on slow connections - const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠣", "⠏"]; - let fi = 0; - const spinnerTimer = setInterval(() => { - process.stderr.write(`\r ${dim}${frames[fi++ % frames.length]} fetching ${remoteDefault}…${reset}`); - }, 80); - - const fetchStart = Date.now(); - try { - fetchRemoteBranch(cwd, remoteName, remoteBranch); - clearInterval(spinnerTimer); - const ms = Date.now() - fetchStart; - process.stderr.write(`\r\x1b[K`); // erase spinner line - console.log(` ${green}✓${reset} fetched ${bold}${remoteDefault}${reset} ${dim}(${ms}ms)${reset}`); - } catch { - clearInterval(spinnerTimer); - process.stderr.write(`\r\x1b[K`); - console.log(` ${yellow}!${reset} fetch failed — branching from local ref`); - } - startPoint = remoteDefault; - } - } - - try { - createBranch(cwd, branchName, startPoint); - console.log(` ${green}✓${reset} created and checked out ${bold}${branchName}${reset}`); - if (ticket) { - console.log(` ${dim}${ticket.identifier}: ${ticket.title}${reset}`); - } - console.log(""); - - // Notify daemon — fire and forget, runner's FSEvents watcher will - // pick up the HEAD change independently and update the UI immediately - daemonQuery("cache:refresh").catch(() => {}); - } catch (err) { - console.log(` ${red}✗${reset} failed: ${err instanceof Error ? err.message : String(err)}\n`); - } -} - -async function createBranchDirect(branchName: string, startPoint?: string): Promise { - const cwd = process.cwd(); - try { - createBranch(cwd, branchName, startPoint); - console.log(`\n ${green}✓${reset} created and checked out ${bold}${branchName}${reset}`); - if (startPoint) { - console.log(` ${dim}from ${startPoint}${reset}`); - } - console.log(""); - daemonQuery("cache:refresh").catch(() => {}); - } catch (err) { - console.log(`\n ${red}✗${reset} failed: ${err instanceof Error ? err.message : String(err)}\n`); - } -} - - diff --git a/commands/build-select.ts b/commands/build-select.ts deleted file mode 100644 index 54cea7fd..00000000 --- a/commands/build-select.ts +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bun - -/** - * rt build — Interactive turbo build selector. - * - * Select packages to build interactively with multi-select - * and build history tracking. - * - * Adapted from matts-tools/build-select.ts, now powered by @inkjs/ui. - */ - -import { readFileSync, writeFileSync } from "fs"; -import { join } from "path"; -import { execSync } from "child_process"; -import { bold, cyan, dim, green, yellow, reset } from "../lib/tui.ts"; -import { getWorkspacePackages } from "../lib/repo.ts"; -import type { CommandContext } from "../lib/command-tree.ts"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -interface Package { - name: string; - path: string; -} - -interface HistoryEntry { - lastBuilt: number; - count: number; -} - -type History = Record; - -// ─── History ───────────────────────────────────────────────────────────────── - -function historyPath(dataDir: string): string { - return join(dataDir, "build-history.json"); -} - -function loadHistory(dataDir: string): History { - try { - return JSON.parse(readFileSync(historyPath(dataDir), "utf8")); - } catch { - return {}; - } -} - -function saveHistory(dataDir: string, selectedNames: string[]): void { - const history = loadHistory(dataDir); - const now = Date.now(); - for (const name of selectedNames) { - history[name] = { - lastBuilt: now, - count: (history[name]?.count ?? 0) + 1, - }; - } - writeFileSync(historyPath(dataDir), JSON.stringify(history, null, 2)); -} - -// ─── Package discovery ─────────────────────────────────────────────────────── - -function getPackages(root: string): Package[] { - return getWorkspacePackages(root) - .filter((p) => p.path.startsWith("packages/")) - .sort((a, b) => a.path.localeCompare(b.path)); -} - -function timeAgo(ts: number): string { - const diff = Date.now() - ts; - const mins = Math.floor(diff / 60000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - -// ─── Interactive selector ──────────────────────────────────────────────────── - -async function selectPackages(root: string, dataDir: string): Promise { - const packages = getPackages(root); - - if (packages.length === 0) { - console.log(`${yellow}no packages found${reset}`); - process.exit(1); - } - - const history = loadHistory(dataDir); - const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; - - // Sort: recent packages first, then alphabetical - const sortedPackages = [...packages].sort((a, b) => { - const aRecent = (history[a.name]?.lastBuilt ?? 0) > cutoff; - const bRecent = (history[b.name]?.lastBuilt ?? 0) > cutoff; - if (aRecent && !bRecent) return -1; - if (!aRecent && bRecent) return 1; - if (aRecent && bRecent) { - return (history[b.name]?.lastBuilt ?? 0) - (history[a.name]?.lastBuilt ?? 0); - } - return a.path.localeCompare(b.path); - }); - - const { filterableMultiselect } = await import("../lib/rt-render.tsx"); - - const options = sortedPackages.map((pkg) => { - const shortPath = pkg.path.split("/").slice(1).join("/") || pkg.path; - const entry = history[pkg.name]; - const isRecent = entry && entry.lastBuilt > cutoff; - const hint = isRecent - ? `${pkg.name} · ${timeAgo(entry!.lastBuilt)}` - : pkg.name; - return { - value: pkg.name, - label: shortPath, - hint, - }; - }); - - return filterableMultiselect({ - message: "Select packages to build", - options, - }); -} - -// ─── Entry ─────────────────────────────────────────────────────────────────── - -export async function buildSelect(args: string[], ctx: CommandContext): Promise { - const force = args.includes("--force"); - - const { repoRoot: root, dataDir } = ctx.identity!; - const selectedPackages = await selectPackages(root, dataDir); - - if (!selectedPackages || selectedPackages.length === 0) { - console.log(`\n ${yellow}nothing selected, exiting${reset}\n`); - process.exit(0); - } - - const filters = selectedPackages.map((p) => `--filter=${p}`).join(" "); - const forceFlag = force ? " --force" : ""; - const cmd = `pnpm turbo run build ${filters}${forceFlag}`; - - console.log(""); - console.log( - ` ${bold}${cyan}building ${selectedPackages.length} package${selectedPackages.length !== 1 ? "s" : ""}${force ? " (force)" : ""}...${reset}`, - ); - console.log(` ${dim}${cmd}${reset}`); - console.log(""); - - try { - execSync(cmd, { - cwd: root, - stdio: "inherit", - }); - } catch { - process.exit(1); - } - // Outside the try — a history-write failure must not turn a green build - // into exit 1. - try { saveHistory(dataDir, selectedPackages); } catch { /* best-effort */ } -} diff --git a/commands/code.ts b/commands/code.ts index 2ce9f94c..f475ac6b 100644 --- a/commands/code.ts +++ b/commands/code.ts @@ -1,13 +1,12 @@ #!/usr/bin/env bun /** - * rt code — Open a worktree in your preferred editor. + * Editor-preference and launch machinery shared with `rt nav`. * - * Two-step picker: - * 1. Pick a worktree (context-aware: worktrees if in a known repo, all repos otherwise) - * 2. Pick a workspace file if multiple exist (choice is saved for next time) - * - * Opens via editor CLI command (code, cursor, zed, etc.) + * Tracks a per-repo editor choice and per-directory workspace-file choice + * (~/.mattstack/rt/workspace-prefs.json), detects installed editors, and + * launches one via its CLI command (code, cursor, zed, etc.), falling back + * to the app bundle when the CLI shim is missing or broken. */ import { execSync } from "child_process"; @@ -15,11 +14,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from import { join } from "path"; import { homedir } from "os"; import { rtDir } from "../lib/rt-paths.ts"; -import { bold, cyan, dim, green, red, reset, yellow } from "../lib/tui.ts"; -import { - getRepoIdentity, getKnownRepos, updateRepoIndex, type KnownRepo, -} from "../lib/repo.ts"; -import { pickWorktreeWithSwitch, pickFromAllRepos, isSwitchRepo } from "../lib/pickers.ts"; +import { dim, green, red, reset } from "../lib/tui.ts"; // ─── Preference storage (~/.mattstack/rt/workspace-prefs.json) ───────────────────────── @@ -235,58 +230,6 @@ async function resolveWorkspaceTarget(dirPath: string, prefs: Prefs): Promise { - const { filterableSelect } = await import("../lib/rt-render.tsx"); - - const OPEN_THIS = "__open_this__"; - - const options = [ - { - value: OPEN_THIS, - label: `${identity.repoName} (this repo — will be tracked)`, - hint: identity.repoRoot.replace(process.env.HOME || "", "~"), - }, - ...repos.map(r => ({ - value: r.repoName, - label: r.repoName, - hint: r.worktrees.length > 1 - ? `${r.worktrees.length} worktrees` - : r.worktrees[0]?.path.replace(process.env.HOME || "", "~") || "", - })), - ]; - - const picked = await filterableSelect({ - message: "Pick a repo to open", - options, - }); - - if (!picked) { - process.exit(0); - } - - if (picked === OPEN_THIS) { - updateRepoIndex(identity.repoName, identity.repoRoot); - console.log(` Now tracking ${identity.repoName}`); - return identity.repoRoot; - } - - const selectedRepo = repos.find(r => r.repoName === picked)!; - - if (selectedRepo.worktrees.length === 1) { - return selectedRepo.worktrees[0]!.path; - } - - const { pickWorktreeFromRepo } = await import("../lib/repo.ts"); - const wtPath = await pickWorktreeFromRepo(selectedRepo, `${selectedRepo.repoName} worktrees`); - if (!wtPath) process.exit(0); // Esc on worktree picker - return wtPath; -} - // ─── Editor launch (with app-bundle fallback) ─────────────────────────────── function editorLabelFor(command: string): string { @@ -301,8 +244,8 @@ function editorLabelFor(command: string): string { * command to launch it. Returns null when no app-bundle fallback applies * (already an app-bundle launch, unknown editor, or app not installed). * - * This is what keeps `rt code` doing its one job — opening an IDE — even when - * the `cursor` on PATH is the cursor-agent shim, which is a dead end. + * This is what keeps editor launches landing on an actual IDE even when the + * `cursor` on PATH is the cursor-agent shim, which is a dead end. */ export function appBundleFallback(editorCommand: string): string | null { if (/^open\s+-a\s+/.test(editorCommand)) return null; // already an app launch @@ -357,56 +300,3 @@ export async function openDirectoryInEditor(dirPath: string): Promise { process.exit(1); } } - -// ─── Entry ─────────────────────────────────────────────────────────────────── - -export async function openInEditor(args: string[]): Promise { - - const pickMode = args.includes("-p") || args.includes("--pick"); - const prefs = loadPrefs(); - const repos = getKnownRepos(); - const identity = getRepoIdentity(); - const currentRepo = identity - ? repos.find(r => r.repoName === identity.repoName) ?? null - : null; - - let selectedPath: string; - - if (!pickMode && currentRepo) { - selectedPath = identity!.repoRoot; - } else if (pickMode && currentRepo && currentRepo.worktrees.length > 1) { - const result = await pickWorktreeWithSwitch(currentRepo, identity!.repoRoot); - selectedPath = isSwitchRepo(result) - ? await pickFromAllRepos(repos) - : result; - } else if (!currentRepo && identity) { - selectedPath = await pickWithCurrentUntracked(identity, repos); - } else { - selectedPath = await pickFromAllRepos(repos); - } - - // Derive repo name from selected path - const freshRepos = getKnownRepos(); - const selectedRepo = freshRepos.find(r => - r.worktrees.some(wt => wt.path === selectedPath), - ); - const repoName = selectedRepo?.repoName || selectedPath.split("/").pop() || "unknown"; - - const editor = await ensureEditor(prefs, repoName); - const editorLabel = editorLabelFor(editor); - - const target = await resolveWorkspaceTarget(selectedPath, prefs); - - const used = launchEditor(editor, target); - if (used) { - if (used !== editor) { prefs.editors[repoName] = used; savePrefs(prefs); } - const label = target.endsWith(".code-workspace") - ? target.split("/").pop() - : selectedPath.split("/").pop(); - console.log(`\n ${green}✓${reset} Opened ${label} in ${editorLabel}`); - } else { - console.log(`\n ${red}Failed to open ${editorLabel}. Is '${editor}' CLI installed?${reset}`); - console.log(` ${dim}You can reset your editor preference by deleting ~/.mattstack/rt/workspace-prefs.json${reset}\n`); - process.exit(1); - } -} diff --git a/commands/doppler.ts b/commands/doppler.ts deleted file mode 100644 index bba2b0dc..00000000 --- a/commands/doppler.ts +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env bun - -/** - * rt doppler — manage per-repo Doppler templates and sync them into - * `~/.doppler/.doppler.yaml`. - * - * Usage: - * rt doppler init → capture existing entries from ~/.doppler/.doppler.yaml - * into ~/.mattstack/rt/repos//doppler-template.yaml - * rt doppler sync → reconcile ~/.doppler/.doppler.yaml against the template - * + current worktrees - * rt doppler status → show: which template entries are present, missing, - * or overridden in ~/.doppler/.doppler.yaml - * rt doppler edit → open the template in $EDITOR - * - * See docs/superpowers/specs/2026-04-30-doppler-template-sync-design.md. - */ - -import { spawnSync } from "child_process"; -import { existsSync } from "fs"; -import { join } from "path"; -import { bold, cyan, dim, green, red, reset, yellow } from "../lib/tui.ts"; -import { - captureFromActualConfig, loadTemplate, saveTemplate, templatePath, -} from "../lib/doppler-template.ts"; -import { loadDopplerConfig } from "../lib/doppler-config.ts"; -import { listWorktreeRoots } from "../lib/git-worktrees.ts"; -import type { CommandContext } from "../lib/command-tree.ts"; - -// ─── rt doppler init ───────────────────────────────────────────────────────── - -export async function initCommand( - _args: string[], - ctx: CommandContext, -): Promise { - const repoName = ctx.identity!.repoName; - const repoRoot = ctx.identity!.repoRoot; - - const dopplerCfg = loadDopplerConfig(); - const captured = captureFromActualConfig(dopplerCfg, repoRoot); - - if (captured.length === 0) { - console.log(`\n ${yellow}no enclave entries found under${reset} ${dim}${repoRoot}${reset}`); - console.log(` ${dim}run \`make initDoppler\` (or your repo's equivalent) at least once first${reset}\n`); - process.exit(1); - } - - const path = templatePath(repoName); - if (existsSync(path)) { - const existing = loadTemplate(repoName) ?? []; - if (JSON.stringify(existing) === JSON.stringify(captured)) { - console.log(`\n ${dim}template already up to date (${captured.length} entries)${reset}`); - console.log(` ${dim}${path}${reset}\n`); - return; - } - console.log(`\n ${yellow}template exists at${reset} ${dim}${path}${reset}`); - console.log(` ${yellow}overwriting with ${captured.length} captured entries${reset}\n`); - } - - saveTemplate(repoName, captured); - - console.log(`\n ${green}✓${reset} captured ${bold}${captured.length}${reset} entries into ${dim}${path}${reset}`); - for (const e of captured) { - console.log(` ${cyan}${e.path}${reset} ${dim}→${reset} ${e.project}/${e.config}`); - } - console.log(`\n ${dim}run${reset} ${bold}rt doppler sync${reset} ${dim}to apply across all worktrees${reset}\n`); -} - -// ─── rt doppler sync ───────────────────────────────────────────────────────── - -/** - * Walk this repo's worktrees and apply the template to each. Identical logic - * to the daemon's per-tick reconciliation, surfaced as a CLI command for - * on-demand runs (e.g. just after `rt doppler init`, or when the daemon is - * down). - */ -export async function syncCommand( - _args: string[], - ctx: CommandContext, -): Promise { - const { reconcileForRepo } = await import("../lib/daemon/doppler-sync.ts"); - const repoName = ctx.identity!.repoName; - const repoRoot = ctx.identity!.repoRoot; - - if (!existsSync(templatePath(repoName))) { - console.log(`\n ${red}no template at${reset} ${dim}${templatePath(repoName)}${reset}`); - console.log(` ${dim}run${reset} ${bold}rt doppler init${reset} ${dim}first${reset}\n`); - process.exit(1); - } - - const worktreeRoots = listWorktreeRoots(repoRoot); - console.log(`\n ${bold}${cyan}rt doppler sync${reset} ${dim}(${worktreeRoots.length} worktrees)${reset}`); - for (const w of worktreeRoots) { - console.log(` ${dim}- ${w}${reset}`); - } - - const summary = await reconcileForRepo({ repoName, worktreeRoots }); - - if (summary.skipped === "malformed-template") { - console.log(`\n ${red}template is malformed — fix with rt doppler edit${reset}\n`); - process.exit(1); - } - if (summary.skipped === "no-template") { - console.log(`\n ${red}no template — run rt doppler init${reset}\n`); - process.exit(1); - } - - console.log(`\n ${green}✓${reset} wrote ${bold}${summary.wrote}${reset} entries`); - console.log(` ${dim}${summary.unchanged} unchanged, ${summary.overridden} overridden${reset}\n`); -} - -// ─── rt doppler status ─────────────────────────────────────────────────────── - -interface StatusRow { - path: string; - template: string; // "/" - actual: string | null; // null = missing - status: "ok" | "missing" | "overridden"; -} - -export async function statusCommand( - _args: string[], - ctx: CommandContext, -): Promise { - const repoName = ctx.identity!.repoName; - const repoRoot = ctx.identity!.repoRoot; - - const template = loadTemplate(repoName); - if (template === null || template.length === 0) { - console.log(`\n ${red}no template at${reset} ${dim}${templatePath(repoName)}${reset}`); - console.log(` ${dim}run${reset} ${bold}rt doppler init${reset}\n`); - process.exit(1); - } - - const dopplerCfg = loadDopplerConfig(); - const worktreeRoots = listWorktreeRoots(repoRoot); - - console.log(`\n ${bold}${cyan}rt doppler status${reset} ${dim}(${repoName})${reset}\n`); - - for (const root of worktreeRoots) { - const rows: StatusRow[] = []; - for (const entry of template) { - const absPath = join(root, entry.path); - const actual = dopplerCfg.scoped[absPath]; - const wantStr = `${entry.project}/${entry.config}`; - if (!actual) { - rows.push({ path: entry.path, template: wantStr, actual: null, status: "missing" }); - continue; - } - const actStr = `${actual["enclave.project"] ?? "?"}/${actual["enclave.config"] ?? "?"}`; - if ( - actual["enclave.project"] === entry.project && - actual["enclave.config"] === entry.config - ) { - rows.push({ path: entry.path, template: wantStr, actual: actStr, status: "ok" }); - } else { - rows.push({ path: entry.path, template: wantStr, actual: actStr, status: "overridden" }); - } - } - - const widest = Math.max(...rows.map(r => r.path.length)); - console.log(` ${bold}${root}${reset}`); - for (const row of rows) { - const icon = row.status === "ok" ? `${green}✓${reset}` - : row.status === "missing" ? `${red}✗${reset}` - : /* overridden */ `${yellow}~${reset}`; - const label = row.status === "ok" ? `${dim}${row.template}${reset}` - : row.status === "missing" ? `${red}missing${reset} ${dim}(want ${row.template})${reset}` - : /* overridden */ `${yellow}override${reset} ${dim}(want ${row.template}, got ${row.actual})${reset}`; - console.log(` ${icon} ${row.path.padEnd(widest)} ${label}`); - } - console.log(""); - } -} - -// ─── rt doppler edit ───────────────────────────────────────────────────────── - -export async function editCommand( - _args: string[], - ctx: CommandContext, -): Promise { - const repoName = ctx.identity!.repoName; - const path = templatePath(repoName); - - if (!existsSync(path)) { - console.log(`\n ${red}no template at${reset} ${dim}${path}${reset}`); - console.log(` ${dim}run${reset} ${bold}rt doppler init${reset} ${dim}first${reset}\n`); - process.exit(1); - } - - // EDITOR commonly carries flags (e.g. "code --wait") — split into argv. - const [editor = "vi", ...editorArgs] = (process.env.EDITOR || "vi").split(/\s+/); - const result = spawnSync(editor, [...editorArgs, path], { stdio: "inherit" }); - if (result.error) { - console.log(`\n ${yellow}could not launch editor "${editor}": ${result.error.message}${reset}\n`); - process.exit(1); - } - if (result.status !== 0) { - console.log(`\n ${yellow}editor exited with status ${result.status}${reset}\n`); - process.exit(result.status ?? 1); - } - - console.log(`\n ${dim}run${reset} ${bold}rt doppler sync${reset} ${dim}to apply${reset}\n`); -} diff --git a/commands/mr.ts b/commands/mr.ts deleted file mode 100644 index a9720f52..00000000 --- a/commands/mr.ts +++ /dev/null @@ -1,561 +0,0 @@ -/** - * rt mr open / rt pr open — create a GitLab MR on the current branch (thin glab wrapper). - * rt mr describe / rt pr describe — draft a description with an agent (streams to stdout). - * rt mr ship / rt pr ship — composite: push + describe + open. The all-in-one. - * - * Config lives at ~/.mattstack/rt/repos//mr.json. All three commands share the same - * helpers (`generateDescription`, `runGlabCreate`) so behavior is consistent - * whether a user chains atoms by hand or runs the composite. - */ - -import { execSync, spawnSync } from "child_process"; -import { readFileSync } from "fs"; -import { bold, cyan, dim, green, red, reset, yellow } from "../lib/tui.ts"; -import { getCurrentBranch, getRemoteDefaultBranch } from "../lib/git-ops.ts"; -import { - loadMRConfig, - readPromptFile, - resolveConfigPath, - type MRConfig, -} from "../lib/mr-config.ts"; -import { isGitLabRemote } from "../lib/enrich.ts"; -import { resolveAgentInvocation, runAgent } from "../lib/agent-runner.ts"; -import { pushCommand } from "./git/push.ts"; -import type { CommandContext } from "../lib/command-tree.ts"; - -// ─── Arg helpers ───────────────────────────────────────────────────────────── - -function argValue(args: string[], flag: string): string | undefined { - const i = args.indexOf(flag); - if (i === -1) return undefined; - return args[i + 1]; -} - -// ─── Platform / git helpers ────────────────────────────────────────────────── - -function isGitHub(remoteUrl: string | undefined): boolean { - return !!remoteUrl && /github\.com/i.test(remoteUrl); -} - -function remoteBranchExists(branch: string, cwd: string): boolean { - const r = spawnSync("git", ["rev-parse", "--verify", `origin/${branch}`], { - cwd, stdio: "pipe", - }); - return r.status === 0; -} - -function commitsAhead(targetRef: string, cwd: string): number { - // Two-dot: commits on HEAD that aren't on the target. Three-dot would count - // the symmetric difference, so upstream-only commits would read as "ahead". - const r = spawnSync("git", ["rev-list", "--count", `${targetRef}..HEAD`], { - cwd, stdio: "pipe", encoding: "utf8", - }); - if (r.status !== 0) return 0; - return parseInt((r.stdout ?? "").trim(), 10) || 0; -} - -function lastCommitSubject(cwd: string): string { - try { - return execSync("git log -1 --pretty=%s", { cwd, encoding: "utf8" }).trim(); - } catch { - return ""; - } -} - -function readDescriptionFile(path: string): string { - if (path === "-") return readFileSync(0, "utf8"); - return readFileSync(path, "utf8"); -} - -function extractMRUrl(text: string): string | null { - const match = text.match(/https?:\/\/[^\s]*\/-\/merge_requests\/\d+/); - return match ? match[0] : null; -} - -function resolveTarget(args: string[], config: MRConfig, cwd: string): string { - return argValue(args, "--target") - ?? config.target - ?? getRemoteDefaultBranch(cwd)?.replace(/^origin\//, "") - ?? "master"; -} - -function platformGate(remoteUrl: string | undefined): void { - if (isGitHub(remoteUrl)) { - console.error(`\n ${yellow}GitHub not supported yet — use ${bold}gh pr create${reset}${yellow} for now${reset}\n`); - process.exit(1); - } - if (!isGitLabRemote(remoteUrl)) { - console.error(`\n ${red}remote does not look like GitLab: ${dim}${remoteUrl}${reset}\n`); - process.exit(1); - } -} - -// ─── generateDescription — shared by describe and create ───────────────────── - -function gitCapture(args: string[], cwd: string): string { - const r = spawnSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); - return r.status === 0 ? (r.stdout ?? "") : ""; -} - -function truncate(text: string, maxBytes: number): string { - const buf = Buffer.from(text, "utf8"); - if (buf.length <= maxBytes) return text; - const head = buf.subarray(0, maxBytes).toString("utf8"); - const skipped = buf.length - maxBytes; - return `${head}\n\n[... ${Math.round(skipped / 1024)}KB of diff truncated ...]`; -} - -interface LoadedPrompt { source: string; body: string; } - -function loadPrompts(config: MRConfig, dataDir: string): { - loaded: LoadedPrompt[]; missing: string[]; -} { - const loaded: LoadedPrompt[] = []; - const missing: string[] = []; - for (const raw of config.prompts ?? []) { - const path = resolveConfigPath(raw, dataDir); - const body = readPromptFile(path); - if (body === null) missing.push(raw); - else loaded.push({ source: raw, body: body.trim() }); - } - return { loaded, missing }; -} - -function collectContextFiles(config: MRConfig, cwd: string): LoadedPrompt[] { - const include = config.context?.include ?? []; - const exclude = config.context?.exclude ?? []; - if (include.length === 0) return []; - - let files: string[] = []; - try { - const out = execSync( - `git ls-files -- ${include.map((g) => `'${g}'`).join(" ")}`, - { cwd, encoding: "utf8" }, - ); - files = out.split("\n").map((s) => s.trim()).filter(Boolean); - } catch { - files = []; - } - - if (exclude.length > 0) { - files = files.filter((f) => !exclude.some((g) => { - try { return new Bun.Glob(g).match(f); } catch { return false; } - })); - } - - const out: LoadedPrompt[] = []; - for (const f of files) { - try { - const body = readFileSync(`${cwd}/${f}`, "utf8"); - out.push({ source: f, body: body.trim() }); - } catch { /* skip */ } - } - return out; -} - -function captureGitSnapshot( - branch: string, target: string, cwd: string, maxDiffBytes: number, -) { - // Three-dot for diff (= diff since merge-base), two-dot for log (branch-only - // commits — three-dot log would include upstream commits in the prompt). - const ref = `origin/${target}...HEAD`; - const logRef = `origin/${target}..HEAD`; - return { - branch, target, - commits: gitCapture(["log", logRef, "--pretty=format:- %h %s", "-n", "20"], cwd).trim(), - changedFiles: gitCapture(["diff", "--name-only", ref], cwd).trim(), - diffStat: gitCapture(["diff", "--stat", ref], cwd).trim(), - diff: truncate(gitCapture(["diff", ref], cwd), maxDiffBytes), - }; -} - -/** - * Pull a `Title: ...` line off the top of agent output and return the title - * plus the body with that line (and any leading blank line) removed. - * Returns `{ title: undefined, body: raw }` when no title line is present. - */ -function splitTitleAndBody(raw: string): { title?: string; body: string } { - const match = raw.match(/^[ \t]*Title:[ \t]*(.+?)[ \t]*\r?\n\r?\n?/); - if (!match || !match[1]) return { body: raw }; - const title = match[1].trim(); - if (!title) return { body: raw }; - return { title, body: raw.slice(match[0].length) }; -} - -function assemblePrompt( - prompts: LoadedPrompt[], - contextFiles: LoadedPrompt[], - inline: string | undefined, - git: ReturnType, -): string { - const parts: string[] = []; - - if (prompts.length > 0) { - parts.push("# Style and template guidance\n"); - for (const p of prompts) parts.push(`\n${p.body}`); - } - - if (contextFiles.length > 0) { - parts.push("# Additional context files\n"); - for (const c of contextFiles) parts.push(`\n\`\`\`\n${c.body}\n\`\`\``); - } - - if (inline && inline.trim().length > 0) { - parts.push(`# Additional inline guidance\n\n${inline.trim()}`); - } - - parts.push( - `# Git state\n` - + `\nBranch: ${git.branch}\n` - + `Target: ${git.target}\n` - + `\n## Commits (HEAD vs origin/${git.target})\n\n${git.commits || "(none)"}\n` - + `\n## Changed files\n\n${git.changedFiles || "(none)"}\n` - + `\n## Diff stat\n\n\`\`\`\n${git.diffStat || "(none)"}\n\`\`\`\n` - + `\n## Diff\n\n\`\`\`diff\n${git.diff || "(none)"}\n\`\`\``, - ); - - parts.push( - `# Task\n\n` - + `Write a merge-request title and description for this branch, following ` - + `the style and template guidance above.\n\n` - + `Output format (strict):\n` - + `1. The FIRST line must be exactly: \`Title: \`\n` - + `2. Then a single blank line.\n` - + `3. Then ONLY the markdown body of the description — no preamble, no ` - + `explanation, no surrounding code fence.`, - ); - - return parts.join("\n\n"); -} - -interface GenerateOpts { - cwd: string; - dataDir: string; - branch: string; - target: string; - config: MRConfig; - extraInline?: string; - /** If true, don't call the agent — return the assembled prompt as `description`. */ - debug?: boolean; - /** Header label for the stderr banner (e.g. "rt mr describe" or "rt mr ship"). */ - label: string; -} - -/** - * Run the describe flow: gather context, assemble prompt, stream agent to stdout. - * Returns the captured description. Writes progress + errors to stderr. - * Calls process.exit(1) on fatal errors. - */ -async function generateDescription(opts: GenerateOpts): Promise { - const { cwd, dataDir, branch, target, config, extraInline, debug, label } = opts; - - const note = (msg: string) => process.stderr.write(` ${dim}${msg}${reset}\n`); - process.stderr.write(`\n ${bold}${cyan}${label}${reset} ${dim}(${branch} vs ${target})${reset}\n`); - - const { loaded: prompts, missing } = loadPrompts(config, dataDir); - for (const m of missing) { - process.stderr.write(` ${yellow}! prompt not found:${reset} ${m}\n`); - } - note(`prompts: ${prompts.length}${prompts.length > 0 ? ` (${prompts.map((p) => p.source).join(", ")})` : ""}`); - - const contextFiles = collectContextFiles(config, cwd); - note(`context: ${contextFiles.length}${contextFiles.length > 0 ? ` file${contextFiles.length === 1 ? "" : "s"}` : ""}`); - - const maxDiffKb = config.agent?.maxDiffKb ?? 80; - const git = captureGitSnapshot(branch, target, cwd, maxDiffKb * 1024); - const commitCount = git.commits ? git.commits.split("\n").length : 0; - const fileCount = git.changedFiles ? git.changedFiles.split("\n").length : 0; - note(`git: ${commitCount} commit${commitCount === 1 ? "" : "s"}, ${fileCount} file${fileCount === 1 ? "" : "s"} changed`); - - const inline = [config.inline, extraInline].filter(Boolean).join("\n\n"); - const fullPrompt = assemblePrompt(prompts, contextFiles, inline, git); - note(`prompt: ${Math.round(Buffer.byteLength(fullPrompt, "utf8") / 1024)}KB`); - - if (debug) { - process.stderr.write(` ${yellow}--debug — printing assembled prompt instead of calling agent${reset}\n\n`); - return fullPrompt; - } - - const { cli, args: cliArgs } = resolveAgentInvocation({ - cli: config.agent?.cli, - args: config.agent?.args, - }); - process.stderr.write(` ${dim}agent: ${cli} ${cliArgs.join(" ")}${reset}\n\n`); - - const result = await runAgent({ - cli, args: cliArgs, prompt: fullPrompt, cwd, stream: process.stdout, - }); - - if (!result.ok) { - process.stderr.write(`\n ${red}agent exited ${result.exitCode ?? "?"}${reset}\n`); - if (result.stderr.trim()) process.stderr.write(`${result.stderr.trim()}\n`); - process.exit(1); - } - - if (!result.stdout.endsWith("\n")) process.stdout.write("\n"); - process.stderr.write(`\n ${green}✓${reset} ${dim}description drafted${reset}\n`); - return result.stdout; -} - -// ─── runGlabCreate — shared by open and create ──────────────────────────────── - -interface GlabCreateOpts { - cwd: string; - branch: string; - target: string; - title: string; - draft: boolean; - config: MRConfig; - /** Description body. If undefined + useFill=false, creates MR with no body. */ - description?: string; - useFill?: boolean; - dryRun?: boolean; - label: string; -} - -/** - * Run `glab mr create` and return the parsed URL. Streams glab output via a - * step spinner. Calls process.exit(1) on glab failure. - */ -async function runGlabCreate(opts: GlabCreateOpts): Promise { - const { - cwd, branch, target, title, draft, config, description, useFill, dryRun, label, - } = opts; - - let descriptionArgs: string[] = []; - if (description !== undefined) { - descriptionArgs = ["--description", description]; - } else if (useFill) { - descriptionArgs = ["--fill"]; - } - - const glabArgs: string[] = [ - "mr", "create", - "--no-editor", "--yes", - "--title", title, - "--target-branch", target, - "--source-branch", branch, - ...descriptionArgs, - ]; - if (draft) glabArgs.push("--draft"); - if (config.removeSourceBranch) glabArgs.push("--remove-source-branch"); - if (config.squash) glabArgs.push("--squash-before-merge"); - - console.log(`\n ${bold}${cyan}${label}${reset} ${dim}(${branch} → ${target})${reset}`); - console.log(` ${dim}title:${reset} ${title}`); - console.log(` ${dim}target:${reset} ${target}${draft ? ` ${dim}(draft)${reset}` : ""}`); - // Redact long description bodies in the preview. - const previewArgs = glabArgs.map((a, i) => { - if (i > 0 && glabArgs[i - 1] === "--description" && a.length > 80) { - return `<${Math.round(Buffer.byteLength(a, "utf8") / 1024)}KB>`; - } - return a.includes(" ") ? `"${a}"` : a; - }); - console.log(` ${dim}glab ${previewArgs.join(" ")}${reset}\n`); - - if (dryRun) { - console.log(` ${yellow}--dry-run — not running${reset}\n`); - return null; - } - - const { createStepRunner } = await import("../lib/rt-render.tsx"); - const steps = createStepRunner(); - - let stdout = ""; - let stderr = ""; - try { - await steps.run("creating MR…", async () => { - const r = spawnSync("glab", glabArgs, { cwd, encoding: "utf8", stdio: "pipe" }); - stdout = r.stdout ?? ""; - stderr = r.stderr ?? ""; - if (r.status !== 0) { - const msg = (stderr || stdout).trim().split("\n").pop() || `glab exited ${r.status}`; - throw new Error(msg); - } - }, { done: "MR created" }); - } catch { - if (stderr.trim()) console.error(`\n${stderr.trim()}\n`); - process.exit(1); - } - - return extractMRUrl(stdout) ?? extractMRUrl(stderr); -} - -// ─── rt mr open ────────────────────────────────────────────────────────────── - -export async function openCommand( - args: string[], - ctx: CommandContext, -): Promise { - const cwd = ctx.identity!.repoRoot; - const dataDir = ctx.identity!.dataDir; - - platformGate(ctx.identity!.remoteUrl); - - const branch = getCurrentBranch(cwd); - if (!branch) { - console.error(`\n ${red}not on a branch (detached HEAD)${reset}\n`); - process.exit(1); - } - - const config = loadMRConfig(dataDir); - const target = resolveTarget(args, config, cwd); - - if (branch === target) { - console.error(`\n ${red}on target branch ${bold}${target}${reset}${red} — nothing to MR${reset}\n`); - process.exit(1); - } - - if (!remoteBranchExists(branch, cwd)) { - console.error(`\n ${yellow}${bold}${branch}${reset}${yellow} has not been pushed yet${reset}`); - console.error(` ${dim}run${reset} ${bold}rt git push${reset} ${dim}first${reset}\n`); - process.exit(1); - } - - if (commitsAhead(`origin/${target}`, cwd) === 0) { - console.error(`\n ${red}no commits between ${bold}origin/${target}${reset}${red} and ${bold}${branch}${reset}\n`); - process.exit(1); - } - - const title = argValue(args, "--title") ?? lastCommitSubject(cwd) ?? branch; - const draft = args.includes("--no-draft") ? false - : (args.includes("--draft") || (config.draft ?? false)); - - const descFileArg = argValue(args, "--description-file"); - const descInline = argValue(args, "--description"); - const useFill = args.includes("--fill"); - - let description: string | undefined; - if (descFileArg) description = readDescriptionFile(descFileArg); - else if (descInline !== undefined) description = descInline; - - const url = await runGlabCreate({ - cwd, branch, target, title, draft, config, - description, useFill, - dryRun: args.includes("--dry-run"), - label: "rt mr open", - }); - - if (url) { - console.log(`\n ${green}→${reset} ${url}\n`); - if (args.includes("--web")) { - spawnSync("glab", ["mr", "view", "--web", url], { cwd, stdio: "ignore" }); - } - } -} - -// ─── rt mr describe ────────────────────────────────────────────────────────── - -export async function describeCommand( - args: string[], - ctx: CommandContext, -): Promise { - const cwd = ctx.identity!.repoRoot; - const dataDir = ctx.identity!.dataDir; - - const branch = getCurrentBranch(cwd); - if (!branch) { - process.stderr.write(`\n ${red}not on a branch (detached HEAD)${reset}\n\n`); - process.exit(1); - } - - const config = loadMRConfig(dataDir); - const target = resolveTarget(args, config, cwd); - - if (branch === target) { - process.stderr.write(`\n ${red}on target branch ${bold}${target}${reset}${red} — nothing to describe${reset}\n\n`); - process.exit(1); - } - - const debug = args.includes("--debug"); - const description = await generateDescription({ - cwd, dataDir, branch, target, config, - extraInline: argValue(args, "--inline"), - debug, - label: "rt mr describe", - }); - - // In --debug we returned the prompt; print it to stdout so piping still works. - if (debug) { - process.stdout.write(description); - if (!description.endsWith("\n")) process.stdout.write("\n"); - } - process.stderr.write("\n"); -} - -// ─── rt mr ship (composite) ────────────────────────────────────────────────── - -export async function shipCommand( - args: string[], - ctx: CommandContext, -): Promise { - const cwd = ctx.identity!.repoRoot; - const dataDir = ctx.identity!.dataDir; - - platformGate(ctx.identity!.remoteUrl); - - const branch = getCurrentBranch(cwd); - if (!branch) { - console.error(`\n ${red}not on a branch (detached HEAD)${reset}\n`); - process.exit(1); - } - - const config = loadMRConfig(dataDir); - const target = resolveTarget(args, config, cwd); - - if (branch === target) { - console.error(`\n ${red}on target branch ${bold}${target}${reset}${red} — nothing to MR${reset}\n`); - process.exit(1); - } - - // Step 1: push. pushCommand exits the process on hard failure and returns - // false when the user cancels the diverged-branch prompt — stop there so we - // don't create an MR from the stale remote state the user just declined to - // update. --dry-run flows through (returns true) — the whole composite - // becomes a rehearsal. - const pushed = await pushCommand(args, ctx); - if (!pushed) return; - - if (commitsAhead(`origin/${target}`, cwd) === 0) { - console.error(`\n ${red}no commits between ${bold}origin/${target}${reset}${red} and ${bold}${branch}${reset}\n`); - process.exit(1); - } - - // Step 2: generate description (streams to stdout; we capture the text). - const debug = args.includes("--debug"); - const description = await generateDescription({ - cwd, dataDir, branch, target, config, - extraInline: argValue(args, "--inline"), - debug, - label: "rt mr describe", - }); - - if (debug) { - process.stdout.write(description); - if (!description.endsWith("\n")) process.stdout.write("\n"); - process.stderr.write(`\n ${yellow}--debug — stopping before MR creation${reset}\n\n`); - return; - } - - // Step 3: create MR with the drafted description. - // Prefer agent-emitted `Title: ...` line over the last commit subject so - // the style/template guidance can shape the title, not just the body. - const { title: agentTitle, body } = splitTitleAndBody(description); - const title = argValue(args, "--title") ?? agentTitle ?? lastCommitSubject(cwd) ?? branch; - const draft = args.includes("--no-draft") ? false - : (args.includes("--draft") || (config.draft ?? false)); - - const url = await runGlabCreate({ - cwd, branch, target, title, draft, config, - description: body.trimEnd(), - dryRun: args.includes("--dry-run"), - label: "rt mr open", - }); - - if (url) { - console.log(`\n ${green}→${reset} ${url}\n`); - if (args.includes("--web")) { - spawnSync("glab", ["mr", "view", "--web", url], { cwd, stdio: "ignore" }); - } - } -} diff --git a/commands/open.ts b/commands/open.ts deleted file mode 100644 index 32c57f29..00000000 --- a/commands/open.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * rt open — Open external pages for the current branch. - * - * rt open interactive picker - * rt open mr GitLab merge request - * rt open pipeline GitLab CI pipelines - * rt open repo GitLab/GitHub repo page - * rt open ticket Linear ticket (desktop app or web) - */ - -import { execSync } from "child_process"; -import { bold, cyan, dim, green, red, reset, yellow } from "../lib/tui.ts"; -import { extractLinearId } from "../lib/linear.ts"; -import { daemonQuery } from "../lib/daemon-client.ts"; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function getCurrentBranch(): string { - return execSync("git rev-parse --abbrev-ref HEAD", { - encoding: "utf8", - stdio: "pipe", - }).trim(); -} - -type Forge = "github" | "gitlab"; - -function getBaseUrl(): { baseUrl: string; repoName: string; forge: Forge } { - let remote: string; - try { - remote = execSync("git remote get-url origin", { - encoding: "utf8", - stdio: "pipe", - }).trim(); - } catch { - console.log(`\n ${yellow}this repo has no origin remote — nothing to open${reset}`); - console.log(` ${dim}add one with: git remote add origin ${reset}\n`); - process.exit(1); - } - - // SSH: git@gitlab.com:org/repo.git → https://gitlab.com/org/repo - const sshMatch = /^git@([^:]+):(.+?)(?:\.git)?$/.exec(remote); - if (sshMatch) { - const host = sshMatch[1]!; - const repoName = sshMatch[2]!.split("/").pop() || sshMatch[2]!; - const forge: Forge = host.includes("github") ? "github" : "gitlab"; - return { baseUrl: `https://${host}/${sshMatch[2]}`, repoName, forge }; - } - - // HTTPS: https://gitlab.com/org/repo.git → same - const httpsMatch = /^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/.exec(remote); - if (httpsMatch) { - const host = httpsMatch[1]!; - const repoName = httpsMatch[2]!.split("/").pop() || httpsMatch[2]!; - const forge: Forge = host.includes("github") ? "github" : "gitlab"; - return { baseUrl: `https://${host}/${httpsMatch[2]}`, repoName, forge }; - } - - throw new Error(`could not parse remote URL: ${remote}`); -} - -function openUrl(url: string): void { - console.log(` ${green}→${reset} ${dim}${url}${reset}\n`); - try { - execSync(`open "${url}"`, { stdio: "pipe" }); - } catch { - console.log(` ${yellow}could not open — copy the URL above${reset}`); - } -} - -// ─── Subcommands ───────────────────────────────────────────────────────────── - -export async function openMR(): Promise { - const branch = getCurrentBranch(); - const { repoName, forge } = getBaseUrl(); - - console.log(`\n ${dim}${repoName} · ${branch}${reset}`); - - const cmd = forge === "github" ? "gh pr view --web" : "glab mr view --web"; - try { - execSync(cmd, { stdio: "pipe" }); - } catch (err) { - // Don't blame every failure on a missing MR — a missing/unauthenticated - // CLI fails the same way, so surface what the tool actually said. - console.log(` ${yellow}no open ${forge === "github" ? "PR" : "MR"} found for this branch${reset}`); - const stderr = - err instanceof Error && "stderr" in err - ? String((err as Error & { stderr: unknown }).stderr).trim() - : ""; - if (stderr) console.log(` ${dim}${stderr.split("\n")[0]}${reset}`); - console.log(""); - } -} - -export async function openPipeline(): Promise { - const branch = getCurrentBranch(); - const { baseUrl, repoName, forge } = getBaseUrl(); - - const url = forge === "github" - ? `${baseUrl}/actions?query=branch%3A${encodeURIComponent(branch)}` - : `${baseUrl}/-/pipelines?ref=${encodeURIComponent(branch)}`; - - console.log(`\n ${dim}${repoName} · ${branch}${reset}`); - openUrl(url); -} - -export async function openRepo(): Promise { - const { baseUrl, repoName } = getBaseUrl(); - - console.log(`\n ${dim}${repoName}${reset}`); - openUrl(baseUrl); -} - -export async function openTicket(): Promise { - const branch = getCurrentBranch(); - - const linearId = extractLinearId(branch); - if (!linearId) { - console.log(`\n ${yellow}no Linear ticket ID found in branch: ${dim}${branch}${reset}\n`); - process.exit(1); - } - - // Try daemon cache for rich data - let url: string | null = null; - let title: string | null = null; - let stateName: string | null = null; - - const result = await daemonQuery("cache:read"); - if (result?.ok && result.data) { - const entry = result.data[branch]; - if (entry?.ticket?.url) { - url = entry.ticket.url; - title = entry.ticket.title; - stateName = entry.ticket.stateName; - } - } - - if (!url) { - url = `https://linear.app/issue/${linearId}`; - } - - console.log(`\n ${bold}${cyan}${linearId}${reset}${title ? ` ${title}` : ""}${stateName ? ` ${dim}[${stateName}]${reset}` : ""}`); - openUrl(url); -} diff --git a/commands/settings.ts b/commands/settings.ts index da11945e..abe7380b 100644 --- a/commands/settings.ts +++ b/commands/settings.ts @@ -21,7 +21,6 @@ import { loadSecrets, saveSecret, fetchTeams, - getTeamConfig, saveTeamConfig, } from "../lib/linear.ts"; import { diff --git a/commands/workspace.ts b/commands/workspace.ts deleted file mode 100644 index 9532fd52..00000000 --- a/commands/workspace.ts +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env bun - -/** - * rt workspace sync — Auto-sync .code-workspace files across worktrees. - * - * First run: - * - Scans all worktrees for .code-workspace files - * - Picks the most recently modified as the initial source - * - Shows what will happen, asks for Enter to confirm - * - Syncs to all other worktrees (preserving peacock colors) - * - Registers a daemon watcher for automatic future syncs - * - * Subsequent runs: - * - Triggers an immediate sync + shows status - * - * Flags: - * --status show current sync config and watcher state - * --off disable syncing and remove file watcher - */ - -import { existsSync, readdirSync, statSync, readFileSync } from "fs"; -import { join, basename, dirname } from "path"; -import { execSync } from "child_process"; -import { bold, cyan, dim, green, red, reset, yellow } from "../lib/tui.ts"; -import { getRepoIdentity, requireIdentity } from "../lib/repo.ts"; -import { daemonQuery } from "../lib/daemon-client.ts"; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function getWorktreePaths(repoPath: string): string[] { - try { - const output = execSync("git worktree list --porcelain", { - cwd: repoPath, - encoding: "utf8", - stdio: "pipe", - }); - return output - .split("\n") - .filter(l => l.startsWith("worktree ")) - .map(l => l.replace("worktree ", "").trim()); - } catch { - return [repoPath]; - } -} - -function parseJsonc(text: string): any { - const stripped = text - .replace(/\/\/.*$/gm, "") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/,\s*([\]}])/g, "$1"); - return JSON.parse(stripped); -} - -function getPeacockColor(filePath: string): string | null { - try { - const content = parseJsonc(readFileSync(filePath, "utf8")); - return content?.settings?.["peacock.color"] || null; - } catch { - return null; - } -} - -function colorDot(hex: string | null): string { - if (!hex) return `${dim}●${reset}`; - // Parse hex to ANSI 24-bit color - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `\x1b[38;2;${r};${g};${b}m●${reset}`; -} - -function timeAgo(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - -// ─── Export: workspaceSyncCommand ──────────────────────────────────────────── - -export async function workspaceSyncCommand(): Promise { - const identity = await requireIdentity("rt workspace sync"); - const { repoName, repoRoot } = identity; - const flags = process.argv.slice(2).filter(a => a.startsWith("--")); - - // ── --off: disable ────────────────────────────────────────────────────── - if (flags.includes("--off")) { - const result = await daemonQuery("workspace:sync:stop", { repo: repoName }); - if (result?.ok) { - console.log(`\n ${green}✓${reset} Stopped watching workspace file for ${bold}${repoName}${reset}\n`); - } else { - console.log(`\n ${red}✗${reset} Failed to stop: ${result?.error || "daemon not available"}\n`); - } - return; - } - - // ── --status: show state ────────────────────────────────────────────────── - if (flags.includes("--status")) { - const result = await daemonQuery("workspace:sync:status", { repo: repoName }); - if (!result?.ok || !result.data?.config) { - console.log(`\n ${dim}No workspace sync configured for ${repoName}${reset}\n`); - return; - } - - const { config, active, watcherCount } = result.data; - const worktrees = getWorktreePaths(repoRoot); - - console.log(` File: ${bold}${config.fileName}${reset}`); - console.log(` Repo: ${repoName}`); - console.log(` Watcher: ${active ? `${green}active${reset} (${watcherCount} worktrees)` : `${red}stopped${reset}`}`); - if (config.lastSyncAt) { - console.log(` Last: ${timeAgo(config.lastSyncAt)} from ${basename(config.lastSyncSource || "unknown")}`); - } - - console.log(`\n Worktrees:`); - const cwd = process.cwd(); - for (const wt of worktrees) { - const filePath = join(wt, config.fileName); - const color = getPeacockColor(filePath); - const isHere = cwd === wt || cwd.startsWith(wt + "/"); - console.log(` ${basename(wt).padEnd(20)} ${colorDot(color)} ${color || dim + "no color" + reset}${isHere ? ` ${dim}(you are here)${reset}` : ""}`); - } - console.log(); - return; - } - - // ── Main: init or re-sync ───────────────────────────────────────────────── - - // Check if already configured - const existing = await daemonQuery("workspace:sync:status", { repo: repoName }); - const isConfigured = existing?.ok && existing.data?.config?.enabled; - - const worktrees = getWorktreePaths(repoRoot); - - if (isConfigured) { - // Already configured — just trigger a sync - const config = existing!.data!.config; - console.log(` Syncing ${bold}${config.fileName}${reset}...`); - - const result = await daemonQuery("workspace:sync:trigger", { repo: repoName }); - if (result?.ok) { - const { synced, results } = result.data || { synced: 0, results: [] }; - console.log(` ${green}✓${reset} ${synced} worktree(s) synced (peacock preserved)\n`); - for (const r of results) { - // r.path is the workspace *file* path — the worktree is its dirname. - console.log(` ${basename(dirname(r.path)).padEnd(20)} ${colorDot(r.color)} ${r.color || ""}`); - } - } else { - console.log(` ${red}✗${reset} Sync failed: ${result?.error || "daemon not available"}`); - } - console.log(); - return; - } - - // ── First-time init ────────────────────────────────────────────────────── - // Find all workspace files across all worktrees, pick the most recent - interface Candidate { - filePath: string; - worktree: string; - fileName: string; - mtime: Date; - } - - let latest: Candidate | null = null; - const allFiles = new Map(); // fileName → candidates - - for (const wt of worktrees) { - try { - const files = readdirSync(wt).filter(f => f.endsWith(".code-workspace")); - for (const f of files) { - const filePath = join(wt, f); - try { - const stat = statSync(filePath); - const candidate: Candidate = { filePath, worktree: wt, fileName: f, mtime: stat.mtime }; - - if (!allFiles.has(f)) allFiles.set(f, []); - allFiles.get(f)!.push(candidate); - - if (!latest || stat.mtime > latest.mtime) { - latest = candidate; - } - } catch { /* stat failed */ } - } - } catch { /* readdir failed */ } - } - - if (!latest) { - console.log(` ${red}No .code-workspace files found${reset} in any worktree.\n`); - return; - } - - // Show what we found - const candidates = allFiles.get(latest.fileName) || []; - const otherWorktrees = worktrees.filter(wt => wt !== latest!.worktree); - - console.log(` Most recent: ${bold}${latest.fileName}${reset}`); - console.log(` Source: ${bold}${basename(latest.worktree)}${reset} ${dim}(modified ${timeAgo(latest.mtime.toISOString())})${reset}\n`); - - console.log(` Will sync to:`); - for (const wt of otherWorktrees) { - const filePath = join(wt, latest.fileName); - const color = existsSync(filePath) ? getPeacockColor(filePath) : null; - const exists = existsSync(filePath); - console.log(` ${basename(wt).padEnd(20)} ${colorDot(color)} ${color || ""}${!exists ? ` ${dim}(will create)${reset}` : ""}`); - } - - console.log(`\n ${dim}Peacock colors will be preserved in each worktree.${reset}`); - console.log(` ${dim}Future edits in any worktree will auto-sync to all others.${reset}\n`); - - // Wait for Enter - if (process.stdin.isTTY) { - process.stdout.write(` Press ${bold}Enter${reset} to sync, ${bold}Ctrl+C${reset} to cancel: `); - await new Promise((resolve) => { - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.once("data", (data) => { - process.stdin.setRawMode(false); - process.stdin.pause(); - const char = data[0]; - if (char === 3 || char === 27) { - // Ctrl+C or Escape - console.log(`\n\n ${dim}Cancelled. Make your edits and run again.${reset}\n`); - process.exit(0); - } - console.log(); // newline after Enter - resolve(); - }); - }); - } - - // Do the initial sync + register with daemon - const result = await daemonQuery("workspace:sync:start", { - repo: repoName, - repoPath: repoRoot, - fileName: latest.fileName, - sourcePath: latest.filePath, - }); - - if (result?.ok) { - const { synced, results } = result.data || { synced: 0, results: [] }; - console.log(` ${green}✓${reset} Watching ${bold}${latest.fileName}${reset}`); - console.log(` ${green}✓${reset} Added to .git/info/exclude`); - console.log(` ${green}✓${reset} Synced to ${synced} worktree(s) (peacock preserved)\n`); - for (const r of results) { - console.log(` ${basename(dirname(r.path)).padEnd(20)} ${colorDot(r.color)} ${r.color || ""}`); - } - } else { - console.log(` ${red}✗${reset} Failed: ${result?.error || "daemon not available"}`); - console.log(` ${dim}Make sure the daemon is running: rt daemon start${reset}`); - } - - console.log(); -} - diff --git a/commands/worktree.ts b/commands/worktree.ts index 52b0e227..147cae5c 100644 --- a/commands/worktree.ts +++ b/commands/worktree.ts @@ -485,13 +485,6 @@ export async function worktreeNav(_args: string[], _ctx: unknown): Promise realStdoutWrite(selected + "\n"); } -// ─── park is gone ──────────────────────────────────────────────────────────── - -export async function parkDeprecated(_args: string[], _ctx: unknown): Promise { - console.log(`\n ${red}✗${reset} rt park is gone — the parking lot was replaced by rt worktree (provision/dispose/list). See RT-34.\n`); - process.exit(1); -} - // ─── each ──────────────────────────────────────────────────────────────────── function fail(msg: string): never { diff --git a/docs/daemon-runner-health.md b/docs/daemon-runner-health.md index 41520c6e..95b5ae62 100644 --- a/docs/daemon-runner-health.md +++ b/docs/daemon-runner-health.md @@ -130,7 +130,7 @@ Forced transitions remain permitted (needed for kill-of-warm, reconcile, etc.) b - [lib/daemon/handlers/remedy.ts](../lib/daemon/handlers/remedy.ts) — `remedy:set|clear|drain` - [lib/daemon/handlers/proxy.ts](../lib/daemon/handlers/proxy.ts) — `proxy:start|stop|set-upstream|status|list` -Each module is a factory that takes a `HandlerContext` and returns a `HandlerMap`. Daemon.ts constructs the ctx once and merges the maps into `routedHandlers`; `handleCommand` does `routedHandlers[cmd] ?? switch` so non-extracted commands (ping, hooks:*, repos, ports, status, tcc:check, notifications*, tray:status, group:*, workspace:sync:*, port:*, shutdown) remain inline because they read daemon-local state (watchers, repos index, notifications, port allocator, workspace-sync, groups) that wouldn't benefit from being pushed out. +Each module is a factory that takes a `HandlerContext` and returns a `HandlerMap`. Daemon.ts constructs the ctx once and merges the maps into `routedHandlers`; `handleCommand` does `routedHandlers[cmd] ?? switch` so non-extracted commands (ping, hooks:*, repos, ports, status, tcc:check, notifications*, tray:status, group:*, port:*, shutdown) remain inline because they read daemon-local state (watchers, repos index, notifications, port allocator, groups) that wouldn't benefit from being pushed out. Live cache access goes through `ctx.cache.entries` — `loadCache()` now mutates `cache.entries` in place instead of reassigning, so handlers see disk reloads without plumbing getters. diff --git a/docs/mr-workflow.md b/docs/mr-workflow.md deleted file mode 100644 index 3ce23713..00000000 --- a/docs/mr-workflow.md +++ /dev/null @@ -1,262 +0,0 @@ -# MR workflow — `rt mr` / `rt pr` - -Atomic commands for the push → MR-description → MR-create flow against a GitLab -remote. Each command is independently useful; `rt mr ship` is the all-in-one -composite for daily use. - -GitLab only today (`glab` must be installed + authenticated). GitHub repos exit -with a hint to use `gh pr create` directly — GitHub support will land later. - -`mr` and `pr` are interchangeable — `rt mr open` and `rt pr open` point at the -same handler. - -## Atoms at a glance - -| Command | Does | Shape | -|---|---|---| -| `rt mr open` | Creates a bare MR via `glab` | Thin wrapper, no agent | -| `rt mr describe` | Drafts a description with an agent | Streams to stdout | -| `rt mr ship` | Push + describe + open | Daily driver composite | - ---- - -## The three atoms - -### `rt mr open` -[commands/mr.ts:openCommand](../commands/mr.ts) - -Opens a bare MR on the current (already-pushed) branch. Thin wrapper around -`glab mr create` — no agent, no description magic. - -``` -rt mr open # uses commit info (glab --fill) for body -rt mr open --description-file draft.md # read body from a file -printf "body\n" | rt mr open --description-file - # body from stdin -rt mr open --title "fix: foo" --target main --draft -rt mr open --web # open MR in browser after creation -rt mr open --dry-run # print the glab command, don't run -``` - -**Guards** — exits with an actionable message if: -- the remote isn't GitLab, -- the branch isn't pushed (`run rt git push first`), -- there are zero commits between the branch and target. - -**Title** defaults to the last commit subject. **Target** defaults to -`mr.json.target`, then `origin/HEAD`. - ---- - -### `rt mr describe` -[commands/mr.ts:describeCommand](../commands/mr.ts) - -Drafts an MR description with an agent, using the per-repo prompts + context -defined in `mr.json`. **Streams the description to stdout**; status chatter -goes to stderr. Designed to pipe cleanly into `rt mr open`. - -``` -rt mr describe # stream draft to terminal -rt mr describe > draft.md # capture; status still visible on stderr -rt mr describe | rt mr open --description-file - -rt mr describe --inline "call out the breaking flag change" -rt mr describe --debug # print the assembled prompt, skip the agent -rt mr describe --target main # override target for the diff base -``` - -**Agent** defaults to `claude -p`. Override via `mr.json`'s `agent` block -(e.g. `cursor-agent` or `codex`). - -**Diff cap** defaults to 80KB; oversized diffs get truncated with a marker. -Raise via `agent.maxDiffKb` in `mr.json`. - ---- - -### `rt mr ship` -[commands/mr.ts:shipCommand](../commands/mr.ts) - -The daily driver. Composite that chains: - -1. `rt git push` (auto upstream-fix; prompts force-with-lease if diverged) -2. `rt mr describe` (streams the agent's draft live) -3. `rt mr open` (passes the captured draft as `--description`) -4. Prints the MR URL - -``` -rt mr ship # the whole flow, end to end -rt mr ship --inline "highlight the RLS change" -rt mr ship --draft --web -rt mr ship --dry-run # rehearsal: nothing pushed, no MR created -rt mr ship --debug # stops after the draft is printed, no MR -``` - -If any step fails (push rejection, agent error, glab error) the command exits -before the next step runs. You can recover by invoking the remaining atoms by -hand. - ---- - -## Config — `~/.rt//mr.json` - -Sibling of `sync.json`. All fields optional — zero-config works with sensible -defaults; the agent-related fields only matter for `describe` / `create`. - -```jsonc -{ - // Open-atom defaults - "target": "master", - "draft": false, - "removeSourceBranch": true, // true → pass --remove-source-branch; unset → use project default - "squash": false, // true → pass --squash-before-merge - - // Describe-atom inputs - "prompts": [ - "~/.cursor/rules/mr-writing-style.mdc" - ], - "context": { - "include": [ - "docs/feature-management/skills/fm-mr-writeup/SKILL.md", - "docs/feature-management/skills/fm-mr-writeup/references/*.md" - ], - "exclude": ["**/ignore/**"] - }, - "inline": "Always flag RLS or schema-migration MRs in the summary.", - - // Agent override - "agent": { - "cli": "claude", // default; "codex" uses args ["exec", "-"] - "args": ["-p"], // optional override - "maxDiffKb": 80 // default diff-truncation cap - } -} -``` - -### `prompts` vs `context` - -Both end up concatenated into the agent's prompt, but differ in loading rules -and intent. - -| | **`prompts`** | **`context.include`** | -|---|---|---| -| **Path style** | Explicit paths, one per entry | Repo-root-relative **globs** | -| **Resolution** | Absolute / `~/...` / relative to `~/.rt//` | Matched via `git ls-files` from repo root | -| **File source** | Anywhere on disk | Only git-tracked files in the current repo | -| **`.mdc` frontmatter** | Stripped automatically | Passed through raw | -| **Missing file** | Yellow warning on stderr | Silently skipped | -| **Prompt section** | `# Style and template guidance` | `# Additional context files` (wrapped in code fences) | -| **Intended for** | The **instructions** — how to write | The **material** the instructions reference | - -Rule of thumb: user-level style guides and anything not in the repo go in -`prompts`; repo-tracked templates / reference docs go in `context` so every -worktree of the repo picks them up automatically. - -### Path resolution for `prompts` - -- `/absolute/path` → used as-is. -- `~/something` → `$HOME/something`. -- `something/else` → resolved against `~/.rt//`. - -### `context` globs - -Matched via `git ls-files -- ` from the repo root, so: -- `.gitignore` is honored (non-tracked files never appear). -- Patterns are standard git pathspecs (`docs/**/*.md`, etc.). -- Works from any worktree without hardcoded worktree paths. - -`exclude` patterns are then applied on top using `Bun.Glob`. - -### Agent invocation - -The agent runner ([lib/agent-runner.ts](../lib/agent-runner.ts)) spawns the -configured CLI with the assembled prompt piped on stdin. Defaults are -agent-aware: - -``` -claude -p < -codex exec - < -``` - -Stdout streams live to your terminal and is simultaneously captured for the -composite command (`rt mr ship`) to forward into `glab mr create ---description`. - ---- - -## Typical workflows - -### Daily driver - -```bash -# on a feature branch with commits -rt mr ship -``` - -Watch push happen, watch the agent draft the description, see the URL. - -### Draft in a branch, polish, then open manually - -```bash -rt git push -rt mr describe > /tmp/draft.md -$EDITOR /tmp/draft.md -rt mr open --description-file /tmp/draft.md --web -``` - -### One-off override without editing `mr.json` - -```bash -rt mr ship --inline "heads-up: flips the ff_new_pricing default to on" -``` - -### Dry-run the whole chain - -```bash -rt mr ship --dry-run -``` - -Push prints the command without running; describe calls the agent for real -(it's the only non-destructive step); open prints the glab command without -running. - -### See the exact prompt the agent receives - -```bash -rt mr describe --debug -``` - -No agent call — the assembled prompt prints to stdout (status to stderr). -Handy for tuning `prompts` / `context` / `inline`. - ---- - -## Pipeline composition - -The atoms are designed to pipe: - -```bash -rt mr describe | rt mr open --description-file - -``` - -`describe`'s stdout is pure markdown (all status goes to stderr) so downstream -consumers — `open --description-file -`, `tee`, your editor — get clean input. - ---- - -## Failure modes - -| Symptom | Cause | Fix | -|---|---|---| -| `GitHub not supported yet` | `github.com` remote | Use `gh pr create`. | -| ` has not been pushed yet` | `open` can't find `origin/` | Run `rt git push` first (or use `rt mr ship`). | -| `no commits between origin/ and ` | Branch is up to date with target | Commit something, rebase off a stale target, or pick a different `--target`. | -| `prompt not found: ...` (yellow) | A `prompts[]` path didn't resolve | Fix the path, or remove it from `mr.json`. | -| `agent exited ` | The CLI (`claude` / `cursor-agent` / `codex`) failed | Check CLI auth; try the command directly with the prompt piped in. | -| `glab exited ` | glab rejected the MR | Read the stderr — usually a project-rule violation (missing template fields, invalid target). | - ---- - -## Related files - -- [commands/mr.ts](../commands/mr.ts) — all three handlers + shared helpers. -- [lib/mr-config.ts](../lib/mr-config.ts) — config loader, path resolution, `.mdc` frontmatter strip. -- [lib/agent-runner.ts](../lib/agent-runner.ts) — generic `runAgent({ cli, args, prompt, stream })`. Reusable by future `rt commit describe`, `rt pr review`, etc. -- [commands/git/push.ts](../commands/git/push.ts) — the push atom that `rt mr ship` calls in step 1. diff --git a/docs/superpowers/plans/2026-08-20-rt50-deletions.md b/docs/superpowers/plans/2026-08-20-rt50-deletions.md new file mode 100644 index 00000000..fe8c2d58 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-rt50-deletions.md @@ -0,0 +1,240 @@ +# RT-50 Step 1 — Dead-Command Deletions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the ruled-dead rt commands (mr/pr, branch, turbo, open, code-verb, agent, workspace, park, doppler-verb) and everything they exclusively own, leaving every survivor green. + +**Architecture:** rt's command surface is defined once in `lib/command-tree-def.ts` (dispatch + docs generation both read it); every `module:` referenced there must have a static import + entry in `lib/module-registry.ts` or the compiled binary breaks. Deletions therefore always remove tree node + registry pair + module file together, then update the tests that hardcode the command list. + +**Tech Stack:** Bun (TypeScript), `bun test`, e2e harness under `e2e/`, docs generated by `scripts/gen-docs.ts`. + +**Spec:** `docs/superpowers/specs/2026-08-20-rt50-deletions.md` + +## Global Constraints + +- Working tree: `/Users/matt/Documents/GitHub/repo-tools-rt50-wt`, branch `goodwinmattheweric/rt-50-settings-state-endgame`. Never touch the main checkout. +- `rt hooks` and `rt plugin` are HARD KEEPS (Matt's ruling) — do not touch their commands, daemon verbs, hooks-guard, or api-server routes. +- Doppler MACHINERY stays: `lib/daemon/doppler-sync.ts`, `lib/doppler-template.ts`, `lib/doppler-config.ts`, `doppler-template.yaml` handling. Only `commands/doppler.ts` + its tree/registry entries go. +- `workspace-prefs.json` and the editor-pref machinery in `commands/code.ts` SURVIVE (`rt nav` depends on them via `openDirectoryInEditor`). Only the `openInEditor` export and the `code` tree node go. +- `branch-naming.json` files on disk stay (VS Code extension has its own reader at `extensions/vscode/rt-context/src/branchNaming.ts`); only rt's `lib/branch-naming.ts` goes. +- No compat shims, no deprecation stubs, no "removed" notices in code. +- Comments follow the clean-code rule: never explain what was removed or why to a reviewer; decision records go in your task report, not the source. +- Gates after every task: `bun x tsc --noEmit` → 0 errors; the named test commands pass. No monitor exists — run tests yourself, never wait for anything. +- Any test that spawns quit/kill/launchctl must pass `env: process.env` from its first run (Bun PATH-snapshot gotcha). +- Commit after every task with the trailer `Co-Authored-By: Claude Fable 5 `. + +--- + +### Task 1: Remove `rt mr` / `rt pr` + +**Files:** +- Modify: `lib/command-tree-def.ts` — delete the `mr` node (lines 275–326, includes `aliases: ["pr"]`) +- Delete: `commands/mr.ts`, `lib/mr-config.ts`, `lib/agent-runner.ts`, `lib/__tests__/agent-runner.test.ts`, `docs/mr-workflow.md` +- Modify: `lib/module-registry.ts` — remove the `../commands/mr.ts` import (line 25) and its `MODULE_REGISTRY` entry (line 62) +- Modify: `lib/__tests__/command-tree-def.test.ts:7` — remove `"mr"` from the representative-roots array +- Modify: `e2e/tests/picker-basics.test.ts:39-55` +- Modify: `lib/settings/registry.ts:127-136` — delete the `rt.mr` row; update `lib/settings/__tests__/registry.test.ts` wherever it enumerates or counts `rt.mr` (explorer found references at lines 97, 124, 128, 140, 155, 158, 160 — check each; only those about `rt.mr` change) + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: a TREE without `mr`; the second top-level item becomes `sync`. + +- [ ] **Step 1: Delete the code** + +Remove the six files and the tree/registry/settings-row lines listed above. `commands/mr.ts` imports `pushCommand` from `./git/push.ts` — that file is a survivor, leave it. + +- [ ] **Step 2: Fix the picker e2e test** + +The "Down + Enter selects non-first item" test assumed item 2 = `mr` with `ship`/`describe` subcommands. Item 2 is now `sync`. Retarget the test at a pure branch node (subcommands, no top-level `fn`) so Enter opens a subpicker instead of running a command: check the new tree order and press Down the right number of times to reach `daemon` (or the first fn-less branch node), then assert two of its subcommand names, e.g.: + +```ts +test("Down + Enter selects non-first item", async () => { + session = await startInteractive({ args: [], home }); + await session.waitForText("filter:", 8000); + // Navigate to "daemon" (a pure branch node) — count Downs from the tree order. + for (let i = 0; i < N; i++) await session.press("Down"); + await session.waitForIdle(); + await session.press("Enter"); + await session.waitForText("logs", 5000); + const screen = await session.screen(); + expect(screen).toContain("status"); + expect(screen).not.toContain("version"); +}, 15_000); +``` + +Replace `N` with the real ordinal you observe and the two asserted names with real `daemon` subcommands (`rt daemon --help` in the e2e home, or read the tree). Verify the asserted words don't also appear in the top-level picker (the reason the old test waited on `ship`). + +- [ ] **Step 3: Gates** + +Run: `bun x tsc --noEmit` → 0 errors. `bun test lib/` → green. `bun test e2e/tests/picker-basics.test.ts` → green (build the e2e binary first if the harness needs it — follow `e2e/README` or existing scripts; delete `dist/rt` before building). + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "RT-50: remove rt mr/pr and its exclusively-owned code + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: Remove `rt branch` (and `rt git branch` — same object) + +**Files:** +- Modify: `lib/command-tree-def.ts` — delete the `branchSubcommands` const (lines 10–49), the `git.subcommands.branch` site (205–208), and the top-level `branch` node (353–356). Both tree sites share the const by identity; deleting only one silently keeps the modules alive. +- Delete: `commands/branch.ts`, `commands/branch-clean.ts`, `lib/branch-naming.ts`, `lib/__tests__/branch-naming.test.ts` +- Modify: `lib/module-registry.ts` — imports lines 9–10, registry lines 46–47 +- Modify: `lib/__tests__/command-tree-def.test.ts:14-16` — delete the "branch subtree is shared by identity" test entirely +- Modify: `e2e/tests/smoke.test.ts:41-44` — remove `"branch"` from the `rt git` `expectedSubs` array (the top-level `expectedCommands` array is rewritten wholesale in Task 6; if the suite is run before Task 6, that one test is expected to fail — note it in your report, don't fix it here) +- Keep: `lib/settings/registry.ts` `rt.branchNaming` row (the files stay; step 2 of RT-50 migrates them to a team key) + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: TREE with neither `branch` nor `git.subcommands.branch`. + +- [ ] **Step 1: Delete the code** — files + both tree sites + const + registry pair as listed. +- [ ] **Step 2: Update the two tests** as listed. +- [ ] **Step 3: Gates** — `bun x tsc --noEmit` → 0; `bun test lib/` → green. +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "RT-50: remove rt branch / rt git branch and lib/branch-naming + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: Remove `rt turbo`, `rt open`, `rt agent`, and the `rt code` verb + +**Files:** +- Modify: `lib/command-tree-def.ts` — delete nodes `turbo` (358–372), `open` (518–552), `code` (576–584), `agent` (586–595). Line numbers shift after Tasks 1–2 — locate by key, not offset. +- Delete: `commands/build-select.ts`, `commands/open.ts`, `commands/agent.ts`, `commands/__tests__/agent.test.ts` +- Modify: `commands/code.ts` — delete ONLY the `openInEditor` export (lines 363–412). Everything else (loadPrefs/savePrefs, PREFS_PATH, detectInstalledEditors, ensureEditor, resolveWorkspaceTarget, launchEditor, openDirectoryInEditor, appBundleFallback) survives for `rt nav` (`commands/nav.ts:34` imports `openDirectoryInEditor`). +- Modify: `lib/module-registry.ts` — imports at (pre-shift) lines 11, 13, 18 and registry entries 48, 50, 55 for build-select/agent/open. The `../commands/code.ts` registry pair (33/70): remove it ONLY if no tree node references `./commands/code.ts` after this task — grep `command-tree-def.ts` for `commands/code.ts` first; if `rt nav`'s tree node points at `./commands/nav.ts` (it does), the code.ts registry pair goes too, since the registry mirrors tree `module:` references, and nav imports code.ts statically anyway. + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: `commands/code.ts` without `openInEditor`; TREE without turbo/open/code/agent. + +- [ ] **Step 1: Delete the code** as listed. +- [ ] **Step 2: Verify nav still compiles against code.ts** — `bun x tsc --noEmit` → 0 errors (this is the real check that the surviving exports are intact). +- [ ] **Step 3: Gates** — `bun test lib/ commands/` → green. +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "RT-50: remove rt turbo/open/agent and the rt code verb (nav keeps the editor machinery) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: Remove `rt workspace` + daemon workspace-sync plumbing + +**Files:** +- Delete: `commands/workspace.ts`, `lib/daemon/handlers/workspace.ts`, `lib/daemon/workspace-sync.ts`, `lib/daemon/__tests__/workspace-sync.test.ts` +- Modify: `lib/command-tree-def.ts` — delete the `workspace` node (597–612 pre-shift) +- Modify: `lib/module-registry.ts` — import line 24, registry line 61 +- Modify: `lib/daemon.ts` — remove the `restoreWatchers` import (line 47) and its boot call block (lines 285–290, including the error log) +- Modify: `lib/daemon/shutdown.ts` — remove the `cleanupAllWatchers` import (line 11) and call (line 36) +- Modify: `lib/daemon/command-router.ts` — remove the `createWorkspaceHandlers` import (line 13) and spread (line 47) +- Modify: `lib/__tests__/repo-layout.test.ts` — remove the `saveSyncConfig` import (line 16) and the "workspace-sync saveSyncConfig writes under repos/" test (lines 44–48). The doppler `saveTemplate` test above it stays. +- Modify: `lib/settings/registry.ts:177-186` — delete the `rt.workspaceSync` row; update `lib/settings/__tests__/registry.test.ts` references to it. + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: a daemon with no `workspace:sync:*` verbs and no watcher lifecycle in boot/shutdown. + +- [ ] **Step 1: Delete the code** as listed. The CLI reached these verbs via string `daemonQuery` calls only, so tsc won't catch a missed side — delete both sides in this task. +- [ ] **Step 2: Gates** — `bun x tsc --noEmit` → 0; `bun test lib/` → green (includes daemon tests and the updated repo-layout + settings-registry tests). +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "RT-50: remove rt workspace and the daemon workspace-sync machinery + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: Remove `rt park` and the `rt doppler` verb + +**Files:** +- Modify: `lib/command-tree-def.ts` — delete the `park` node (614–619 pre-shift) and the `doppler` node (706–739 pre-shift) +- Modify: `commands/worktree.ts` — delete the `parkDeprecated` function (lines 488–492). The rest of the file is the surviving `rt worktree` command. +- Delete: `commands/doppler.ts` +- Modify: `lib/module-registry.ts` — remove the `../commands/doppler.ts` import (line 37) and registry entry (line 74). The `../commands/worktree.ts` pair STAYS (rt worktree lives). +- Keep untouched: `lib/daemon/doppler-sync.ts`, `lib/doppler-template.ts`, `lib/doppler-config.ts`, their tests, and the `rt.dopplerTemplate` settings row. `reconcileForRepo` keeps its two surviving callers (`lib/worktree/create.ts:140`, `lib/daemon/cache-refresh.ts:206`). + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: TREE without `park`/`doppler`; doppler reconcile machinery intact. + +- [ ] **Step 1: Delete the code** as listed. Do NOT prune `saveTemplate`/`captureFromActualConfig`/`getScopedEntry` from the doppler libs — surviving tests use them and the follow-up is out of scope. +- [ ] **Step 2: Gates** — `bun x tsc --noEmit` → 0; `bun test lib/ commands/` → green. +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "RT-50: remove rt park and the rt doppler verb (reconcile machinery stays) + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 6: Command-list tests, docs sweep, regenerated reference docs, full gates + +**Files:** +- Modify: `e2e/tests/smoke.test.ts:25-30` — rewrite `expectedCommands` to the surviving surface: + +```ts +const expectedCommands = [ + "git", "sync", "run", "commit", + "port", "status", "update", "version", + "cd", "nav", "daemon", "settings", "hooks", +]; +``` + +Sanity-check this list against the real `rt --help` output before committing — add any surviving top-level command the old list named that still exists (`worktree`, `verify`, `sdm`, etc. were not in the old list; do not grow the list beyond what the old test asserted minus the deleted commands, unless the old list's omissions were already arbitrary — match the old test's spirit: every listed name must appear). +- Modify: `README.md` — remove/rewrite the sections referencing deleted commands (lines ~94, 113–114, 124, 142, 159–164, 199–202, 208 pre-shift; grep for `rt mr`, `rt branch`, `rt turbo`, `rt open`, `rt code`, `rt agent`, `rt workspace`, `rt park`, `rt doppler` and fix every hit; `rt hooks` mentions stay). +- Modify: `website/docs/getting-started/first-commands.mdx` (lines 17, 31–33, 47–50), `website/docs/getting-started/onboard-a-repo.mdx:31` (the `rt hooks` mention at :30 stays), `website/docs/guides/common-flags.mdx` — same grep-and-fix. +- Delete: generated reference docs for removed commands under `website/docs/reference/`: `agent.mdx`, `code.mdx`, `park.mdx`, `branch/`, `mr/`, `open/`, `turbo/`, `workspace/`, `doppler/` — then run the docs generator and confirm it does not recreate them and does not diff surviving pages unexpectedly. +- Leave as-is: `RELEASE_NOTES.md`, `docs/2026-07-01-refactor-herd-summary.md`, historical specs/plans (they are records, not live docs). + +**Interfaces:** +- Consumes: the fully pruned TREE from Tasks 1–5. +- Produces: a green full suite on the final surface. + +- [ ] **Step 1: Update smoke test + docs** as listed. +- [ ] **Step 2: Regenerate reference docs** — run `bun scripts/gen-docs.ts` (check `package.json` for the wired script name first) and inspect `git status` for surprises. +- [ ] **Step 3: Full gates** — `bun x tsc --noEmit` → 0; `bun run test:all` → green; `rm -f dist/rt` then run the e2e suite per the repo's e2e script → green. Report exact counts. +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "RT-50: command-list tests + docs swept to the surviving surface + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 7 (orchestrator-only, live machine): cruft + orphaned state deletion + +Not a subagent task — executed inline by the orchestrator after Tasks 1–6 are merged and gates are green, because it deletes live files under `~/.mattstack/rt/`: + +- `daemon.log`, `diag.log`, `sync.log` (July, pre-convention) +- `.DS_Store` (top), `repos/.DS_Store`, `plugins/.DS_Store` +- `attach-1-assured-wktree-4.sock`, `attach-2-assured-wktree-4.sock`, `attach-adjuster:start.sock`, `attach-backend:start-lite-watch.sock` +- `.attic-2026-08-20.tar.gz` +- the six `*.json.migrated` blobs (state.db is live; rollback window closed by Matt's ok) +- orphaned by the code deletions: `repos/*/mr.json`, `repos/*/build-history.json`, `repos/*/parking-lot.json` (16), `repos/*/workspace-sync.json` +- NOT deleted: `workspace-prefs.json` (survives with rt nav), `branch-naming.json` files (VS Code extension), `doppler-template.yaml` files (reconciler reads them) + +Then `rt verify` must stay green and the daemon healthy (restart it on the new build if the merged binary changed daemon behavior — Task 4 changed boot/shutdown, so a daemon restart IS required). diff --git a/docs/superpowers/specs/2026-08-20-rt50-deletions.md b/docs/superpowers/specs/2026-08-20-rt50-deletions.md new file mode 100644 index 00000000..5de0d0ea --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-rt50-deletions.md @@ -0,0 +1,59 @@ +# RT-50 step 1 — dead-command deletions + cruft (spec) + +Scope: the deletion lane of RT-50 (Linear: "settings/state endgame"). Settings-key +migration is step 2, a separate plan. All removals below were ruled by Matt on +2026-08-20 (Linear RT-50 comment + session handoff); rulings are final. + +## Commands removed (code + registry + docs + tests) + +| Command | Also removed with it | +|---|---| +| `rt mr` / `rt pr` | per-repo `mr.json` read/write code; the files under `~/.mattstack/rt/repos/*/mr.json` | +| `rt branch` | branch-clean; **branch-naming.json FILES STAY** (VS Code extension reads them) — only rt's verb goes | +| `rt turbo` | build-history.json code + files | +| `rt open` | — | +| `rt code` | workspace-prefs.json code + file | +| `rt agent` | — | +| `rt workspace` | daemon `workspace:sync:*` handler family, `lib/workspace-sync.ts`, workspace-sync.json | +| `rt park` | parking-lot.json code + the 16 per-repo files (on-deck replaced parking) | +| `rt doppler` (verb ONLY) | **machinery stays**: `lib/daemon/doppler-sync.ts` worktree-create auto-sync and doppler-template.yaml handling untouched | + +Kept by ruling regardless of usage: `rt sync`, `rt status`, `rt update` (dies with +brew in MAT-383 phase 2, not before). + +## Caller-check audit results (evidence gathered 2026-08-20) + +- 14 days of `~/.mattstack/rt/logs/cli.*`: zero hits for mr/pr, branch, turbo, open, + code, agent, workspace, hooks, plugin. `park` 11 hits (ruled removed anyway), + `doppler` 13 (verb-only removal stands). The 352 `validate` hits are the top-level + `rt validate` command, not `rt plugin validate`. +- No installed Claude plugin pack (assured, claimview, mattstack, official) references + `rt hooks` or `rt plugin`. +- **RULED (Matt, 2026-08-20 mid-session): `rt hooks` and `rt plugin` are hard keeps.** + Their commands, daemon verbs (hooks:status/repair/watch), hooks-guard, and the + api-server repair route are untouched by this lane. (Supporting evidence anyway: + `rt plugin` is driven by e2e tests and the rt:create-plugin skill; `rt hooks` is the + sole writer of the state hooks-guard reads.) + +## Cruft deleted from ~/.mattstack/rt (no code involved) + +- `daemon.log` (5.7MB Jul), `diag.log` (2MB Jul), `sync.log` (Jul 16) — pre-convention, + superseded by `logs/` +- `.DS_Store` ×3 (top, repos/, plugins/) +- `attach-*.sock` ×4 (May/June, dead) +- `.attic-2026-08-20.tar.gz` +- the six `*.json.migrated` blobs (branch-cache, discussions, events-cursors, + notifier-state, notify-queue, project-mrs) — Matt ok'd; state.db is live and green +- state files orphaned by the code deletions above: `workspace-prefs.json`, + `workspace-sync.json`, per-repo `mr.json`, `build-history.json`, + 16× `parking-lot.json` + +## Constraints + +- Worktree `repo-tools-rt50-wt`, branch `goodwinmattheweric/rt-50-settings-state-endgame`. +- Every command-tree `module:` removal must remove its `lib/module-registry.ts` entry + in the same commit (compiled-binary footgun, CLAUDE.md). +- No compat shims, no deprecation stubs: pure canonical removal. +- Green gates: `tsc` 0 errors, `bun run test:all`, e2e suite; delete `dist/rt` before + trusting e2e. +- Tests spawning quit/kill/launchctl must pass `env: process.env` from the first run. diff --git a/e2e/tests/picker-basics.test.ts b/e2e/tests/picker-basics.test.ts index 928e1e94..152909bd 100644 --- a/e2e/tests/picker-basics.test.ts +++ b/e2e/tests/picker-basics.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync } from "fs"; import { join } from "path"; import { createTestHome } from "../harness.ts"; import { startInteractive, type TermwrightSession } from "../interactive.ts"; +import { TREE } from "../../lib/command-tree-def.ts"; describe("picker basics", () => { let home: string; @@ -40,17 +41,16 @@ describe("picker basics", () => { session = await startInteractive({ args: [], home }); await session.waitForText("filter:", 8000); - // First item is "git". Down once -> "mr". Select it. - await session.press("Down"); + // "daemon" is a pure branch node (subcommands, no top-level fn), so + // Enter opens its subpicker instead of dispatching a command. + const downs = Object.keys(TREE).indexOf("daemon"); + for (let i = 0; i < downs; i++) await session.press("Down"); await session.waitForIdle(); await session.press("Enter"); - // "mr" is a branch node -- its picker shows open/describe/ship. - // Wait for "ship" specifically since "open" also appears in the - // top-level picker and could match during the transition. - await session.waitForText("ship", 5000); + await session.waitForText("logs", 5000); const screen = await session.screen(); - expect(screen).toContain("describe"); + expect(screen).toContain("status"); expect(screen).not.toContain("version"); }, 15_000); diff --git a/e2e/tests/smoke.test.ts b/e2e/tests/smoke.test.ts index 3ff947d5..a0036187 100644 --- a/e2e/tests/smoke.test.ts +++ b/e2e/tests/smoke.test.ts @@ -23,10 +23,9 @@ describe("smoke", () => { const output = result.stderr; const expectedCommands = [ - "git", "mr", "sync", "branch", "turbo", "run", "commit", - "port", "status", "update", "version", "open", - "cd", "nav", "code", "agent", "workspace", "park", - "doppler", "daemon", "settings", "hooks", + "git", "sync", "run", "commit", + "port", "status", "update", "version", + "cd", "nav", "daemon", "settings", "hooks", ]; for (const cmd of expectedCommands) { expect(output).toContain(cmd); @@ -39,7 +38,7 @@ describe("smoke", () => { const output = result.stderr; const expectedSubs = [ - "rebase", "reset", "branch", "commit", + "rebase", "reset", "commit", "backup", "restore", "pull", "push", "upstream", ]; for (const sub of expectedSubs) { diff --git a/lib/__tests__/agent-runner.test.ts b/lib/__tests__/agent-runner.test.ts deleted file mode 100644 index 9d2f92ea..00000000 --- a/lib/__tests__/agent-runner.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { resolveAgentInvocation } from "../agent-runner.ts"; - -describe("resolveAgentInvocation", () => { - test("uses Claude print mode by default", () => { - expect(resolveAgentInvocation({})).toEqual({ - cli: "claude", - args: ["-p"], - }); - }); - - test("uses Codex exec stdin mode when cli is codex", () => { - expect(resolveAgentInvocation({ cli: "codex" })).toEqual({ - cli: "codex", - args: ["exec", "-"], - }); - }); - - test("preserves explicit args for Codex", () => { - expect(resolveAgentInvocation({ - cli: "codex", - args: ["exec", "--full-auto", "-"], - })).toEqual({ - cli: "codex", - args: ["exec", "--full-auto", "-"], - }); - }); -}); diff --git a/lib/__tests__/branch-naming.test.ts b/lib/__tests__/branch-naming.test.ts deleted file mode 100644 index 2bf18979..00000000 --- a/lib/__tests__/branch-naming.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, test, expect, afterEach, beforeEach } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "fs"; -import { join } from "path"; -import type { LinearTicket } from "../linear.ts"; - -const origHome = process.env.HOME; -const TMP = "/tmp/branch-naming-test-home"; - -beforeEach(() => { - rmSync(TMP, { recursive: true, force: true }); - process.env.HOME = TMP; -}); - -afterEach(() => { - process.env.HOME = origHome; - rmSync(TMP, { recursive: true, force: true }); -}); - -const sampleTicket: LinearTicket = { - id: "abc-123", - identifier: "CV-1287", - title: "Add damage photos to claim view", - description: null, - url: "https://linear.app/issue/CV-1287", - stateName: "In Progress", - stateColor: "#6b6bff", - branchName: "feature/cv-1287-add-damage-photos", -}; - -describe("loadBranchNamingConfig", () => { - test("returns null when config file does not exist", async () => { - const { loadBranchNamingConfig } = await import("../branch-naming.ts"); - const dataDir = join(TMP, "nonexistent"); - expect(loadBranchNamingConfig(dataDir)).toBeNull(); - }); - - test("loads config when file exists", async () => { - const { loadBranchNamingConfig } = await import("../branch-naming.ts"); - const dataDir = join(TMP, "repo"); - mkdirSync(dataDir, { recursive: true }); - writeFileSync( - join(dataDir, "branch-naming.json"), - JSON.stringify({ template: "${teamPrefix}-${ticketNumber}-${llmSlug:10}" }), - ); - const config = loadBranchNamingConfig(dataDir); - expect(config).not.toBeNull(); - expect(config!.template).toBe("${teamPrefix}-${ticketNumber}-${llmSlug:10}"); - }); - - test("returns null for malformed JSON", async () => { - const { loadBranchNamingConfig } = await import("../branch-naming.ts"); - const dataDir = join(TMP, "bad-repo"); - mkdirSync(dataDir, { recursive: true }); - writeFileSync(join(dataDir, "branch-naming.json"), "not json"); - expect(loadBranchNamingConfig(dataDir)).toBeNull(); - }); - - test("returns null for empty/missing template field", async () => { - const { loadBranchNamingConfig } = await import("../branch-naming.ts"); - const dataDir = join(TMP, "empty-repo"); - mkdirSync(dataDir, { recursive: true }); - writeFileSync( - join(dataDir, "branch-naming.json"), - JSON.stringify({}), - ); - expect(loadBranchNamingConfig(dataDir)).toBeNull(); - }); - - test("returns null for whitespace-only template", async () => { - const { loadBranchNamingConfig } = await import("../branch-naming.ts"); - const dataDir = join(TMP, "ws-repo"); - mkdirSync(dataDir, { recursive: true }); - writeFileSync( - join(dataDir, "branch-naming.json"), - JSON.stringify({ template: " " }), - ); - expect(loadBranchNamingConfig(dataDir)).toBeNull(); - }); -}); - -describe("resolveBranchName", () => { - test("fallback: produces identifier-titleSlug when config is null", async () => { - const { resolveBranchName } = await import("../branch-naming.ts"); - const name = await resolveBranchName(sampleTicket, null); - expect(name).toBe("cv-1287-add-damage-photos-to-claim-view"); - }); - - test("resolves ${identifier} and ${titleSlug}", async () => { - const { resolveBranchName } = await import("../branch-naming.ts"); - const name = await resolveBranchName(sampleTicket, { - template: "${identifier}-${titleSlug}", - }); - expect(name).toBe("cv-1287-add-damage-photos-to-claim-view"); - }); - - test("resolves ${teamPrefix}", async () => { - const { resolveBranchName } = await import("../branch-naming.ts"); - const name = await resolveBranchName(sampleTicket, { - template: "${teamPrefix}", - }); - expect(name).toBe("cv"); - }); - - test("resolves ${ticketNumber}", async () => { - const { resolveBranchName } = await import("../branch-naming.ts"); - const name = await resolveBranchName(sampleTicket, { - template: "${ticketNumber}", - }); - expect(name).toBe("1287"); - }); - - test("combines multiple vars", async () => { - const { resolveBranchName } = await import("../branch-naming.ts"); - const name = await resolveBranchName(sampleTicket, { - template: "${teamPrefix}-${ticketNumber}-${titleSlug}", - }); - expect(name).toBe("cv-1287-add-damage-photos-to-claim-view"); - }); - - test("uses directory separators in template", async () => { - const { resolveBranchName } = await import("../branch-naming.ts"); - const name = await resolveBranchName(sampleTicket, { - template: "${teamPrefix}/${ticketNumber}/${titleSlug}", - }); - expect(name).toBe("cv/1287/add-damage-photos-to-claim-view"); - }); - - test("strips leading/trailing slashes from resolved name", async () => { - const { resolveBranchName } = await import("../branch-naming.ts"); - const name = await resolveBranchName(sampleTicket, { - template: "/${identifier}/", - }); - expect(name).toBe("cv-1287"); - }); - - test("rejects unknown variable names", async () => { - const { resolveBranchName } = await import("../branch-naming.ts"); - await expect( - resolveBranchName(sampleTicket, { template: "${badVar}" }), - ).rejects.toThrow("Unknown variable"); - }); - - test("rejects ${llmSlug} with no N", async () => { - const { resolveBranchName } = await import("../branch-naming.ts"); - await expect( - resolveBranchName(sampleTicket, { template: "${llmSlug}" }), - ).rejects.toThrow("${llmSlug}"); - }); - - test("rejects ${llmSlug} with non-integer N", async () => { - const { resolveBranchName } = await import("../branch-naming.ts"); - await expect( - resolveBranchName(sampleTicket, { template: "${llmSlug:abc}" }), - ).rejects.toThrow("${llmSlug}"); - }); - - test("falls back to mechanical slug when llmSlug errors", async () => { - // We'll test this by making the LLM unavailable ($~/.mattstack/rt/llm.json won't - // exist, so the model is ""), which causes llmPrompt to fail with - // LlmUnavailableError. resolveBranchName should catch and use a - // truncated mechanical slug instead. - const { resolveBranchName } = await import("../branch-naming.ts"); - const name = await resolveBranchName(sampleTicket, { - template: "${llmSlug:10}", - }); - // Should be a mechanical slug truncated to 10 chars - expect(name.length).toBeLessThanOrEqual(10); - expect(name).not.toBe(""); - expect(name).toMatch(/^[a-z0-9-]+$/); - }); -}); diff --git a/lib/__tests__/command-tree-def.test.ts b/lib/__tests__/command-tree-def.test.ts index 646ab10d..1d2f1d35 100644 --- a/lib/__tests__/command-tree-def.test.ts +++ b/lib/__tests__/command-tree-def.test.ts @@ -4,19 +4,18 @@ import { TREE } from "../command-tree-def.ts"; test("TREE is importable without side effects and has expected roots", () => { expect(typeof TREE).toBe("object"); // A representative slice of the built-in surface. - for (const key of ["git", "mr", "sync", "run", "status", "sdm", "daemon"]) { + for (const key of ["git", "sync", "run", "status", "sdm", "daemon"]) { expect(TREE[key]).toBeDefined(); expect(typeof TREE[key]!.description).toBe("string"); } expect(TREE.git!.subcommands?.rebase?.description).toContain("rebase"); }); -test("branch subtree is shared by identity, not copy-pasted", () => { - expect(TREE.branch!.subcommands).toBe(TREE.git!.subcommands!.branch!.subcommands); -}); - test("commit description is consistent across both paths", () => { expect(TREE.commit!.description).toBe(TREE.git!.subcommands!.commit!.description); + // Shared by identity, not copy-pasted — a divergence here means the tree + // was edited to duplicate commitNode instead of reusing the constant. + expect(TREE.commit).toBe(TREE.git!.subcommands!.commit); }); test("verify command is present in the tree", () => { diff --git a/lib/__tests__/command-tree.test.ts b/lib/__tests__/command-tree.test.ts index 430f992e..ecab3a81 100644 --- a/lib/__tests__/command-tree.test.ts +++ b/lib/__tests__/command-tree.test.ts @@ -50,12 +50,12 @@ describe("walkTree", () => { test("returns null for an unknown segment", () => { expect(walkTree(TREE, ["nope"])).toBeNull(); - expect(walkTree(TREE, ["branch", "nope"])).toBeNull(); + expect(walkTree(TREE, ["daemon", "nope"])).toBeNull(); }); test("returns null when the path ends on a leaf", () => { expect(walkTree(TREE, ["cd"])).toBeNull(); - expect(walkTree(TREE, ["branch", "switch"])).toBeNull(); + expect(walkTree(TREE, ["daemon", "logs", "tail"])).toBeNull(); }); }); diff --git a/lib/__tests__/repo-layout.test.ts b/lib/__tests__/repo-layout.test.ts index afb3b135..3e4b9756 100644 --- a/lib/__tests__/repo-layout.test.ts +++ b/lib/__tests__/repo-layout.test.ts @@ -13,7 +13,6 @@ import { tmpdir } from "os"; import { join } from "path"; import { repoDataDir } from "../rt-paths.ts"; import { saveTemplate } from "../doppler-template.ts"; -import { saveSyncConfig } from "../daemon/workspace-sync.ts"; describe("per-repo files land under ~/.mattstack/rt/repos//", () => { const origHome = process.env.HOME; @@ -40,10 +39,4 @@ describe("per-repo files land under ~/.mattstack/rt/repos//", () => { expect(existsSync(newPath("acme", "doppler-template.yaml"))).toBe(true); expect(existsSync(oldPath("acme", "doppler-template.yaml"))).toBe(false); }); - - test("workspace-sync saveSyncConfig writes under repos/", () => { - saveSyncConfig("acme", { enabled: true } as never); - expect(existsSync(newPath("acme", "workspace-sync.json"))).toBe(true); - expect(existsSync(oldPath("acme", "workspace-sync.json"))).toBe(false); - }); }); diff --git a/lib/agent-runner.ts b/lib/agent-runner.ts deleted file mode 100644 index 6ac4c246..00000000 --- a/lib/agent-runner.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Generic agent runner — pipe a prompt into a CLI agent via stdin, - * stream its stdout to the caller while also capturing it for return. - * - * Default: `claude -p`. Agent-aware defaults cover Codex (`codex exec -`); - * override via `cli` / `args` for other agents. - * - * Intentionally minimal — no prompt assembly, no config reading. Callers - * build the prompt string; this just runs the CLI and streams. - */ - -import { spawn } from "child_process"; - -export interface AgentOptions { - /** Executable name. Default: "claude". */ - cli?: string; - /** CLI args. Default: ["-p"] (non-interactive print mode). */ - args?: string[]; - /** The prompt text — piped to the CLI on stdin. */ - prompt: string; - /** Working directory for the spawned CLI. */ - cwd?: string; - /** - * If set, each stdout chunk is written here as it arrives (in addition - * to being captured in the returned `stdout`). Pass `process.stdout` to - * stream the response live to the user's terminal. - */ - stream?: NodeJS.WritableStream; - /** Optional stderr sink for status / progress (e.g. `process.stderr`). */ - stderrStream?: NodeJS.WritableStream; -} - -export interface AgentInvocation { - cli: string; - args: string[]; -} - -export interface AgentResult { - stdout: string; - stderr: string; - ok: boolean; - exitCode: number | null; -} - -function defaultArgsForAgent(cli: string): string[] { - const name = cli.split("/").pop() ?? cli; - if (name === "codex") return ["exec", "-"]; - return ["-p"]; -} - -export function resolveAgentInvocation(opts: Pick): AgentInvocation { - const cli = opts.cli ?? "claude"; - return { - cli, - args: opts.args ?? defaultArgsForAgent(cli), - }; -} - -export async function runAgent(opts: AgentOptions): Promise { - const { cli, args } = resolveAgentInvocation(opts); - - return new Promise((resolve, reject) => { - const child = spawn(cli, args, { - cwd: opts.cwd, - stdio: ["pipe", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - if (opts.stream) opts.stream.write(chunk); - }); - - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - if (opts.stderrStream) opts.stderrStream.write(chunk); - }); - - child.on("error", (err) => { - reject(err); - }); - - child.on("close", (code) => { - resolve({ stdout, stderr, ok: code === 0, exitCode: code }); - }); - - // Swallow stdin stream errors (EPIPE when the child dies before reading - // the prompt) — the meaningful failure surfaces via error/close above. - child.stdin.on("error", () => {}); - child.stdin.end(opts.prompt); - }); -} diff --git a/lib/branch-naming.ts b/lib/branch-naming.ts deleted file mode 100644 index 84cc8b00..00000000 --- a/lib/branch-naming.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { join } from "path"; -import { existsSync, readFileSync } from "fs"; -import type { LinearTicket } from "./linear.ts"; -import { LlmUnavailableError, LlmEmptyResponseError } from "./llm.ts"; -import { dim, reset } from "./tui.ts"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface BranchNamingConfig { - template: string; -} - -// ─── Config loader ─────────────────────────────────────────────────────────── - -/** - * Load per-repo branch naming config from `dataDir/branch-naming.json`. - * Returns null if the file doesn't exist, is invalid, or the template is empty. - */ -export function loadBranchNamingConfig( - dataDir: string, -): BranchNamingConfig | null { - const configPath = join(dataDir, "branch-naming.json"); - - if (!existsSync(configPath)) return null; - - let raw: unknown; - try { - raw = JSON.parse(readFileSync(configPath, "utf8")); - } catch { - return null; - } - - if (typeof raw !== "object" || raw === null) return null; - - const template = (raw as Record).template; - if (typeof template !== "string" || !template.trim()) return null; - - return { template: template.trim() }; -} - -// ─── Slug helpers ──────────────────────────────────────────────────────────── - -function mechanicalSlug(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); -} - -function slugifyTitle(title: string, maxLen = 50): string { - const slug = mechanicalSlug(title); - return slug.length <= maxLen ? slug : truncateAtDash(slug, maxLen); -} - -function truncateAtDash(slug: string, maxChars: number): string { - const lastDash = slug.lastIndexOf("-", maxChars); - return lastDash > 0 ? slug.slice(0, lastDash) : slug.slice(0, maxChars); -} - -// ─── Variable names (for error messages) ───────────────────────────────────── - -const IDENTIFIER = "identifier"; -const TEAM_PREFIX = "teamPrefix"; -const TICKET_NUMBER = "ticketNumber"; -const TITLE_SLUG = "titleSlug"; -const LLM_SLUG = "llmSlug"; - -// ─── Template resolver ─────────────────────────────────────────────────────── - -const VAR_RE = /\$\{([a-zA-Z]+(?::[a-zA-Z0-9]+)?)\}/g; - -/** - * Resolve a branch name from a Linear ticket using a configurable template. - * - * Supported variables: - * ${identifier} — full Linear identifier, lowercased (e.g. "cv-1287") - * ${teamPrefix} — the team portion of the identifier (e.g. "cv") - * ${ticketNumber} — the numeric portion of the identifier (e.g. "1287") - * ${titleSlug} — mechanical slug of the ticket title - * ${llmSlug:N} — LLM-generated slug, max N chars; falls back to - * truncated mechanical slug on LLM failure - * - * When `config` is null, falls back to `${identifier}-${titleSlug}`. - */ -export async function resolveBranchName( - ticket: LinearTicket, - config: BranchNamingConfig | null, -): Promise { - const template = config?.template ?? "${identifier}-${titleSlug}"; - - let result = ""; - let lastIndex = 0; - let match: RegExpExecArray | null; - - // Reset regex state - VAR_RE.lastIndex = 0; - - while ((match = VAR_RE.exec(template)) !== null) { - // Append literal text before this variable - result += template.slice(lastIndex, match.index); - - const raw = match[1]!; - let varName: string; - let llmMaxChars: number | undefined; - - if (raw.startsWith("llmSlug")) { - const colonIdx = raw.indexOf(":"); - if (colonIdx === -1) { - throw new Error( - `Invalid variable \${${raw}}. \${${LLM_SLUG}} requires :N (e.g. \${${LLM_SLUG}}:10)`, - ); - } - const numStr = raw.slice(colonIdx + 1); - const n = parseInt(numStr, 10); - if (!Number.isInteger(n) || n <= 0) { - throw new Error( - `Invalid variable \${${raw}}. \${${LLM_SLUG}}:N requires a positive integer N`, - ); - } - varName = LLM_SLUG; - llmMaxChars = n; - } else { - varName = raw; - } - - let value: string; - - switch (varName) { - case "identifier": - value = ticket.identifier.toLowerCase(); - break; - case "teamPrefix": { - const parts = ticket.identifier.split("-"); - value = parts[0]?.toLowerCase() ?? ""; - break; - } - case "ticketNumber": { - const parts = ticket.identifier.split("-"); - value = parts.slice(1).join("-") ?? ""; - break; - } - case "titleSlug": - value = slugifyTitle(ticket.title); - break; - case "llmSlug": { - value = await resolveLlmSlug(ticket.title, llmMaxChars!); - break; - } - default: - throw new Error( - `Unknown variable \${${raw}} in branch naming template. ` + - `Supported: ${IDENTIFIER}, ${TEAM_PREFIX}, ${TICKET_NUMBER}, ${TITLE_SLUG}, ${LLM_SLUG}:N`, - ); - } - - result += value; - lastIndex = match.index + match[0].length; - } - - // Append remaining literal text after the last variable - result += template.slice(lastIndex); - - // Strip leading/trailing slashes and whitespace - result = result.replace(/^\/+|\/+$/g, "").trim(); - - if (!result) { - // Ultimate fallback — should never reach here with valid config - return `${ticket.identifier.toLowerCase()}-${slugifyTitle(ticket.title)}`; - } - - return result; -} - -async function resolveLlmSlug(title: string, maxChars: number): Promise { - try { - const { llmSummarize } = await import("./llm.ts"); - return await llmSummarize(title, maxChars); - } catch (err) { - if (err instanceof LlmUnavailableError || err instanceof LlmEmptyResponseError) { - // Fall back to mechanical slug, truncated to maxChars at a word boundary - process.stderr.write(` ${dim}llm unavailable (${err.message}), using mechanical slug${reset}\n`); - const slug = mechanicalSlug(title); - return slug.length <= maxChars ? slug : truncateAtDash(slug, maxChars); - } - throw err; - } -} diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index 64e5b32b..3ed21c8a 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -7,47 +7,6 @@ */ import type { CommandNode } from "./command-tree.ts"; -const branchSubcommands: Record = { - switch: { - description: "Checkout with stash handling", - module: "./commands/branch.ts", - fn: "switchBranch", - context: "worktree", - aliases: ["sw"], - args: [], - }, - create: { - description: "From Linear ticket or scratch", - module: "./commands/branch.ts", - fn: "createBranchFlow", - context: "worktree", - aliases: ["new"], - args: [ - { name: "Branch name", type: "text", placeholder: "feature/my-branch", hint: "Skip the interactive picker and create this branch directly" }, - { name: "From", flag: "--from", type: "text", placeholder: "origin/main", hint: "Start point for the new branch" }, - ], - }, - rename: { - description: "Rename the current branch", - module: "./commands/branch.ts", - fn: "renameBranch", - context: "worktree", - aliases: ["mv"], - args: [], - }, - clean: { - description: "Delete stale branches interactively", - module: "./commands/branch-clean.ts", - fn: "cleanBranches", - context: "worktree", - requiresTTY: true, - args: [ - { name: "Dry run", flag: "--dry-run", type: "boolean", default: false, hint: "Preview deletions without deleting (alias -n)" }, - { name: "Force", flag: "--force", type: "boolean", default: false, hint: "Skip the open-MR warning and force-delete (alias -f)" }, - ], - }, -}; - const eventsSubcommands: Record = { emit: { description: "Publish an event to a topic", @@ -147,7 +106,7 @@ const commitNode: CommandNode = { export const TREE: Record = { git: { - description: "Git operations (rebase, reset, branch, commit, backup)", + description: "Git operations (rebase, reset, commit, backup)", subcommands: { rebase: { description: "Smart rebase onto origin/master with auto-resolve", @@ -202,10 +161,6 @@ export const TREE: Record = { }, }, }, - branch: { - description: "Branch management (switch, create, rename, clean)", - subcommands: branchSubcommands, - }, commit: commitNode, backup: { description: "Back up the current branch", @@ -272,59 +227,6 @@ export const TREE: Record = { }, }, - mr: { - description: "Merge request operations (GitLab); `pr` works too", - aliases: ["pr"], - subcommands: { - open: { - description: "Open a bare MR on the current branch via glab", - module: "./commands/mr.ts", - fn: "openCommand", - context: "worktree", - args: [ - { name: "Target branch", flag: "--target", type: "text", placeholder: "master", hint: "Target branch for the MR (defaults to config or repo default)" }, - { name: "Title", flag: "--title", type: "text", placeholder: "...", hint: "MR title (defaults to the last commit subject)" }, - { name: "Draft", flag: "--draft", type: "boolean", default: false, hint: "Open as a draft MR" }, - { name: "No draft", flag: "--no-draft", type: "boolean", default: false, hint: "Force non-draft even if config defaults to draft" }, - { name: "Description", flag: "--description", type: "text", placeholder: "...", hint: "Inline description body" }, - { name: "Description file", flag: "--description-file", type: "text", placeholder: "path or -", hint: "Read description from a file (- for stdin)" }, - { name: "Fill", flag: "--fill", type: "boolean", default: false, hint: "Let glab fill the description from commits" }, - { name: "Dry run", flag: "--dry-run", type: "boolean", default: false, hint: "Preview the glab command without creating the MR" }, - { name: "Web", flag: "--web", type: "boolean", default: false, hint: "Open the new MR in the browser" }, - ], - }, - describe: { - description: "Draft an MR description with an agent (streams to stdout)", - module: "./commands/mr.ts", - fn: "describeCommand", - context: "worktree", - args: [ - { name: "Target branch", flag: "--target", type: "text", placeholder: "master", hint: "Target branch to diff against" }, - { name: "Inline guidance", flag: "--inline", type: "text", placeholder: "...", hint: "Extra inline guidance appended to the prompt" }, - { name: "Debug", flag: "--debug", type: "boolean", default: false, hint: "Print the assembled prompt instead of calling the agent" }, - ], - }, - ship: { - description: "All-in-one: push + describe + open (the daily driver)", - module: "./commands/mr.ts", - fn: "shipCommand", - context: "worktree", - args: [ - { name: "Target branch", flag: "--target", type: "text", placeholder: "master", hint: "Target branch for the MR" }, - { name: "Title", flag: "--title", type: "text", placeholder: "...", hint: "MR title (overrides the agent-drafted title)" }, - { name: "Draft", flag: "--draft", type: "boolean", default: false, hint: "Open as a draft MR" }, - { name: "No draft", flag: "--no-draft", type: "boolean", default: false, hint: "Force non-draft even if config defaults to draft" }, - { name: "Inline guidance", flag: "--inline", type: "text", placeholder: "...", hint: "Extra inline guidance appended to the description prompt" }, - { name: "Debug", flag: "--debug", type: "boolean", default: false, hint: "Print the assembled prompt and stop before creating the MR" }, - { name: "Dry run", flag: "--dry-run", type: "boolean", default: false, hint: "Rehearse push + MR creation without doing either" }, - { name: "Web", flag: "--web", type: "boolean", default: false, hint: "Open the new MR in the browser" }, - { name: "Remote", flag: "--remote", type: "text", placeholder: "origin", hint: "Remote to push to (forwarded to the push step)" }, - { name: "No verify", flag: "--no-verify", type: "boolean", default: false, hint: "Skip pre-push hooks (forwarded to the push step)" }, - ], - }, - }, - }, - sync: { description: "Sync branches: rebase onto master + push (daily routine)", module: "./commands/sync.ts", @@ -349,28 +251,6 @@ export const TREE: Record = { }, }, - // Aliases: rt branch and rt commit still work as before - branch: { - description: "Branch management (switch, create, rename, clean)", - subcommands: branchSubcommands, - }, - - turbo: { - description: "Turborepo operations", - subcommands: { - build: { - description: "Interactive turbo build selector", - module: "./commands/build-select.ts", - fn: "buildSelect", - context: "worktree", - requiresTTY: true, - args: [ - { name: "Force", flag: "--force", type: "boolean", default: false, hint: "Force turbo to ignore its build cache" }, - ], - }, - }, - }, - hooks: { description: "Toggle git hooks on/off (husky)", module: "./commands/hooks.ts", @@ -515,42 +395,6 @@ export const TREE: Record = { ], }, - open: { - description: "Open external pages for the current branch", - subcommands: { - mr: { - description: "GitLab merge request", - module: "./commands/open.ts", - fn: "openMR", - context: "worktree", - args: [], - }, - pipeline: { - description: "GitLab CI pipelines", - module: "./commands/open.ts", - fn: "openPipeline", - context: "worktree", - aliases: ["ci"], - args: [], - }, - repo: { - description: "Repository page", - module: "./commands/open.ts", - fn: "openRepo", - context: "worktree", - args: [], - }, - ticket: { - description: "Linear ticket for this branch", - module: "./commands/open.ts", - fn: "openTicket", - context: "worktree", - aliases: ["linear"], - args: [], - }, - }, - }, - cd: { description: "Worktree/repo directory picker", module: "./commands/cd.ts", @@ -573,51 +417,6 @@ export const TREE: Record = { ], }, - code: { - description: "Open a worktree in your preferred editor", - module: "./commands/code.ts", - fn: "openInEditor", - requiresTTY: true, - args: [ - { name: "Pick", flag: "--pick", type: "boolean", default: false, hint: "Force the worktree/repo picker instead of using the current repo (alias -p)" }, - ], - }, - - agent: { - description: "Launch a CLI coding agent (Claude Code, Cursor, etc.) in a worktree", - module: "./commands/agent.ts", - fn: "launchAgent", - requiresTTY: true, - args: [ - { name: "Here", flag: "--here", type: "boolean", default: false, hint: "Use the exact current directory instead of resolving a repo/worktree (alias -h)" }, - { name: "Pick", flag: "--pick", type: "boolean", default: false, hint: "Force the repo/worktree picker before launching (alias -p)" }, - ], - }, - - workspace: { - description: "VS Code workspace management", - subcommands: { - sync: { - description: "Auto-sync workspace file across worktrees", - module: "./commands/workspace.ts", - fn: "workspaceSyncCommand", - context: "repo", - requiresTTY: true, - args: [ - { name: "Status", flag: "--status", type: "boolean", default: false, hint: "Show current sync config and watcher state" }, - { name: "Off", flag: "--off", type: "boolean", default: false, hint: "Disable syncing and remove the file watcher" }, - ], - }, - }, - }, - - park: { - description: "Deprecated — replaced by rt worktree", - module: "./commands/worktree.ts", - fn: "parkDeprecated", - args: [], - }, - worktree: { description: "Worktree lifecycle (provision/dispose/list) + worktree-wide operations", module: "./commands/worktree.ts", @@ -703,41 +502,6 @@ export const TREE: Record = { }, }, - doppler: { - description: "Per-repo Doppler template + sync into ~/.doppler/.doppler.yaml", - subcommands: { - init: { - description: "Capture existing Doppler entries for this repo into a template", - module: "./commands/doppler.ts", - fn: "initCommand", - context: "repo", - args: [], - }, - sync: { - description: "Apply the template across all worktrees (manual trigger)", - module: "./commands/doppler.ts", - fn: "syncCommand", - context: "repo", - args: [], - }, - status: { - description: "Show template vs. actual config per worktree", - module: "./commands/doppler.ts", - fn: "statusCommand", - context: "repo", - args: [], - }, - edit: { - description: "Open the template in $EDITOR", - module: "./commands/doppler.ts", - fn: "editCommand", - context: "repo", - requiresTTY: true, - args: [], - }, - }, - }, - daemon: { description: "Manage the rt background daemon", subcommands: { diff --git a/lib/command-tree.ts b/lib/command-tree.ts index bc617e2d..dd989299 100644 --- a/lib/command-tree.ts +++ b/lib/command-tree.ts @@ -3,13 +3,13 @@ * * Every command registers as a node in a tree. The dispatcher handles: * - Screen clearing between steps - * - Breadcrumb headers (rt › branch › switch) + * - Breadcrumb headers (rt › daemon › status) * - fzf pickers for subcommand navigation * - Context resolution (repo/worktree identity) * - TTY guards * - Lazy module loading for fast startup * - * Direct args still work: `rt branch switch` traverses silently. + * Direct args still work: `rt daemon status` traverses silently. * No args at a branch node → shows picker. * ctrl-up at a subtree picker goes up one tree level (root picker exits, * same as Esc). Tree back-nav never crosses into a running command. @@ -63,7 +63,7 @@ export interface CommandNode { /** Subcommands — makes this a branch node (shows picker if no args). */ subcommands?: Record; - /** Lazy module path for handler (e.g. "./commands/branch.ts"). */ + /** Lazy module path for handler (e.g. "./commands/sync.ts"). */ module?: string; /** Function name to call in the module (default: "run"). */ @@ -113,7 +113,7 @@ export interface CommandNode { /** * Navigate the command tree and execute the resolved handler. * - * - Direct args: `rt branch switch` → resolve branch → resolve switch → execute + * - Direct args: `rt daemon status` → resolve daemon → resolve status → execute * - No args at branch: show fzf picker * - Leaf node: clear screen, show breadcrumb, execute handler */ diff --git a/lib/daemon.ts b/lib/daemon.ts index 99f68105..e71da86a 100644 --- a/lib/daemon.ts +++ b/lib/daemon.ts @@ -44,7 +44,6 @@ import { startSocketServer } from "./daemon/socket-server.ts"; import { startApiServer, broadcast } from "./daemon/api-server.ts"; import { loadCronConfig, startCron } from "./daemon/cron.ts"; import { startPollers } from "./daemon/pollers.ts"; -import { restoreWatchers } from "./daemon/workspace-sync.ts"; import { initFreshness, reconcileFreshness, @@ -282,13 +281,6 @@ export function startDaemon(): void { // Discover and watch repos hooksGuard.refreshWatchedRepos(); - // Restore workspace sync watchers - try { - restoreWatchers(loadRepoIndex()); - } catch (err) { - log.error({ err }, "workspace-sync: failed to restore watchers"); - } - // Watch repos.json for changes (new repos added) if (existsSync(REPOS_JSON_PATH)) { watch(REPOS_JSON_PATH, () => { diff --git a/lib/daemon/__tests__/workspace-sync.test.ts b/lib/daemon/__tests__/workspace-sync.test.ts deleted file mode 100644 index 446979f4..00000000 --- a/lib/daemon/__tests__/workspace-sync.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * workspace-sync unit tests. - * - * Covers deep-merge semantics of syncWorkspaceFile (preserveKeys round-trip), - * worktree discovery fallback when `git worktree list` fails, and the - * ensureGitExclude/removeGitExclude round-trip against a real temp git repo. - */ - -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { - syncWorkspaceFile, - getWorktreePaths, - ensureGitExclude, - removeGitExclude, -} from "../workspace-sync.ts"; - -let tmp: string; - -beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "rt-ws-sync-test-")); -}); - -afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); -}); - -// ── syncWorkspaceFile ──────────────────────────────────────────────────────── - -describe("syncWorkspaceFile — deep merge with preserveKeys", () => { - test("non-preserved keys take source value, preserved keys retain target", () => { - const source = join(tmp, "src.code-workspace"); - const targetA = join(tmp, "a.code-workspace"); - const targetB = join(tmp, "b.code-workspace"); - - writeFileSync(source, JSON.stringify({ - folders: [{ path: "." }], - settings: { - "editor.fontSize": 14, - "peacock.color": "#111111", - }, - })); - writeFileSync(targetA, JSON.stringify({ - folders: [{ path: "old" }], - settings: { - "editor.fontSize": 99, - "peacock.color": "#aaaaaa", - }, - })); - writeFileSync(targetB, JSON.stringify({ - folders: [{ path: "old-b" }], - settings: { - "editor.fontSize": 12, - "peacock.color": "#bbbbbb", - }, - })); - - const result = syncWorkspaceFile( - source, - [source, targetA, targetB], - ["peacock.color"], - ); - - expect(result.synced).toBe(2); - - const a = JSON.parse(readFileSync(targetA, "utf8")); - expect(a.settings["editor.fontSize"]).toBe(14); // non-preserved → source - expect(a.settings["peacock.color"]).toBe("#aaaaaa"); // preserved → target - - const b = JSON.parse(readFileSync(targetB, "utf8")); - expect(b.settings["editor.fontSize"]).toBe(14); - expect(b.settings["peacock.color"]).toBe("#bbbbbb"); - - // Result includes per-target color summary - expect(result.results.map((r) => r.color).sort()).toEqual(["#aaaaaa", "#bbbbbb"]); - }); - - test("no-op when target content already matches merged output (mtime unchanged)", async () => { - // Regression: without this guard, every sync round bumps mtime, which - // VS Code reloads as a workspace change and creates a feedback loop via - // the daemon's fs.watch watchers. - const source = join(tmp, "src.code-workspace"); - const target = join(tmp, "t.code-workspace"); - - const sourceContent = { - folders: [{ path: "." }], - settings: { "editor.fontSize": 14, "peacock.color": "#111111" }, - }; - writeFileSync(source, JSON.stringify(sourceContent)); - - // Seed target with canonical post-sync form (source content + target's own peacock). - const canonical = { ...sourceContent, settings: { ...sourceContent.settings, "peacock.color": "#aaaaaa" } }; - writeFileSync(target, JSON.stringify(canonical, null, 2) + "\n"); - - const mtimeBefore = statSync(target).mtimeMs; - // Bun.sleep ensures any actual write would bump mtime detectably. - await Bun.sleep(20); - - const result = syncWorkspaceFile(source, [source, target], ["peacock.color"]); - - expect(result.synced).toBe(0); - expect(result.results).toEqual([]); - expect(statSync(target).mtimeMs).toBe(mtimeBefore); - }); - - test("skips nonexistent targets silently (no throw)", () => { - const source = join(tmp, "src.code-workspace"); - writeFileSync(source, JSON.stringify({ settings: { foo: 1 } })); - - const missing = join(tmp, "does-not-exist.code-workspace"); - const result = syncWorkspaceFile(source, [missing], []); - expect(result.synced).toBe(0); - expect(result.results).toEqual([]); - }); - - test("malformed JSON source returns { synced: 0 }", () => { - const source = join(tmp, "bad.code-workspace"); - writeFileSync(source, "this is not json {{{"); - - const target = join(tmp, "t.code-workspace"); - writeFileSync(target, JSON.stringify({ settings: {} })); - - const result = syncWorkspaceFile(source, [target], []); - - expect(result.synced).toBe(0); - expect(result.results).toEqual([]); - }); -}); - -// ── getWorktreePaths ──────────────────────────────────────────────────────── - -describe("getWorktreePaths", () => { - test("falls back to [repoPath] when `git worktree list` fails", () => { - // tmp is not a git repo → `git worktree list` fails → fallback to [tmp] - const paths = getWorktreePaths(tmp); - expect(paths).toEqual([tmp]); - }); - - test("returns real worktree listing inside an initialized git repo", async () => { - const proc = Bun.spawn(["git", "init", "-q", tmp], { stdout: "pipe", stderr: "pipe" }); - await proc.exited; - - const paths = getWorktreePaths(tmp); - expect(paths.length).toBeGreaterThanOrEqual(1); - // Path comparison tolerates macOS /private/ symlink prefix - const firstIncludes = paths[0]!.endsWith(tmp) || tmp.endsWith(paths[0]!); - expect(firstIncludes).toBe(true); - }); -}); - -// ── ensureGitExclude / removeGitExclude ───────────────────────────────────── - -describe("ensureGitExclude / removeGitExclude round-trip", () => { - test("add → verify present → remove → verify absent", async () => { - const proc = Bun.spawn(["git", "init", "-q", tmp], { stdout: "pipe", stderr: "pipe" }); - await proc.exited; - - const fileName = "project.code-workspace"; - ensureGitExclude(tmp, fileName); - - const excludePath = join(tmp, ".git", "info", "exclude"); - expect(existsSync(excludePath)).toBe(true); - let contents = readFileSync(excludePath, "utf8"); - expect(contents.split("\n").some((l) => l.trim() === fileName)).toBe(true); - - // ensure is idempotent — no duplicate entry - ensureGitExclude(tmp, fileName); - contents = readFileSync(excludePath, "utf8"); - const matches = contents.split("\n").filter((l) => l.trim() === fileName); - expect(matches.length).toBe(1); - - // now remove and verify - removeGitExclude(tmp, fileName); - const afterRemove = readFileSync(excludePath, "utf8"); - expect(afterRemove.split("\n").some((l) => l.trim() === fileName)).toBe(false); - }); - - test("removeGitExclude on missing exclude file is a no-op", () => { - // No .git at all — should silently return without throwing. - expect(() => removeGitExclude(tmp, "whatever.code-workspace")).not.toThrow(); - }); - - test("ensureGitExclude creates info/exclude if missing", async () => { - const proc = Bun.spawn(["git", "init", "-q", tmp], { stdout: "pipe", stderr: "pipe" }); - await proc.exited; - - // Delete the auto-created exclude and its parent to force creation - const infoDir = join(tmp, ".git", "info"); - rmSync(infoDir, { recursive: true, force: true }); - expect(existsSync(infoDir)).toBe(false); - - ensureGitExclude(tmp, "synced.code-workspace"); - - const excludePath = join(infoDir, "exclude"); - expect(existsSync(excludePath)).toBe(true); - const contents = readFileSync(excludePath, "utf8"); - expect(contents).toContain("synced.code-workspace"); - }); -}); diff --git a/lib/daemon/command-router.ts b/lib/daemon/command-router.ts index ca125c5b..420d850c 100644 --- a/lib/daemon/command-router.ts +++ b/lib/daemon/command-router.ts @@ -10,7 +10,6 @@ import type { HandlerContext, HandlerMap, TypedHandlers } from "./handlers/types import { createCacheHandlers } from "./handlers/cache.ts"; import { createHooksHandlers } from "./handlers/hooks.ts"; import { createStatusHandlers } from "./handlers/status.ts"; -import { createWorkspaceHandlers } from "./handlers/workspace.ts"; import { createMRHandlers } from "./handlers/mr.ts"; import { createWorktreeHandlers, type WorktreeHandlerOpts } from "./handlers/worktree.ts"; import { createDiscussionHandlers } from "./handlers/discussions.ts"; @@ -44,7 +43,6 @@ export function buildRoutedHandlers(opts: { ...createCacheHandlers(ctx), ...createHooksHandlers(ctx), ...createStatusHandlers(ctx), - ...createWorkspaceHandlers(ctx), ...createMRHandlers(ctx, broadcast), ...createWorktreeHandlers(ctx, opts.worktree), ...createDiscussionHandlers(ctx, broadcast), diff --git a/lib/daemon/doppler-sync.ts b/lib/daemon/doppler-sync.ts index 7d4a9f25..1f3f8cb1 100644 --- a/lib/daemon/doppler-sync.ts +++ b/lib/daemon/doppler-sync.ts @@ -3,9 +3,10 @@ * each repo's `~/.mattstack/rt/repos//doppler-template.yaml` across all worktrees. * * Called once per cache-refresh tick by the daemon (`refreshCacheImpl` in - * `lib/daemon.ts`) and on demand by `rt doppler sync`. The reconciler is - * additive — it only writes missing entries and never overwrites existing - * ones, so user overrides via `doppler setup -p X -c Y` are preserved. + * `lib/daemon.ts`) and once when a new worktree is created (`lib/worktree/create.ts`). + * The reconciler is additive — it only writes missing entries and never + * overwrites existing ones, so user overrides via `doppler setup -p X -c Y` + * are preserved. */ import { existsSync } from "fs"; diff --git a/lib/daemon/handlers/types.ts b/lib/daemon/handlers/types.ts index 7b912666..02e99f88 100644 --- a/lib/daemon/handlers/types.ts +++ b/lib/daemon/handlers/types.ts @@ -47,7 +47,7 @@ export interface HandlerContext { /** Async refresh from upstream (enrich + Linear batch). Fire-and-forget safe. */ refreshCache: () => Promise; - // ── Extensions for hooks/status/workspace handlers ────────────────────────── + // ── Extensions for hooks/status handlers ───────────────────────────────── /** Daemon logger; handlers write side-effect logs through this. */ log: Logger; diff --git a/lib/daemon/handlers/workspace.ts b/lib/daemon/handlers/workspace.ts deleted file mode 100644 index b11ebc2f..00000000 --- a/lib/daemon/handlers/workspace.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Workspace-sync IPC handlers. Keeps `.code-workspace` (or similar) - * files consistent across a repo's worktrees by watching one source and - * writing through to siblings, preserving per-worktree keys like peacock. - * - * workspace:sync:start — enable sync, seed from an initial source - * workspace:sync:stop — disable sync, unregister watcher - * workspace:sync:status — current watcher status - * workspace:sync:trigger — force a sync round using the latest-mtime file - */ - -import { statSync } from "fs"; -import { join } from "path"; -import type { HandlerContext, HandlerMap } from "./types.ts"; -import { - startWatching, stopWatching, getWatcherStatus, loadSyncConfig, saveSyncConfig, - ensureGitExclude, removeGitExclude, getWorktreePaths, syncWorkspaceFile, - type WorkspaceSyncConfig, -} from "../workspace-sync.ts"; - -export function createWorkspaceHandlers(ctx: HandlerContext): HandlerMap { - return { - "workspace:sync:start": async (payload) => { - const { repo, repoPath, fileName, sourcePath } = payload as { - repo: string; repoPath: string; fileName: string; sourcePath: string; - }; - if (!repo || !repoPath || !fileName) { - return { ok: false, error: "missing repo, repoPath, or fileName" }; - } - - const config: WorkspaceSyncConfig = { - fileName, - enabled: true, - preserveKeys: [ - "peacock.color", - "peacock.favoriteColors", - "workbench.colorCustomizations", - ], - }; - - // Save config - saveSyncConfig(repo, config); - - // Add to git exclude - ensureGitExclude(repoPath, fileName); - - // Do initial sync from the specified source - const worktrees = getWorktreePaths(repoPath); - const targetPaths = worktrees.map(wt => join(wt, fileName)); - const result = syncWorkspaceFile( - sourcePath || join(repoPath, fileName), - targetPaths, - config.preserveKeys, - ); - - config.lastSyncAt = new Date().toISOString(); - config.lastSyncSource = sourcePath; - saveSyncConfig(repo, config); - - // Start watching - startWatching(repo, repoPath, config); - - return { ok: true, data: result }; - }, - - "workspace:sync:stop": async (payload) => { - const { repo } = payload as { repo: string }; - if (!repo) return { ok: false, error: "missing repo" }; - - stopWatching(repo); - - // Disable config - const config = loadSyncConfig(repo); - if (config) { - config.enabled = false; - saveSyncConfig(repo, config); - } - - // Remove from git exclude - const repos = ctx.repoIndex(); - const repoPath = repos[repo]; - if (repoPath && config) { - removeGitExclude(repoPath, config.fileName); - } - - return { ok: true }; - }, - - "workspace:sync:status": async (payload) => { - const { repo } = payload as { repo: string }; - if (!repo) return { ok: false, error: "missing repo" }; - return { ok: true, data: getWatcherStatus(repo) }; - }, - - "workspace:sync:trigger": async (payload) => { - const { repo } = payload as { repo: string }; - if (!repo) return { ok: false, error: "missing repo" }; - - const config = loadSyncConfig(repo); - if (!config) return { ok: false, error: "no sync config for this repo" }; - - const repos = ctx.repoIndex(); - const repoPath = repos[repo]; - if (!repoPath) return { ok: false, error: "unknown repo" }; - - // Find the most recently modified copy as source - const worktrees = getWorktreePaths(repoPath); - let latestPath = ""; - let latestMtime = 0; - for (const wt of worktrees) { - const fp = join(wt, config.fileName); - try { - const mt = statSync(fp).mtimeMs; - if (mt > latestMtime) { latestMtime = mt; latestPath = fp; } - } catch { /* missing */ } - } - - if (!latestPath) return { ok: false, error: "no workspace files found" }; - - const targetPaths = worktrees.map(wt => join(wt, config.fileName)); - const result = syncWorkspaceFile(latestPath, targetPaths, config.preserveKeys); - - config.lastSyncAt = new Date().toISOString(); - config.lastSyncSource = latestPath; - saveSyncConfig(repo, config); - - return { ok: true, data: result }; - }, - }; -} diff --git a/lib/daemon/shutdown.ts b/lib/daemon/shutdown.ts index 535a2fcd..4b427433 100644 --- a/lib/daemon/shutdown.ts +++ b/lib/daemon/shutdown.ts @@ -8,7 +8,6 @@ import type { Server } from "bun"; import type { Logger } from "pino"; import { DAEMON_SOCK_PATH, DAEMON_PID_PATH } from "../daemon-config.ts"; import { clearWsClients } from "./api-server.ts"; -import { cleanupAllWatchers } from "./workspace-sync.ts"; import { disposeFreshness } from "./freshness.ts"; import { stopDiscussionsPoller } from "./discussions-poller.ts"; import type { HooksGuard } from "./hooks-guard.ts"; @@ -33,7 +32,6 @@ export function createCleanup(deps: ShutdownDeps): () => void { try { servers.api?.stop(true); } catch { /* */ } clearWsClients(); - try { cleanupAllWatchers(); } catch { /* */ } try { disposeFreshness(); } catch { /* */ } try { stopDiscussionsPoller(); } catch { /* */ } try { hooksGuard.closeAll(); } catch { /* */ } diff --git a/lib/daemon/workspace-sync.ts b/lib/daemon/workspace-sync.ts deleted file mode 100644 index 6f9fe0c1..00000000 --- a/lib/daemon/workspace-sync.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * Workspace Sync — watches a .code-workspace file across all worktrees - * and auto-syncs changes, preserving per-worktree peacock settings. - * - * Part of the rt daemon. Adapted from scripts/sync-workspace.ts. - */ - -import { - existsSync, readFileSync, writeFileSync, mkdirSync, - watch, statSync, readdirSync, appendFileSync, - type FSWatcher, -} from "fs"; -import { join, resolve, basename, dirname } from "path"; -import { execSync } from "child_process"; -import { repoDataDir } from "../rt-paths.ts"; -import { readJson, writeJson } from "../json-store.ts"; -import { getDaemonLogger } from "../daemon-logger.ts"; -const log = (await getDaemonLogger()).childLogger("workspace-sync"); - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface WorkspaceSyncConfig { - fileName: string; - enabled: boolean; - preserveKeys: string[]; - lastSyncAt?: string; - lastSyncSource?: string; -} - -interface WorkspaceSyncState { - config: WorkspaceSyncConfig; - watchers: FSWatcher[]; - repoName: string; -} - -// ─── JSONC Parse (strip comments) ──────────────────────────────────────────── - -function parseJsonc(text: string): any { - const stripped = text - .replace(/\/\/.*$/gm, "") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/,\s*([\]}])/g, "$1"); // trailing commas - return JSON.parse(stripped); -} - -// ─── Config persistence ───────────────────────────────────────────────────── - -function configPath(repoName: string): string { - return join(repoDataDir(repoName), "workspace-sync.json"); -} - -export function loadSyncConfig(repoName: string): WorkspaceSyncConfig | null { - const raw = readJson(configPath(repoName), null); - if (!raw || !raw.enabled) return null; - return raw; -} - -export function saveSyncConfig(repoName: string, config: WorkspaceSyncConfig): void { - writeJson(configPath(repoName), config); -} - -// ─── Git exclude management ───────────────────────────────────────────────── - -function gitExcludePath(repoPath: string): string { - const dotGit = join(repoPath, ".git"); - try { - const stat = statSync(dotGit); - if (stat.isFile()) { - // Worktree: .git is a file → resolve to main repo's info/exclude - const content = readFileSync(dotGit, "utf8").trim(); - const gitdir = content.replace("gitdir: ", ""); - const mainGitDir = resolve(repoPath, gitdir, "..", ".."); - return join(mainGitDir, "info", "exclude"); - } - } catch { /* */ } - return join(dotGit, "info", "exclude"); -} - -export function ensureGitExclude(repoPath: string, fileName: string): void { - const excludePath = gitExcludePath(repoPath); - const dir = dirname(excludePath); - mkdirSync(dir, { recursive: true }); - - try { - const content = existsSync(excludePath) - ? readFileSync(excludePath, "utf8") - : ""; - if (!content.split("\n").some(line => line.trim() === fileName)) { - appendFileSync(excludePath, `\n${fileName}\n`); - } - } catch { /* best-effort */ } -} - -export function removeGitExclude(repoPath: string, fileName: string): void { - const excludePath = gitExcludePath(repoPath); - if (!existsSync(excludePath)) return; - - try { - const lines = readFileSync(excludePath, "utf8").split("\n"); - const filtered = lines.filter(line => line.trim() !== fileName); - writeFileSync(excludePath, filtered.join("\n")); - } catch { /* best-effort */ } -} - -// ─── Worktree discovery ───────────────────────────────────────────────────── - -export function getWorktreePaths(repoPath: string): string[] { - try { - const output = execSync("git worktree list --porcelain", { - cwd: repoPath, - encoding: "utf8", - stdio: "pipe", - }); - return output - .split("\n") - .filter(l => l.startsWith("worktree ")) - .map(l => l.replace("worktree ", "").trim()); - } catch { - return [repoPath]; - } -} - -// ─── Core sync ─────────────────────────────────────────────────────────────── - -export function syncWorkspaceFile( - sourcePath: string, - targetPaths: string[], - preserveKeys: string[], -): { synced: number; results: Array<{ path: string; color?: string }> } { - let source: any; - try { - source = parseJsonc(readFileSync(sourcePath, "utf8")); - } catch (err) { - log.warn({ err }, `sync failed: cannot parse source ${sourcePath}`); - return { synced: 0, results: [] }; - } - - const results: Array<{ path: string; color?: string }> = []; - let synced = 0; - - for (const targetPath of targetPaths) { - if (targetPath === sourcePath) continue; - if (!existsSync(targetPath)) continue; - - let existingRaw: string; - try { - existingRaw = readFileSync(targetPath, "utf8"); - } catch { - continue; - } - - let target: any; - try { - target = parseJsonc(existingRaw); - } catch { - // Target is unparseable — overwrite from source (preserving nothing) - target = { settings: {} }; - } - - // Extract preserved settings from target BEFORE overwrite - const preserved: Record = {}; - for (const key of preserveKeys) { - if (target.settings?.[key] !== undefined) { - preserved[key] = target.settings[key]; - } - } - - // Deep-clone source and re-apply preserved keys - const merged = JSON.parse(JSON.stringify(source)); - if (!merged.settings) merged.settings = {}; - for (const [key, value] of Object.entries(preserved)) { - merged.settings[key] = value; - } - - const mergedText = JSON.stringify(merged, null, 2) + "\n"; - - // Skip the write when content is already identical. Without this, every - // sync round bumps the target's mtime, which VS Code sees as a workspace - // change and reloads (resetting sidebar scroll). Worse, the write re- - // triggers our own fs.watch on that directory, ping-ponging writes - // between worktrees indefinitely. - if (existingRaw === mergedText) continue; - - writeFileSync(targetPath, mergedText); - - const color = preserved["peacock.color"] || undefined; - results.push({ path: targetPath, color }); - synced++; - } - - return { synced, results }; -} - -// ─── Find most recently modified workspace file ───────────────────────────── - -export interface WorkspaceCandidate { - filePath: string; - worktree: string; - fileName: string; - mtime: Date; -} - -export function findLatestWorkspaceFile( - worktrees: string[], -): WorkspaceCandidate | null { - let latest: WorkspaceCandidate | null = null; - - for (const wt of worktrees) { - try { - const files = readdirSync(wt).filter(f => f.endsWith(".code-workspace")); - for (const f of files) { - const filePath = join(wt, f); - try { - const stat = statSync(filePath); - if (!latest || stat.mtime > latest.mtime) { - latest = { - filePath, - worktree: wt, - fileName: f, - mtime: stat.mtime, - }; - } - } catch { /* stat failed */ } - } - } catch { /* readdir failed */ } - } - - return latest; -} - -// ─── Workspace Watcher ────────────────────────────────────────────────────── - -const activeWatchers = new Map(); - -export function startWatching( - repoName: string, - repoPath: string, - config: WorkspaceSyncConfig, -): void { - // Stop any existing watcher for this repo - stopWatching(repoName); - - const worktrees = getWorktreePaths(repoPath); - const watchers: FSWatcher[] = []; - let debounceTimer: ReturnType | null = null; - - for (const wt of worktrees) { - const filePath = join(wt, config.fileName); - // Watch the directory, not the file (same pattern as .git/config watching) - // VS Code writes atomically: write temp → rename → old inode gone - if (!existsSync(wt)) continue; - - try { - const watcher = watch(wt, (_event, filename) => { - if (filename !== config.fileName) return; - - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - const sourcePath = join(wt, config.fileName); - if (!existsSync(sourcePath)) return; - - const allTargets = worktrees.map(w => join(w, config.fileName)); - const { synced, results } = syncWorkspaceFile( - sourcePath, - allTargets, - config.preserveKeys, - ); - - if (synced > 0) { - config.lastSyncAt = new Date().toISOString(); - config.lastSyncSource = wt; - saveSyncConfig(repoName, config); - log.info(`${config.fileName} changed in ${basename(wt)} → synced to ${synced} worktree(s)`); - } - }, 500); // 500ms debounce for atomic writes - }); - - watchers.push(watcher); - } catch (err) { - log.warn({ err }, `failed to watch ${wt}`); - } - } - - activeWatchers.set(repoName, { config, watchers, repoName }); - log.info(`watching ${config.fileName} across ${watchers.length} worktree(s) for ${repoName}`); -} - -export function stopWatching(repoName: string): void { - const state = activeWatchers.get(repoName); - if (!state) return; - - for (const watcher of state.watchers) { - try { watcher.close(); } catch { /* */ } - } - activeWatchers.delete(repoName); - log.info(`stopped watching for ${repoName}`); -} - -export function getWatcherStatus(repoName: string): { - active: boolean; - config: WorkspaceSyncConfig | null; - watcherCount: number; -} { - const state = activeWatchers.get(repoName); - if (!state) { - return { active: false, config: loadSyncConfig(repoName), watcherCount: 0 }; - } - return { - active: true, - config: state.config, - watcherCount: state.watchers.length, - }; -} - -export function cleanupAllWatchers(): void { - for (const [repoName] of activeWatchers) { - stopWatching(repoName); - } -} - -// ─── Boot-time restore ────────────────────────────────────────────────────── - -export function restoreWatchers( - repos: Record, -): void { - for (const [repoName, repoPath] of Object.entries(repos)) { - if (!existsSync(repoPath)) continue; - const config = loadSyncConfig(repoName); - if (config?.enabled) { - startWatching(repoName, repoPath, config); - } - } -} diff --git a/lib/doppler-template.ts b/lib/doppler-template.ts index c524d181..89973488 100644 --- a/lib/doppler-template.ts +++ b/lib/doppler-template.ts @@ -72,8 +72,8 @@ import type { DopplerConfig } from "./doppler-config.ts"; * given worktree path. Returns relative-pathed template entries, sorted by * path for deterministic output. * - * Used by `rt doppler init` to bootstrap a template from whatever the user - * already had set up via `make initDoppler` or `doppler setup`. + * Templates are hand-authored today (a settings-driven bootstrap is planned + * as a future migration); this capture helper backs its test coverage. */ export function captureFromActualConfig( dopplerCfg: DopplerConfig, diff --git a/lib/git-ops.ts b/lib/git-ops.ts index 2c40bb90..7132dfd4 100644 --- a/lib/git-ops.ts +++ b/lib/git-ops.ts @@ -1,96 +1,11 @@ /** - * Portable git operations for rt branch management. + * Portable git operations shared by rt's daemon and CLI surfaces. * * Ported from worktree-context's git.ts — no VS Code dependencies. * Uses child_process for all git commands. - * - * Stash format uses GitHub Desktop's `!!GitHub_Desktop` marker - * for full interoperability with GitHub Desktop and worktree-context. - */ - -import { execSync, execFileSync } from "child_process"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface BranchInfo { - name: string; - ref: string; - isLocal: boolean; - commitEpoch: number; -} - -export interface DesktopStashEntry { - name: string; // e.g. "stash@{0}" - branchName: string; -} - -// ─── Branch listing ────────────────────────────────────────────────────────── - -/** - * List all local + remote branches, sorted by committer date (most recent first). - * Remote branches that have a matching local branch are deduplicated. */ -export function listAllBranches(cwd: string): BranchInfo[] { - try { - const stdout = execSync( - 'git branch -a --sort=-committerdate --format="%(refname:short)\t%(committerdate:unix)"', - { cwd, encoding: "utf8", stdio: "pipe" }, - ); - const seen = new Map(); - const results: BranchInfo[] = []; - - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - - const [ref, epochStr] = trimmed.split("\t"); - if (!ref) continue; - const commitEpoch = parseInt(epochStr ?? "0", 10) || 0; - if (ref.includes("->") || ref === "origin" || ref.endsWith("/HEAD")) continue; - - const isRemote = ref.startsWith("origin/"); - const displayName = isRemote ? ref.replace(/^origin\//, "") : ref; - if (displayName === "HEAD") continue; - - const existingIdx = seen.get(displayName); - if (existingIdx !== undefined) { - if (!isRemote && !results[existingIdx]!.isLocal) { - results[existingIdx] = { name: displayName, ref, isLocal: true, commitEpoch }; - } - continue; - } - seen.set(displayName, results.length); - results.push({ name: displayName, ref, isLocal: !isRemote, commitEpoch }); - } - - return results; - } catch { - return []; - } -} - -/** - * Get the set of branch names checked out in any worktree. - * These branches can't be switched to from another worktree. - */ -export function getWorktreeBranches(cwd: string): Set { - try { - const stdout = execSync("git worktree list --porcelain", { - cwd, encoding: "utf8", stdio: "pipe", - }); - const branches = new Set(); - for (const line of stdout.split("\n")) { - if (line.startsWith("branch ")) { - const ref = line.slice("branch ".length).trim(); - branches.add(ref.replace(/^refs\/heads\//, "")); - } - } - return branches; - } catch { - return new Set(); - } -} +import { execSync } from "child_process"; /** * Get the current branch name (or null if detached HEAD). @@ -105,10 +20,6 @@ export function getCurrentBranch(cwd: string): string | null { } } -// ─── Stash (GitHub Desktop-compatible) ─────────────────────────────────────── - -const DESKTOP_STASH_RE = /!!GitHub_Desktop<(.+)>$/; - /** * Check if working tree has uncommitted changes. */ @@ -123,68 +34,6 @@ export function hasUncommittedChanges(cwd: string): boolean { } } -/** - * Stash uncommitted changes with a GitHub Desktop-compatible marker. - * Interoperable with GitHub Desktop and worktree-context VS Code extension. - */ -export function stashChanges(cwd: string, branch: string): void { - const message = `!!GitHub_Desktop<${branch}>`; - execFileSync("git", ["stash", "push", "-u", "-m", message], { cwd, stdio: "pipe" }); -} - -/** - * Find the most recent GitHub Desktop-tagged stash entry for a branch. - */ -export function findDesktopStash(cwd: string, branch: string): DesktopStashEntry | null { - try { - const stdout = execSync("git stash list", { - cwd, encoding: "utf8", stdio: "pipe", - }); - for (const line of stdout.split("\n")) { - const match = DESKTOP_STASH_RE.exec(line); - if (match && match[1] === branch) { - const nameMatch = /^(stash@\{\d+\})/.exec(line); - if (nameMatch) { - return { name: nameMatch[1]!, branchName: branch }; - } - } - } - } catch { /* no stashes */ } - return null; -} - -/** Pop a specific stash entry by name (e.g. "stash@{0}"). */ -export function popStash(cwd: string, stashName: string): void { - execFileSync("git", ["stash", "pop", stashName], { cwd, stdio: "pipe" }); -} - -/** Drop a specific stash entry by name without applying it. */ -export function dropStash(cwd: string, stashName: string): void { - execFileSync("git", ["stash", "drop", stashName], { cwd, stdio: "pipe" }); -} - -// ─── Checkout / Branch creation ────────────────────────────────────────────── -// argv-array spawning throughout: branch names may legally contain $, -// backticks, and quotes, which double-quoted shell interpolation would -// expand or choke on. - -/** Checkout an existing branch. */ -export function checkoutBranch(cwd: string, branch: string): void { - execFileSync("git", ["checkout", branch], { cwd, stdio: "pipe" }); -} - -/** Create a new branch and check it out. */ -export function createBranch(cwd: string, branch: string, startPoint?: string): void { - const args = ["checkout", "-b", branch]; - if (startPoint) args.push(startPoint); - execFileSync("git", args, { cwd, stdio: "pipe" }); -} - -/** Fetch a specific remote branch. */ -export function fetchRemoteBranch(cwd: string, remote: string, branch: string): void { - execFileSync("git", ["fetch", remote, branch], { cwd, stdio: "pipe" }); -} - /** Detect whether origin/main or origin/master exists. */ export function getRemoteDefaultBranch(cwd: string): string | null { for (const candidate of ["origin/main", "origin/master"]) { diff --git a/lib/json-store.ts b/lib/json-store.ts index 1b99416b..aeef2d48 100644 --- a/lib/json-store.ts +++ b/lib/json-store.ts @@ -6,9 +6,9 @@ * Writes are write-temp-then-rename atomic — stores never tear (spec §4). * * Scope note: adopted in the per-repo store modules touched by the ~/.mattstack/rt/repos - * move (repo-index, workspace-sync, parking-lot). Other hand-rolled callsites - * are intentionally left alone — converting all of them is a separate, app-wide - * sweep, not part of the path refactor. + * move (repo-index, parking-lot). Other hand-rolled callsites are intentionally + * left alone — converting all of them is a separate, app-wide sweep, not part + * of the path refactor. */ import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs"; diff --git a/lib/linear.ts b/lib/linear.ts index 7e884058..ba8d7820 100644 --- a/lib/linear.ts +++ b/lib/linear.ts @@ -87,26 +87,6 @@ export interface LinearTicket { branchName: string | null; } -const ISSUE_BY_ID_QUERY = ` - query IssueById($id: String!) { - issue(id: $id) { - id identifier title description url branchName - state { name color } - } - } -`; - -const SEARCH_ISSUES_QUERY = ` - query SearchIssues($term: String!) { - searchIssues(term: $term, first: 5) { - nodes { - id identifier title description url branchName - state { name color } - } - } - } -`; - async function linearGraphql(apiKey: string, query: string, variables: Record): Promise { const response = await fetch(GRAPHQL_URL, { method: "POST", @@ -136,27 +116,6 @@ function toTicket(raw: Record): LinearTicket { }; } -export async function fetchTicket(apiKey: string, identifier: string): Promise { - try { - const data = (await linearGraphql(apiKey, ISSUE_BY_ID_QUERY, { id: identifier })) as { - issue: Record | null; - }; - if (data.issue) return toTicket(data.issue); - } catch { /* direct lookup failed */ } - - try { - const data = (await linearGraphql(apiKey, SEARCH_ISSUES_QUERY, { term: identifier })) as { - searchIssues: { nodes: Array> }; - }; - const match = data.searchIssues.nodes.find( - (n) => (n.identifier as string).toUpperCase() === identifier.toUpperCase(), - ); - return match ? toTicket(match) : null; - } catch { - return null; - } -} - /** * Fetch multiple Linear tickets in a single GraphQL request using aliased fields. * Each identifier gets its own `issue(id:)` lookup — all resolved in one HTTP round-trip. @@ -241,46 +200,6 @@ export function saveTeamConfig(teamId: string, teamKey: string): void { writeFileSync(SECRETS_PATH, JSON.stringify(secrets, null, 2)); } -// ─── Create issue ──────────────────────────────────────────────────────────── - -const CREATE_ISSUE_MUTATION = ` - mutation CreateIssue($teamId: String!, $title: String!, $description: String) { - issueCreate(input: { teamId: $teamId, title: $title, description: $description }) { - success - issue { - id identifier title description url branchName - state { name color } - } - } - } -`; - -export async function createIssue( - apiKey: string, - teamId: string, - title: string, - description?: string, -): Promise { - try { - const data = (await linearGraphql(apiKey, CREATE_ISSUE_MUTATION, { - teamId, - title, - description: description || undefined, - })) as { - issueCreate: { - success: boolean; - issue: Record | null; - }; - }; - if (data.issueCreate.success && data.issueCreate.issue) { - return toTicket(data.issueCreate.issue); - } - return null; - } catch (err) { - throw new Error(`Failed to create issue: ${err instanceof Error ? err.message : String(err)}`); - } -} - // ─── Fetch team tickets ────────────────────────────────────────────────────── // Tickets eligible for branch creation: assigned to the viewer, on the @@ -355,16 +274,7 @@ export async function searchTickets(apiKey: string, term: string): Promise(states: T[]): T | null if (started.length === 0) return null; return started.reduce((lowest, s) => (s.position < lowest.position ? s : lowest)); } - -const UPDATE_ISSUE_MUTATION = ` - mutation UpdateIssue($id: String!, $stateId: String!, $assigneeId: String!) { - issueUpdate(id: $id, input: { stateId: $stateId, assigneeId: $assigneeId }) { - success - } - } -`; - -export async function claimTicket(apiKey: string, issueId: string, teamId: string): Promise { - const data = (await linearGraphql(apiKey, VIEWER_AND_STATES_QUERY, { teamId })) as { - viewer: { id: string }; - team: { states: { nodes: WorkflowState[] } }; - }; - - const startedState = pickStartedState(data.team.states.nodes); - if (!startedState) throw new Error("No 'started' state found for team"); - - await linearGraphql(apiKey, UPDATE_ISSUE_MUTATION, { - id: issueId, - stateId: startedState.id, - assigneeId: data.viewer.id, - }); -} diff --git a/lib/llm.ts b/lib/llm.ts index 3b2710e5..69288535 100644 --- a/lib/llm.ts +++ b/lib/llm.ts @@ -123,41 +123,6 @@ export async function llmPrompt( return text; } -/** - * Generate a short, descriptive slug from `text` using the local LLM. - * - * The prompt instructs the model to produce a compact hyphenated slug; the - * result is post-processed and truncated to `maxChars` at a word boundary. - * - * On failure, throws — callers should catch and fall back to a mechanical slug. - */ -export async function llmSummarize(text: string, maxChars: number): Promise { - const prompt = `Turn this ticket title into a short hyphenated branch slug (${maxChars} chars max). Output only the slug:\n\n"${text}"`; - - // Don't set num_predict — thinking models (qwen3.6) use tokens for - // internal reasoning and need unconstrained budget to produce content. - const result = await llmPrompt( - "Reply with only a short hyphenated slug. No explanation.", - prompt, - ); - - // Post-process: lowercase, strip non-slug chars, collapse hyphens, trim to length - const slug = result - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, ""); - - return truncateAtWordBoundary(slug, maxChars); -} - -function truncateAtWordBoundary(slug: string, maxChars: number): string { - if (slug.length <= maxChars) return slug; - const lastDash = slug.lastIndexOf("-", maxChars); - // If a dash exists within the limit, cut there; otherwise take the first word - return lastDash > 0 ? slug.slice(0, lastDash) : slug.slice(0, maxChars); -} - /** * List locally installed Ollama models. * diff --git a/lib/module-registry.ts b/lib/module-registry.ts index 897ccbb5..f9e7df09 100644 --- a/lib/module-registry.ts +++ b/lib/module-registry.ts @@ -6,23 +6,16 @@ * command tree dispatcher works in both source and compiled modes. */ -import * as branch from "../commands/branch.ts"; -import * as branchClean from "../commands/branch-clean.ts"; -import * as buildSelect from "../commands/build-select.ts"; import * as commit from "../commands/commit.ts"; -import * as agent from "../commands/agent.ts"; import * as daemon from "../commands/daemon.ts"; import * as events from "../commands/events.ts"; import * as extension from "../commands/extension.ts"; import * as hooks from "../commands/hooks.ts"; -import * as open from "../commands/open.ts"; import * as port from "../commands/port.ts"; import * as run from "../commands/run.ts"; import * as settings from "../commands/settings.ts"; import * as settingsKeys from "../commands/settings-keys.ts"; import * as sync from "../commands/sync.ts"; -import * as workspace from "../commands/workspace.ts"; -import * as mr from "../commands/mr.ts"; import * as rebase from "../commands/git/rebase.ts"; import * as reset from "../commands/git/reset.ts"; import * as backup from "../commands/git/backup.ts"; @@ -30,11 +23,9 @@ import * as pull from "../commands/git/pull.ts"; import * as push from "../commands/git/push.ts"; import * as status from "../commands/status/index.tsx"; import * as cd from "../commands/cd.ts"; -import * as code from "../commands/code.ts"; import * as version from "../commands/version.ts"; import * as verify from "../commands/verify.ts"; import * as update from "../commands/update.ts"; -import * as doppler from "../commands/doppler.ts"; import * as nav from "../commands/nav.ts"; import * as sdm from "../commands/sdm.ts"; import * as plugin from "../commands/plugin.ts"; @@ -43,23 +34,16 @@ import * as intercept from "../commands/intercept.ts"; import * as endpoint from "../commands/endpoint.ts"; export const MODULE_REGISTRY: Record = { - "./commands/branch.ts": branch, - "./commands/branch-clean.ts": branchClean, - "./commands/build-select.ts": buildSelect, "./commands/commit.ts": commit, - "./commands/agent.ts": agent, "./commands/daemon.ts": daemon, "./commands/events.ts": events, "./commands/extension.ts": extension, "./commands/hooks.ts": hooks, - "./commands/open.ts": open, "./commands/port.ts": port, "./commands/run.ts": run, "./commands/settings.ts": settings, "./commands/settings-keys.ts": settingsKeys, "./commands/sync.ts": sync, - "./commands/workspace.ts": workspace, - "./commands/mr.ts": mr, "./commands/git/rebase.ts": rebase, "./commands/git/reset.ts": reset, "./commands/git/backup.ts": backup, @@ -67,11 +51,9 @@ export const MODULE_REGISTRY: Record = { "./commands/git/push.ts": push, "./commands/status/index.tsx": status, "./commands/cd.ts": cd, - "./commands/code.ts": code, "./commands/version.ts": version, "./commands/verify.ts": verify, "./commands/update.ts": update, - "./commands/doppler.ts": doppler, "./commands/nav.ts": nav, "./commands/sdm.ts": sdm, "./commands/plugin.ts": plugin, diff --git a/lib/mr-config.ts b/lib/mr-config.ts deleted file mode 100644 index 9777050f..00000000 --- a/lib/mr-config.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * rt mr config — per-repo defaults + describe-atom inputs. - * - * Lives at ~/.mattstack/rt/repos//mr.json (sibling of sync.json). All fields optional. - * Extended from the open atom with `prompts`, `context`, `inline`, `agent` - * so `rt mr describe` has a home for its cursor-rules-style setup. - */ - -import { existsSync, readFileSync, statSync } from "fs"; -import { homedir } from "os"; -import { isAbsolute, join } from "path"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface MRConfig { - // Open-atom defaults - target?: string; - draft?: boolean; - removeSourceBranch?: boolean; - squash?: boolean; - - // Describe-atom inputs - /** - * Markdown fragments concatenated into the agent's style/template context. - * Paths are absolute, ~-prefixed (homedir), or relative to the data dir. - * `.mdc` files have their YAML frontmatter stripped automatically. - */ - prompts?: string[]; - - /** Glob patterns (repo-root relative) for additional context files. */ - context?: { - include?: string[]; - exclude?: string[]; - }; - - /** Freeform extra guidance appended to the assembled prompt. */ - inline?: string; - - /** Agent CLI override. Defaults are agent-aware, e.g. Claude `-p`, Codex `exec -`. */ - agent?: { - cli?: string; - args?: string[]; - /** Soft cap on the diff block included in the prompt, in KB (default 80). */ - maxDiffKb?: number; - }; -} - -// ─── Load ──────────────────────────────────────────────────────────────────── - -export function loadMRConfig(dataDir: string): MRConfig { - const path = join(dataDir, "mr.json"); - if (!existsSync(path)) return {}; - - try { - const raw = JSON.parse(readFileSync(path, "utf8")); - const out: MRConfig = {}; - - if (typeof raw.target === "string") out.target = raw.target; - if (typeof raw.draft === "boolean") out.draft = raw.draft; - if (typeof raw.removeSourceBranch === "boolean") out.removeSourceBranch = raw.removeSourceBranch; - if (typeof raw.squash === "boolean") out.squash = raw.squash; - - if (Array.isArray(raw.prompts)) { - out.prompts = raw.prompts.filter((p: unknown): p is string => typeof p === "string"); - } - if (raw.context && typeof raw.context === "object") { - const ctx: MRConfig["context"] = {}; - if (Array.isArray(raw.context.include)) { - ctx.include = raw.context.include.filter((p: unknown): p is string => typeof p === "string"); - } - if (Array.isArray(raw.context.exclude)) { - ctx.exclude = raw.context.exclude.filter((p: unknown): p is string => typeof p === "string"); - } - out.context = ctx; - } - if (typeof raw.inline === "string") out.inline = raw.inline; - if (raw.agent && typeof raw.agent === "object") { - const a: MRConfig["agent"] = {}; - if (typeof raw.agent.cli === "string") a.cli = raw.agent.cli; - if (Array.isArray(raw.agent.args)) { - a.args = raw.agent.args.filter((x: unknown): x is string => typeof x === "string"); - } - if (typeof raw.agent.maxDiffKb === "number") a.maxDiffKb = raw.agent.maxDiffKb; - out.agent = a; - } - return out; - } catch { - return {}; - } -} - -// ─── Path resolution ───────────────────────────────────────────────────────── - -/** - * Resolve a config path: absolute → as-is, ~/... → homedir-relative, - * otherwise → resolved relative to dataDir (~/.mattstack/rt/repos/). - */ -export function resolveConfigPath(raw: string, dataDir: string): string { - if (raw.startsWith("~/")) return join(homedir(), raw.slice(2)); - if (raw === "~") return homedir(); - if (isAbsolute(raw)) return raw; - return join(dataDir, raw); -} - -// ─── Prompt file reading (.mdc frontmatter strip) ──────────────────────────── - -const FRONTMATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/; - -/** - * Read a prompt file. For `.mdc` files (cursor-rules style), strip the YAML - * frontmatter block at the top. Returns `null` if the file doesn't exist or - * isn't readable (caller decides whether to warn). - */ -export function readPromptFile(path: string): string | null { - try { - const st = statSync(path); - if (!st.isFile()) return null; - } catch { - return null; - } - try { - const raw = readFileSync(path, "utf8"); - if (path.endsWith(".mdc")) { - return raw.replace(FRONTMATTER_RE, "").trimStart(); - } - return raw; - } catch { - return null; - } -} diff --git a/lib/settings/__tests__/registry.test.ts b/lib/settings/__tests__/registry.test.ts index 48070cf9..3f8f4e54 100644 --- a/lib/settings/__tests__/registry.test.ts +++ b/lib/settings/__tests__/registry.test.ts @@ -94,7 +94,6 @@ describe("settings/registry", () => { expect(getDef("rt.cron")?.legacyFile).toBe("cron.jsonc"); expect(getDef("rt.repoTracking")?.legacyFile).toBe("repo-tracking.json"); expect(getDef("rt.notifications")?.legacyFile).toBe("notifications.json"); - expect(getDef("rt.mr")?.legacyFile).toBe("repos//mr.json"); }); test("repoScoped is consistent with a repos//... legacyFile prefix, in both directions", () => { @@ -118,14 +117,13 @@ describe("settings/registry", () => { } }); - test("the seven traced repo-scoped legacy keys carry repoScoped:true and the repos// prefix", () => { + test("the six traced repo-scoped legacy keys carry repoScoped:true and the repos// prefix", () => { const repoScopedLegacyKeys: Record = { "rt.sync": "repos//sync.json", "rt.branchNaming": "repos//branch-naming.json", "rt.variations": "repos//variations.json", "rt.presets": "repos//presets/.json", "rt.dopplerTemplate": "repos//doppler-template.yaml", - "rt.workspaceSync": "repos//workspace-sync.json", "rt.hooks": "repos//hooks.json", }; @@ -144,18 +142,16 @@ describe("settings/registry", () => { } }); - test("has exactly the 14 wave-1 migrated:false keys plus the 5 migrated:true keys", () => { + test("has exactly the 12 wave-1 migrated:false keys plus the 5 migrated:true keys", () => { const migratedFalseKeys = [ "rt.llm", "rt.cron", "rt.repoTracking", "rt.notifications", - "rt.mr", "rt.sync", "rt.branchNaming", "rt.variations", "rt.presets", - "rt.workspaceSync", "rt.dopplerTemplate", "rt.workspacePrefs", "rt.runaway", diff --git a/lib/settings/registry.ts b/lib/settings/registry.ts index b12c6881..0d9d1ec5 100644 --- a/lib/settings/registry.ts +++ b/lib/settings/registry.ts @@ -124,16 +124,6 @@ const REGISTRY: SettingDef[] = [ siblingCommand: "rt settings notifications", description: "Desktop notification preferences (which events notify, sound on/off).", }, - { - key: "rt.mr", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - repoScoped: true, - migrated: false, - legacyFile: "repos//mr.json", - description: "Per-repo merge-request workflow settings (review board wiring, defaults).", - }, { key: "rt.sync", type: "object", @@ -174,16 +164,6 @@ const REGISTRY: SettingDef[] = [ legacyFile: "repos//presets/.json", description: "Saved argument presets for frequently repeated rt commands.", }, - { - key: "rt.workspaceSync", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - repoScoped: true, - migrated: false, - legacyFile: "repos//workspace-sync.json", - description: "Rules for keeping herdr workspace layout in sync with active worktrees.", - }, { key: "rt.dopplerTemplate", type: "object", diff --git a/lib/tui/SKILL.md b/lib/tui/SKILL.md index 37001923..c525930a 100644 --- a/lib/tui/SKILL.md +++ b/lib/tui/SKILL.md @@ -233,11 +233,11 @@ const spinnerTimer = setInterval(() => { | `openPopup(cmd, opts)` | `display-popup -E` | Pickers, editors, one-off scripts. **Blocks** until exit. No pane ID returned. | | `openTempPane(cmd, opts)` | `split-window -v` | Persistent log viewers, interactive shells. Returns a pane ID. | -Example — opening a branch picker in a popup: +Example — opening the interactive script runner in a popup: ```typescript -openPopup(`${process.execPath} ${CLI_PATH} branch`, { +openPopup(`${process.execPath} ${CLI_PATH} run`, { cwd: entry.worktree, - title: "rt branch", + title: "rt run", width: "100", height: "20", }); diff --git a/lib/tui/tmux/popup.ts b/lib/tui/tmux/popup.ts index 35a04083..1f9810f2 100644 --- a/lib/tui/tmux/popup.ts +++ b/lib/tui/tmux/popup.ts @@ -7,7 +7,7 @@ * ─── openPopup (ephemeral) ──────────────────────────────────────────────────── * Opens a floating `display-popup -E` above the runner layout. * The popup closes automatically when the command exits. - * Use for: pickers, editors, one-off scripts (rt branch, rt run, editors). + * Use for: pickers, editors, one-off scripts (rt run, rt commit, editors). * * ─── openTempPane (persistent) ─────────────────────────────────────────────── * Opens a `split-window -v` in the current or target pane. @@ -127,10 +127,10 @@ export interface PopupOptions { * * @example * ```typescript - * // Open a branch picker: - * openPopup(`${process.execPath} ${CLI_PATH} branch`, { + * // Open the interactive script runner: + * openPopup(`${process.execPath} ${CLI_PATH} run`, { * cwd: entry.worktree, - * title: "rt branch", + * title: "rt run", * width: "100", * height: "20", * }); diff --git a/website/docs/getting-started/first-commands.mdx b/website/docs/getting-started/first-commands.mdx index 0c9556c0..711fbf5d 100644 --- a/website/docs/getting-started/first-commands.mdx +++ b/website/docs/getting-started/first-commands.mdx @@ -14,7 +14,7 @@ rt [subcommand] [args] ## Navigation - [`rt cd`](/reference/cd) ... fuzzy worktree/repo directory picker. -- [`rt code`](/reference/code) ... open a worktree in your preferred editor. +- [`rt nav`](/reference/nav) ... filesystem navigator; `ctrl-o` opens the selected folder in your preferred editor. - `rtcd` ... a shell alias added by install that `cd`s into a picked worktree (wraps `rt cd`). ## Run @@ -26,12 +26,6 @@ rt [subcommand] [args] - [`rt status`](/reference/status) ... live branch dashboard with MR actions, pipeline, and review status. - [`rt port`](/reference/port) ... port scanner and killer, daemon-powered and zero-config. -## Branch - -- [`rt branch switch`](/reference/branch/switch) ... checkout with automatic stash handling. -- [`rt branch create`](/reference/branch/create) ... create a branch from a Linear ticket or from scratch. -- [`rt branch clean`](/reference/branch/clean) ... interactively delete stale branches. - ## Sync - [`rt sync`](/reference/sync) ... rebase the current worktree onto master and push. @@ -42,11 +36,4 @@ rt [subcommand] [args] - [`rt git rebase`](/reference/git/rebase) ... smart rebase onto origin/master with auto-resolve. - [`rt git commit`](/reference/git/commit) ... interactive staging and commit with a live diff preview. -## Open - -- [`rt open mr`](/reference/open/mr) ... open the current branch's GitLab MR. -- [`rt open pipeline`](/reference/open/pipeline) ... open GitLab CI pipelines. -- [`rt open repo`](/reference/open/repo) ... open the repository page. -- [`rt open ticket`](/reference/open/ticket) ... open the Linear ticket for this branch. - For flags and full detail on any of these, follow the link to its reference page. diff --git a/website/docs/getting-started/onboard-a-repo.mdx b/website/docs/getting-started/onboard-a-repo.mdx index 7e5c447f..16e6e4ad 100644 --- a/website/docs/getting-started/onboard-a-repo.mdx +++ b/website/docs/getting-started/onboard-a-repo.mdx @@ -9,7 +9,7 @@ There's no explicit "add repo" step. Any git repo with an `origin` remote is pic ```bash cd ~/code/my-repo -rt status # or rt cd, rt branch switch, anything repo-aware +rt status # or rt cd, anything repo-aware ``` On first invocation rt will: @@ -28,7 +28,6 @@ If the daemon was already running, the next refresh cycle picks the repo up (MR | Command | When you need it | |---|---| | [`rt hooks`](/reference/hooks) | Repo uses husky and you want a quick on/off toggle | -| [`rt workspace sync`](/reference/workspace/sync) | Repo has a `.code-workspace` file you want synced across worktrees | | [`rt settings extension`](/reference/settings/extension) | Install the `rt-context` status-bar extension into local editors | ## Global settings that affect every repo @@ -37,8 +36,8 @@ Set these once; they apply to all repos, and are stored under `~/.rt/`, not in a ```bash rt settings gitlab token # required for rt status, MR actions, notifications -rt settings linear token # required for ticket lookup in rt status / branch names -rt settings linear team # only needed if you use rt branch create to file new tickets +rt settings linear token # required for ticket lookup in rt status +rt settings linear team # Set default Linear team rt settings notifications # pick which events fire native macOS notifications ``` diff --git a/website/docs/guides/common-flags.mdx b/website/docs/guides/common-flags.mdx index cddbbd5f..c74d7aff 100644 --- a/website/docs/guides/common-flags.mdx +++ b/website/docs/guides/common-flags.mdx @@ -17,7 +17,7 @@ Show what a command *would* do without changing anything. On `rt sync` and `rt g ## `--repo ` -Pre-select a repo instead of resolving it from the current working directory. Commands whose context is a worktree (they normally resolve "which repo/worktree am I in" from `cwd`, or show a repo picker) accept `--repo ` to skip that resolution and jump straight to ``'s worktree picker (or directly into its one worktree, if it only has one). For example, `rt branch switch --repo ` resolves `` and jumps to its worktree picker instead of resolving the repo from `cwd`. +Pre-select a repo instead of resolving it from the current working directory. Commands whose context is a worktree (they normally resolve "which repo/worktree am I in" from `cwd`, or show a repo picker) accept `--repo ` to skip that resolution and jump straight to ``'s worktree picker (or directly into its one worktree, if it only has one). For example, `rt commit --repo ` resolves `` and jumps to its worktree picker instead of resolving the repo from `cwd`. Note that `rt cd --repo` is a different, unrelated flag: `rt cd` isn't a worktree-context command, so the generic `--repo ` mechanism above doesn't apply to it. It parses its own boolean `--repo` (no value) that means "show the all-repos picker instead of the current directory's repo," and it happens to share a name with the flag described here. @@ -33,4 +33,4 @@ Note that `rt cd --repo` is a different, unrelated flag: `rt cd` isn't a worktre ## Which commands honor these -Not every command accepts every flag above; each command's own reference page lists the flags it actually parses. `--dry-run`, `--json`, `--agent`, and `--no-agent` are used by `rt sync` and `rt git rebase` (the two commands that run rebases and can hit conflicts). `--repo ` is used by worktree-context commands, such as `rt branch switch` and `rt commit`. (`rt cd --repo` is the unrelated, cd-specific boolean flag described above, not this one.) +Not every command accepts every flag above; each command's own reference page lists the flags it actually parses. `--dry-run`, `--json`, `--agent`, and `--no-agent` are used by `rt sync` and `rt git rebase` (the two commands that run rebases and can hit conflicts). `--repo ` is used by worktree-context commands, such as `rt commit`. (`rt cd --repo` is the unrelated, cd-specific boolean flag described above, not this one.) diff --git a/website/docs/reference/agent.mdx b/website/docs/reference/agent.mdx deleted file mode 100644 index b3190666..00000000 --- a/website/docs/reference/agent.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: rt agent -sidebar_label: agent ---- - -# rt agent - -`rt › agent` - -Launch a CLI coding agent (Claude Code, Cursor, etc.) in a worktree - -## Usage - -```bash -rt agent [flags] -``` - -## Arguments & flags - -| Flag / Arg | Type | Default | Description | -| --- | --- | --- | --- | -| `--here` | boolean | `false` | Use the exact current directory instead of resolving a repo/worktree (alias -h) | -| `--pick` | boolean | `false` | Force the repo/worktree picker before launching (alias -p) | - -_See code: [commands/agent.ts › launchAgent](https://github.com/m4ttstack/rt/blob/main/commands/agent.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/branch/clean.mdx b/website/docs/reference/branch/clean.mdx deleted file mode 100644 index 15de29b9..00000000 --- a/website/docs/reference/branch/clean.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: rt branch clean -sidebar_label: clean ---- - -# rt branch clean - -Alias. See the canonical reference for `clean`. diff --git a/website/docs/reference/branch/create.mdx b/website/docs/reference/branch/create.mdx deleted file mode 100644 index 67c9bbb8..00000000 --- a/website/docs/reference/branch/create.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: rt branch create -sidebar_label: create ---- - -# rt branch create - -Alias. See the canonical reference for `create`. diff --git a/website/docs/reference/branch/index.mdx b/website/docs/reference/branch/index.mdx deleted file mode 100644 index d094c85f..00000000 --- a/website/docs/reference/branch/index.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: rt branch -sidebar_label: branch ---- - -# rt branch - -`rt › branch` - -Branch management (switch, create, rename, clean) - -## Usage - -```bash -rt branch -``` - -## Subcommands - -| Command | Description | -| --- | --- | -| [`switch`](switch) | Checkout with stash handling | -| [`create`](create) | From Linear ticket or scratch | -| [`rename`](rename) | Rename the current branch | -| [`clean`](clean) | Delete stale branches interactively | - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/branch/rename.mdx b/website/docs/reference/branch/rename.mdx deleted file mode 100644 index df4bf718..00000000 --- a/website/docs/reference/branch/rename.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: rt branch rename -sidebar_label: rename ---- - -# rt branch rename - -Alias. See the canonical reference for `rename`. diff --git a/website/docs/reference/branch/switch.mdx b/website/docs/reference/branch/switch.mdx deleted file mode 100644 index 58df47ee..00000000 --- a/website/docs/reference/branch/switch.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: rt branch switch -sidebar_label: switch ---- - -# rt branch switch - -Alias. See the canonical reference for `switch`. diff --git a/website/docs/reference/code.mdx b/website/docs/reference/code.mdx deleted file mode 100644 index 6fe4766d..00000000 --- a/website/docs/reference/code.mdx +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: rt code -sidebar_label: code ---- - -# rt code - -`rt › code` - -Open a worktree in your preferred editor - -## Usage - -```bash -rt code [flags] -``` - -## Arguments & flags - -| Flag / Arg | Type | Default | Description | -| --- | --- | --- | --- | -| `--pick` | boolean | `false` | Force the worktree/repo picker instead of using the current repo (alias -p) | - -_See code: [commands/code.ts › openInEditor](https://github.com/m4ttstack/rt/blob/main/commands/code.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/doppler/edit.mdx b/website/docs/reference/doppler/edit.mdx deleted file mode 100644 index c713615e..00000000 --- a/website/docs/reference/doppler/edit.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt doppler edit -sidebar_label: edit ---- - -# rt doppler edit - -`rt › doppler › edit` - -Open the template in $EDITOR - -## Usage - -```bash -rt doppler edit -``` - -_See code: [commands/doppler.ts › editCommand](https://github.com/m4ttstack/rt/blob/main/commands/doppler.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/doppler/index.mdx b/website/docs/reference/doppler/index.mdx deleted file mode 100644 index ca6aeaa5..00000000 --- a/website/docs/reference/doppler/index.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: rt doppler -sidebar_label: doppler ---- - -# rt doppler - -`rt › doppler` - -Per-repo Doppler template + sync into ~/.doppler/.doppler.yaml - -## Usage - -```bash -rt doppler -``` - -## Subcommands - -| Command | Description | -| --- | --- | -| [`init`](init) | Capture existing Doppler entries for this repo into a template | -| [`sync`](sync) | Apply the template across all worktrees (manual trigger) | -| [`status`](status) | Show template vs. actual config per worktree | -| [`edit`](edit) | Open the template in $EDITOR | - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/doppler/init.mdx b/website/docs/reference/doppler/init.mdx deleted file mode 100644 index 8214cdc4..00000000 --- a/website/docs/reference/doppler/init.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt doppler init -sidebar_label: init ---- - -# rt doppler init - -`rt › doppler › init` - -Capture existing Doppler entries for this repo into a template - -## Usage - -```bash -rt doppler init -``` - -_See code: [commands/doppler.ts › initCommand](https://github.com/m4ttstack/rt/blob/main/commands/doppler.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/doppler/status.mdx b/website/docs/reference/doppler/status.mdx deleted file mode 100644 index 58ac2b85..00000000 --- a/website/docs/reference/doppler/status.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt doppler status -sidebar_label: status ---- - -# rt doppler status - -`rt › doppler › status` - -Show template vs. actual config per worktree - -## Usage - -```bash -rt doppler status -``` - -_See code: [commands/doppler.ts › statusCommand](https://github.com/m4ttstack/rt/blob/main/commands/doppler.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/doppler/sync.mdx b/website/docs/reference/doppler/sync.mdx deleted file mode 100644 index d9f9b601..00000000 --- a/website/docs/reference/doppler/sync.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt doppler sync -sidebar_label: sync ---- - -# rt doppler sync - -`rt › doppler › sync` - -Apply the template across all worktrees (manual trigger) - -## Usage - -```bash -rt doppler sync -``` - -_See code: [commands/doppler.ts › syncCommand](https://github.com/m4ttstack/rt/blob/main/commands/doppler.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/git/branch/clean.mdx b/website/docs/reference/git/branch/clean.mdx deleted file mode 100644 index 7c4edbf0..00000000 --- a/website/docs/reference/git/branch/clean.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: rt git branch clean -sidebar_label: clean ---- - -# rt git branch clean - -`rt › git › branch › clean` - -Delete stale branches interactively - -## Usage - -```bash -rt git branch clean [flags] -``` - -## Arguments & flags - -| Flag / Arg | Type | Default | Description | -| --- | --- | --- | --- | -| [`--dry-run`](/guides/common-flags) | boolean | `false` | Preview deletions without deleting (alias -n) | -| `--force` | boolean | `false` | Skip the open-MR warning and force-delete (alias -f) | - -_See code: [commands/branch-clean.ts › cleanBranches](https://github.com/m4ttstack/rt/blob/main/commands/branch-clean.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/git/branch/create.mdx b/website/docs/reference/git/branch/create.mdx deleted file mode 100644 index 45433e06..00000000 --- a/website/docs/reference/git/branch/create.mdx +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: rt git branch create -sidebar_label: create ---- - -# rt git branch create - -`rt › git › branch › create` - -From Linear ticket or scratch - -## Usage - -```bash -rt git branch create [flags] -``` - -## Arguments & flags - -| Flag / Arg | Type | Default | Description | -| --- | --- | --- | --- | -| `` | text | | Skip the interactive picker and create this branch directly | -| `--from` | text | | Start point for the new branch | - -**Aliases:** `new` - -_See code: [commands/branch.ts › createBranchFlow](https://github.com/m4ttstack/rt/blob/main/commands/branch.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/git/branch/index.mdx b/website/docs/reference/git/branch/index.mdx deleted file mode 100644 index 5b768688..00000000 --- a/website/docs/reference/git/branch/index.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: rt git branch -sidebar_label: branch ---- - -# rt git branch - -`rt › git › branch` - -Branch management (switch, create, rename, clean) - -## Usage - -```bash -rt git branch -``` - -## Subcommands - -| Command | Description | -| --- | --- | -| [`switch`](switch) | Checkout with stash handling | -| [`create`](create) | From Linear ticket or scratch | -| [`rename`](rename) | Rename the current branch | -| [`clean`](clean) | Delete stale branches interactively | - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/git/branch/rename.mdx b/website/docs/reference/git/branch/rename.mdx deleted file mode 100644 index c8c1c195..00000000 --- a/website/docs/reference/git/branch/rename.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: rt git branch rename -sidebar_label: rename ---- - -# rt git branch rename - -`rt › git › branch › rename` - -Rename the current branch - -## Usage - -```bash -rt git branch rename -``` - -**Aliases:** `mv` - -_See code: [commands/branch.ts › renameBranch](https://github.com/m4ttstack/rt/blob/main/commands/branch.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/git/branch/switch.mdx b/website/docs/reference/git/branch/switch.mdx deleted file mode 100644 index e769f5ce..00000000 --- a/website/docs/reference/git/branch/switch.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: rt git branch switch -sidebar_label: switch ---- - -# rt git branch switch - -`rt › git › branch › switch` - -Checkout with stash handling - -## Usage - -```bash -rt git branch switch -``` - -**Aliases:** `sw` - -_See code: [commands/branch.ts › switchBranch](https://github.com/m4ttstack/rt/blob/main/commands/branch.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/git/index.mdx b/website/docs/reference/git/index.mdx index d9db7872..92b0748b 100644 --- a/website/docs/reference/git/index.mdx +++ b/website/docs/reference/git/index.mdx @@ -7,7 +7,7 @@ sidebar_label: git `rt › git` -Git operations (rebase, reset, branch, commit, backup) +Git operations (rebase, reset, commit, backup) ## Usage @@ -21,7 +21,6 @@ rt git | --- | --- | | [`rebase`](rebase) | Smart rebase onto origin/master with auto-resolve | | [`reset`](reset) | Safe reset with divergence detection | -| [`branch`](branch) | Branch management (switch, create, rename, clean) | | [`commit`](commit) | Interactive staged/unstaged commit picker with live diff preview | | [`backup`](backup) | Back up the current branch | | [`restore`](restore) | Restore from a backup branch | diff --git a/website/docs/reference/mr/describe.mdx b/website/docs/reference/mr/describe.mdx deleted file mode 100644 index 8613a8c3..00000000 --- a/website/docs/reference/mr/describe.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: rt mr describe -sidebar_label: describe ---- - -# rt mr describe - -`rt › mr › describe` - -Draft an MR description with an agent (streams to stdout) - -## Usage - -```bash -rt mr describe [flags] -``` - -## Arguments & flags - -| Flag / Arg | Type | Default | Description | -| --- | --- | --- | --- | -| `--target` | text | | Target branch to diff against | -| `--inline` | text | | Extra inline guidance appended to the prompt | -| `--debug` | boolean | `false` | Print the assembled prompt instead of calling the agent | - -_See code: [commands/mr.ts › describeCommand](https://github.com/m4ttstack/rt/blob/main/commands/mr.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/mr/index.mdx b/website/docs/reference/mr/index.mdx deleted file mode 100644 index 743ff9d8..00000000 --- a/website/docs/reference/mr/index.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: rt mr -sidebar_label: mr ---- - -# rt mr - -`rt › mr` - -Merge request operations (GitLab); `pr` works too - -## Usage - -```bash -rt mr -``` - -**Aliases:** `pr` - -## Subcommands - -| Command | Description | -| --- | --- | -| [`open`](open) | Open a bare MR on the current branch via glab | -| [`describe`](describe) | Draft an MR description with an agent (streams to stdout) | -| [`ship`](ship) | All-in-one: push + describe + open (the daily driver) | - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/mr/open.mdx b/website/docs/reference/mr/open.mdx deleted file mode 100644 index 02924386..00000000 --- a/website/docs/reference/mr/open.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: rt mr open -sidebar_label: open ---- - -# rt mr open - -`rt › mr › open` - -Open a bare MR on the current branch via glab - -## Usage - -```bash -rt mr open [flags] -``` - -## Arguments & flags - -| Flag / Arg | Type | Default | Description | -| --- | --- | --- | --- | -| `--target` | text | | Target branch for the MR (defaults to config or repo default) | -| `--title` | text | | MR title (defaults to the last commit subject) | -| `--draft` | boolean | `false` | Open as a draft MR | -| `--no-draft` | boolean | `false` | Force non-draft even if config defaults to draft | -| `--description` | text | | Inline description body | -| `--description-file` | text | | Read description from a file (- for stdin) | -| `--fill` | boolean | `false` | Let glab fill the description from commits | -| [`--dry-run`](/guides/common-flags) | boolean | `false` | Preview the glab command without creating the MR | -| `--web` | boolean | `false` | Open the new MR in the browser | - -_See code: [commands/mr.ts › openCommand](https://github.com/m4ttstack/rt/blob/main/commands/mr.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/mr/ship.mdx b/website/docs/reference/mr/ship.mdx deleted file mode 100644 index 6bbd2f2b..00000000 --- a/website/docs/reference/mr/ship.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: rt mr ship -sidebar_label: ship ---- - -# rt mr ship - -`rt › mr › ship` - -All-in-one: push + describe + open (the daily driver) - -## Usage - -```bash -rt mr ship [flags] -``` - -## Arguments & flags - -| Flag / Arg | Type | Default | Description | -| --- | --- | --- | --- | -| `--target` | text | | Target branch for the MR | -| `--title` | text | | MR title (overrides the agent-drafted title) | -| `--draft` | boolean | `false` | Open as a draft MR | -| `--no-draft` | boolean | `false` | Force non-draft even if config defaults to draft | -| `--inline` | text | | Extra inline guidance appended to the description prompt | -| `--debug` | boolean | `false` | Print the assembled prompt and stop before creating the MR | -| [`--dry-run`](/guides/common-flags) | boolean | `false` | Rehearse push + MR creation without doing either | -| `--web` | boolean | `false` | Open the new MR in the browser | -| `--remote` | text | | Remote to push to (forwarded to the push step) | -| `--no-verify` | boolean | `false` | Skip pre-push hooks (forwarded to the push step) | - -_See code: [commands/mr.ts › shipCommand](https://github.com/m4ttstack/rt/blob/main/commands/mr.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/open/index.mdx b/website/docs/reference/open/index.mdx deleted file mode 100644 index 4d6d9333..00000000 --- a/website/docs/reference/open/index.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: rt open -sidebar_label: open ---- - -# rt open - -`rt › open` - -Open external pages for the current branch - -## Usage - -```bash -rt open -``` - -## Subcommands - -| Command | Description | -| --- | --- | -| [`mr`](mr) | GitLab merge request | -| [`pipeline`](pipeline) | GitLab CI pipelines | -| [`repo`](repo) | Repository page | -| [`ticket`](ticket) | Linear ticket for this branch | - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/open/mr.mdx b/website/docs/reference/open/mr.mdx deleted file mode 100644 index 37259064..00000000 --- a/website/docs/reference/open/mr.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt open mr -sidebar_label: mr ---- - -# rt open mr - -`rt › open › mr` - -GitLab merge request - -## Usage - -```bash -rt open mr -``` - -_See code: [commands/open.ts › openMR](https://github.com/m4ttstack/rt/blob/main/commands/open.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/open/pipeline.mdx b/website/docs/reference/open/pipeline.mdx deleted file mode 100644 index db8b8782..00000000 --- a/website/docs/reference/open/pipeline.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: rt open pipeline -sidebar_label: pipeline ---- - -# rt open pipeline - -`rt › open › pipeline` - -GitLab CI pipelines - -## Usage - -```bash -rt open pipeline -``` - -**Aliases:** `ci` - -_See code: [commands/open.ts › openPipeline](https://github.com/m4ttstack/rt/blob/main/commands/open.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/open/repo.mdx b/website/docs/reference/open/repo.mdx deleted file mode 100644 index ab443a78..00000000 --- a/website/docs/reference/open/repo.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt open repo -sidebar_label: repo ---- - -# rt open repo - -`rt › open › repo` - -Repository page - -## Usage - -```bash -rt open repo -``` - -_See code: [commands/open.ts › openRepo](https://github.com/m4ttstack/rt/blob/main/commands/open.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/open/ticket.mdx b/website/docs/reference/open/ticket.mdx deleted file mode 100644 index 5c32b5d0..00000000 --- a/website/docs/reference/open/ticket.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: rt open ticket -sidebar_label: ticket ---- - -# rt open ticket - -`rt › open › ticket` - -Linear ticket for this branch - -## Usage - -```bash -rt open ticket -``` - -**Aliases:** `linear` - -_See code: [commands/open.ts › openTicket](https://github.com/m4ttstack/rt/blob/main/commands/open.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park.mdx b/website/docs/reference/park.mdx deleted file mode 100644 index d08361c9..00000000 --- a/website/docs/reference/park.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park -sidebar_label: park ---- - -# rt park - -`rt › park` - -Deprecated — replaced by rt worktree - -## Usage - -```bash -rt park -``` - -_See code: [commands/worktree.ts › parkDeprecated](https://github.com/m4ttstack/rt/blob/main/commands/worktree.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/turbo/build.mdx b/website/docs/reference/turbo/build.mdx deleted file mode 100644 index fec0503e..00000000 --- a/website/docs/reference/turbo/build.mdx +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: rt turbo build -sidebar_label: build ---- - -# rt turbo build - -`rt › turbo › build` - -Interactive turbo build selector - -## Usage - -```bash -rt turbo build [flags] -``` - -## Arguments & flags - -| Flag / Arg | Type | Default | Description | -| --- | --- | --- | --- | -| `--force` | boolean | `false` | Force turbo to ignore its build cache | - -_See code: [commands/build-select.ts › buildSelect](https://github.com/m4ttstack/rt/blob/main/commands/build-select.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/turbo/index.mdx b/website/docs/reference/turbo/index.mdx deleted file mode 100644 index bd9bd41a..00000000 --- a/website/docs/reference/turbo/index.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: rt turbo -sidebar_label: turbo ---- - -# rt turbo - -`rt › turbo` - -Turborepo operations - -## Usage - -```bash -rt turbo -``` - -## Subcommands - -| Command | Description | -| --- | --- | -| [`build`](build) | Interactive turbo build selector | - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/workspace/index.mdx b/website/docs/reference/workspace/index.mdx deleted file mode 100644 index e485aa97..00000000 --- a/website/docs/reference/workspace/index.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: rt workspace -sidebar_label: workspace ---- - -# rt workspace - -`rt › workspace` - -VS Code workspace management - -## Usage - -```bash -rt workspace -``` - -## Subcommands - -| Command | Description | -| --- | --- | -| [`sync`](sync) | Auto-sync workspace file across worktrees | - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/workspace/sync.mdx b/website/docs/reference/workspace/sync.mdx deleted file mode 100644 index 5e006852..00000000 --- a/website/docs/reference/workspace/sync.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: rt workspace sync -sidebar_label: sync ---- - -# rt workspace sync - -`rt › workspace › sync` - -Auto-sync workspace file across worktrees - -## Usage - -```bash -rt workspace sync [flags] -``` - -## Arguments & flags - -| Flag / Arg | Type | Default | Description | -| --- | --- | --- | --- | -| `--status` | boolean | `false` | Show current sync config and watcher state | -| `--off` | boolean | `false` | Disable syncing and remove the file watcher | - -_See code: [commands/workspace.ts › workspaceSyncCommand](https://github.com/m4ttstack/rt/blob/main/commands/workspace.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file