@doccy/fell 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/git.ts ADDED
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Git and GitHub CLI operations for worktree management.
3
+ * Uses Bun's native $ shell API for all subprocess calls.
4
+ */
5
+
6
+ import { $ } from "bun"
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface Worktree {
13
+ path: string
14
+ head: string
15
+ branch: string | null
16
+ isBare: boolean
17
+ isMain: boolean
18
+ isLocked: boolean
19
+ lockReason: string | null
20
+ isPrunable: boolean
21
+ prunableReason: string | null
22
+ isDetached: boolean
23
+ }
24
+
25
+ export interface PrInfo {
26
+ number: number
27
+ state: "OPEN" | "CLOSED" | "MERGED"
28
+ url: string
29
+ title: string
30
+ }
31
+
32
+ export type PrStatus =
33
+ | { type: "loading" }
34
+ | { type: "found"; pr: PrInfo }
35
+ | { type: "none" }
36
+ | { type: "error"; message: string }
37
+ | { type: "skipped" }
38
+
39
+ export interface FileStatus {
40
+ /** Staged files (added/modified/deleted in index) */
41
+ staged: number
42
+ /** Unstaged modifications to tracked files */
43
+ modified: number
44
+ /** Untracked files */
45
+ untracked: number
46
+ /** Commits ahead of remote tracking branch */
47
+ ahead: number
48
+ /** Commits behind remote tracking branch */
49
+ behind: number
50
+ }
51
+
52
+ export type FileStatusResult =
53
+ | { type: "loading" }
54
+ | { type: "clean" }
55
+ | { type: "dirty"; status: FileStatus }
56
+ | { type: "error" }
57
+
58
+ /** Diagnostic result from checking gh CLI availability. */
59
+ export type GhDiagnostic =
60
+ | { type: "checking" }
61
+ | { type: "available" }
62
+ | { type: "not-installed" }
63
+ | { type: "not-authenticated"; detail: string }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Worktree operations
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Parse `git worktree list --porcelain` into structured data.
71
+ * The first entry is always the main worktree.
72
+ */
73
+ export async function listWorktrees(): Promise<Worktree[]> {
74
+ const result = await $`git worktree list --porcelain`.nothrow().quiet()
75
+ if (result.exitCode !== 0) return []
76
+
77
+ const stdout = result.stdout.toString()
78
+ const blocks = stdout.trim().split("\n\n")
79
+ const worktrees: Worktree[] = []
80
+
81
+ for (const block of blocks) {
82
+ const lines = block.trim().split("\n")
83
+ let path = ""
84
+ let head = ""
85
+ let branch: string | null = null
86
+ let isBare = false
87
+ let isDetached = false
88
+ let isLocked = false
89
+ let lockReason: string | null = null
90
+ let isPrunable = false
91
+ let prunableReason: string | null = null
92
+
93
+ for (const line of lines) {
94
+ if (line.startsWith("worktree ")) path = line.slice(9)
95
+ else if (line.startsWith("HEAD ")) head = line.slice(5)
96
+ else if (line.startsWith("branch "))
97
+ branch = line.slice(7).replace("refs/heads/", "")
98
+ else if (line === "bare") isBare = true
99
+ else if (line === "detached") isDetached = true
100
+ else if (line === "locked") isLocked = true
101
+ else if (line.startsWith("locked ")) {
102
+ isLocked = true
103
+ lockReason = line.slice(7)
104
+ } else if (line === "prunable") isPrunable = true
105
+ else if (line.startsWith("prunable ")) {
106
+ isPrunable = true
107
+ prunableReason = line.slice(9)
108
+ }
109
+ }
110
+
111
+ if (path) {
112
+ worktrees.push({
113
+ path,
114
+ head,
115
+ branch,
116
+ isBare,
117
+ isMain: worktrees.length === 0,
118
+ isLocked,
119
+ lockReason,
120
+ isPrunable,
121
+ prunableReason,
122
+ isDetached,
123
+ })
124
+ }
125
+ }
126
+
127
+ return worktrees
128
+ }
129
+
130
+ /**
131
+ * Remove a worktree directory and its git administrative tracking.
132
+ * Use force when the worktree has uncommitted changes.
133
+ */
134
+ export async function removeWorktree(
135
+ path: string,
136
+ force = false,
137
+ ): Promise<{ ok: boolean; error?: string }> {
138
+ const flags = force ? ["--force"] : []
139
+ const result = await $`git worktree remove ${flags} ${path}`.nothrow().quiet()
140
+ if (result.exitCode !== 0) {
141
+ return { ok: false, error: result.stderr.toString().trim() }
142
+ }
143
+ return { ok: true }
144
+ }
145
+
146
+ /**
147
+ * Dry-run prune to show what stale references would be cleaned.
148
+ * Returns human-readable descriptions of each stale entry.
149
+ */
150
+ export async function pruneWorktreesDryRun(): Promise<string[]> {
151
+ const result = await $`git worktree prune --dry-run -v`.nothrow().quiet()
152
+ return result.stdout.toString().trim().split("\n").filter(Boolean)
153
+ }
154
+
155
+ /** Actually prune stale worktree references. */
156
+ export async function pruneWorktrees(): Promise<{
157
+ ok: boolean
158
+ error?: string
159
+ }> {
160
+ const result = await $`git worktree prune`.nothrow().quiet()
161
+ if (result.exitCode !== 0) {
162
+ return { ok: false, error: result.stderr.toString().trim() }
163
+ }
164
+ return { ok: true }
165
+ }
166
+
167
+ /**
168
+ * Delete a local git branch.
169
+ * Force (-D) deletes even when not fully merged.
170
+ */
171
+ export async function deleteBranch(
172
+ name: string,
173
+ force = false,
174
+ ): Promise<{ ok: boolean; error?: string }> {
175
+ const flag = force ? "-D" : "-d"
176
+ const result = await $`git branch ${flag} ${name}`.nothrow().quiet()
177
+ if (result.exitCode !== 0) {
178
+ return { ok: false, error: result.stderr.toString().trim() }
179
+ }
180
+ return { ok: true }
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // File status (per-worktree)
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Get the working tree status for a specific worktree path.
189
+ * Uses `git -C <path> status --porcelain=v2 --branch` to parse
190
+ * staged, modified, untracked counts and ahead/behind info.
191
+ */
192
+ export async function fetchWorktreeFileStatus(
193
+ worktreePath: string,
194
+ ): Promise<FileStatusResult> {
195
+ const result =
196
+ await $`git -C ${worktreePath} status --porcelain=v2 --branch`
197
+ .nothrow()
198
+ .quiet()
199
+
200
+ if (result.exitCode !== 0) return { type: "error" }
201
+
202
+ const stdout = result.stdout.toString()
203
+ let staged = 0
204
+ let modified = 0
205
+ let untracked = 0
206
+ let ahead = 0
207
+ let behind = 0
208
+
209
+ for (const line of stdout.split("\n")) {
210
+ if (line.startsWith("# branch.ab ")) {
211
+ // Format: "# branch.ab +N -M"
212
+ const match = line.match(/\+(\d+) -(\d+)/)
213
+ if (match) {
214
+ ahead = parseInt(match[1], 10)
215
+ behind = parseInt(match[2], 10)
216
+ }
217
+ } else if (line.startsWith("1 ") || line.startsWith("2 ")) {
218
+ // Ordinary/rename entries: XY field is chars 2-3
219
+ const xy = line.slice(2, 4)
220
+ const indexStatus = xy[0]
221
+ const worktreeStatus = xy[1]
222
+ if (indexStatus !== ".") staged++
223
+ if (worktreeStatus !== ".") modified++
224
+ } else if (line.startsWith("u ")) {
225
+ // Unmerged entry - count as modified
226
+ modified++
227
+ } else if (line.startsWith("? ")) {
228
+ untracked++
229
+ }
230
+ }
231
+
232
+ const isDirty = staged + modified + untracked + ahead + behind > 0
233
+ if (!isDirty) return { type: "clean" }
234
+
235
+ return {
236
+ type: "dirty",
237
+ status: { staged, modified, untracked, ahead, behind },
238
+ }
239
+ }
240
+
241
+ /** A single file entry from git status with its change type. */
242
+ export interface FileEntry {
243
+ path: string
244
+ status: "staged" | "modified" | "untracked" | "unmerged"
245
+ }
246
+
247
+ /**
248
+ * Get the list of changed files in a worktree.
249
+ * Returns individual file paths with their status category.
250
+ */
251
+ export async function fetchWorktreeFileList(
252
+ worktreePath: string,
253
+ ): Promise<FileEntry[]> {
254
+ const result =
255
+ await $`git -C ${worktreePath} status --porcelain=v2 --branch`
256
+ .nothrow()
257
+ .quiet()
258
+
259
+ if (result.exitCode !== 0) return []
260
+
261
+ const entries: FileEntry[] = []
262
+
263
+ for (const line of result.stdout.toString().split("\n")) {
264
+ if (line.startsWith("1 ")) {
265
+ // Ordinary entry: "1 XY sub mH mI mW hH hI path"
266
+ const xy = line.slice(2, 4)
267
+ const path = line.split("\t")[0]?.split(" ").pop() ?? line.slice(113)
268
+ // Parse path from the fixed-width porcelain v2 format
269
+ const parts = line.split(" ")
270
+ const filePath = parts.slice(8).join(" ")
271
+ if (xy[0] !== ".") entries.push({ path: filePath, status: "staged" })
272
+ else if (xy[1] !== ".") entries.push({ path: filePath, status: "modified" })
273
+ } else if (line.startsWith("2 ")) {
274
+ // Rename entry: "2 XY sub mH mI mW hH hI X\tscore\tpath\torigPath"
275
+ const tabParts = line.split("\t")
276
+ const filePath = tabParts[1] ?? ""
277
+ entries.push({ path: filePath, status: "staged" })
278
+ } else if (line.startsWith("u ")) {
279
+ // Unmerged: "u XY sub m1 m2 m3 mW h1 h2 h3 path"
280
+ const parts = line.split(" ")
281
+ entries.push({ path: parts.slice(10).join(" "), status: "unmerged" })
282
+ } else if (line.startsWith("? ")) {
283
+ entries.push({ path: line.slice(2), status: "untracked" })
284
+ }
285
+ }
286
+
287
+ return entries
288
+ }
289
+
290
+ /**
291
+ * Open a directory in the system file manager.
292
+ * macOS: Finder, Linux: xdg-open, Windows: explorer.
293
+ */
294
+ export async function openDirectory(path: string): Promise<void> {
295
+ const platform = process.platform
296
+ if (platform === "darwin") {
297
+ await $`open ${path}`.nothrow().quiet()
298
+ } else if (platform === "win32") {
299
+ await $`explorer ${path}`.nothrow().quiet()
300
+ } else {
301
+ await $`xdg-open ${path}`.nothrow().quiet()
302
+ }
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // GitHub CLI diagnostics
307
+ // ---------------------------------------------------------------------------
308
+
309
+ /**
310
+ * Determine the availability of the `gh` CLI.
311
+ * Uses `Bun.which` to check the binary exists on PATH, then
312
+ * attempts a lightweight repo query to verify auth/repo access.
313
+ */
314
+ export async function checkGhStatus(): Promise<GhDiagnostic> {
315
+ // Fast path: check if the binary is on PATH at all
316
+ if (!Bun.which("gh")) {
317
+ return { type: "not-installed" }
318
+ }
319
+
320
+ // Binary exists - verify it can actually query this repo (auth + repo context)
321
+ const result = await $`gh repo view --json name`.nothrow().quiet()
322
+ if (result.exitCode === 0) {
323
+ return { type: "available" }
324
+ }
325
+
326
+ const stderr = result.stderr.toString()
327
+ return {
328
+ type: "not-authenticated",
329
+ detail: stderr.trim().split("\n")[0] ?? "unknown error",
330
+ }
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // GitHub PR lookups
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /**
338
+ * Fetch the most recent PR for a branch via `gh` CLI.
339
+ * Returns null when no PR exists or gh is unavailable.
340
+ */
341
+ export async function fetchPrForBranch(
342
+ branch: string,
343
+ ): Promise<PrInfo | null> {
344
+ const result =
345
+ await $`gh pr list --head ${branch} --state all --json number,state,url,title --limit 5`
346
+ .nothrow()
347
+ .quiet()
348
+
349
+ if (result.exitCode !== 0) return null
350
+
351
+ try {
352
+ const prs = JSON.parse(result.stdout.toString()) as Array<{
353
+ number: number
354
+ state: string
355
+ url: string
356
+ title: string
357
+ }>
358
+ if (prs.length === 0) return null
359
+
360
+ // Most recent PR first
361
+ prs.sort((a, b) => b.number - a.number)
362
+ const pr = prs[0]
363
+ return {
364
+ number: pr.number,
365
+ state: pr.state as PrInfo["state"],
366
+ url: pr.url,
367
+ title: pr.title,
368
+ }
369
+ } catch {
370
+ return null
371
+ }
372
+ }
package/lib/tui.ts ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Terminal UI primitives for fell.
3
+ * Stateless helpers: ANSI colours, key parsing, text formatting,
4
+ * and the help screen content.
5
+ */
6
+
7
+ import type { PrStatus, FileStatusResult } from "./git"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // ANSI escape helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const ESC = "\x1b"
14
+ const CSI = `${ESC}[`
15
+
16
+ /** Colour/style wrappers using standard ANSI escape codes. */
17
+ export const c = {
18
+ dim: (s: string) => `${CSI}2m${s}${CSI}0m`,
19
+ bold: (s: string) => `${CSI}1m${s}${CSI}0m`,
20
+ italic: (s: string) => `${CSI}3m${s}${CSI}0m`,
21
+ underline: (s: string) => `${CSI}4m${s}${CSI}0m`,
22
+ inverse: (s: string) => `${CSI}7m${s}${CSI}0m`,
23
+ cyan: (s: string) => `${CSI}36m${s}${CSI}0m`,
24
+ green: (s: string) => `${CSI}32m${s}${CSI}0m`,
25
+ red: (s: string) => `${CSI}31m${s}${CSI}0m`,
26
+ yellow: (s: string) => `${CSI}33m${s}${CSI}0m`,
27
+ magenta: (s: string) => `${CSI}35m${s}${CSI}0m`,
28
+ white: (s: string) => `${CSI}37m${s}${CSI}0m`,
29
+ lime: (s: string) => `${CSI}38;5;154m${s}${CSI}0m`,
30
+ } as const
31
+
32
+ export const SPINNER_FRAMES = [
33
+ "\u28CB", "\u28D9", "\u28F9", "\u28F8", "\u28FC", "\u28F4",
34
+ "\u28E6", "\u28E7", "\u28C7", "\u28CF",
35
+ ] as const
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Terminal control
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export const term = {
42
+ enterAltScreen: () => process.stdout.write(`${CSI}?1049h`),
43
+ exitAltScreen: () => process.stdout.write(`${CSI}?1049l`),
44
+ hideCursor: () => process.stdout.write(`${CSI}?25l`),
45
+ showCursor: () => process.stdout.write(`${CSI}?25h`),
46
+ home: () => process.stdout.write(`${CSI}H`),
47
+ clearBelow: () => process.stdout.write(`${CSI}J`),
48
+ clearScreen: () => process.stdout.write(`${CSI}2J`),
49
+ /** Erase from cursor to end of line - append to each line to prevent ghost chars. */
50
+ EL: `${CSI}K`,
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Text utilities
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * OSC 8 hyperlink - clickable in iTerm2, Kitty, WezTerm, etc.
59
+ * Terminals that don't support it simply render the text.
60
+ */
61
+ export function hyperlink(text: string, url: string): string {
62
+ return `${ESC}]8;;${url}\x07${text}${ESC}]8;;\x07`
63
+ }
64
+
65
+ /** Strip ANSI escape codes (colours + OSC 8 links) for width calculation. */
66
+ export function stripAnsi(s: string): string {
67
+ return s
68
+ .replace(/\x1b\][^\x07]*\x07/g, "") // OSC sequences
69
+ .replace(/\x1b\[[0-9;]*m/g, "") // SGR sequences
70
+ }
71
+
72
+ /** Visible character count excluding ANSI codes. */
73
+ export function visibleLength(s: string): number {
74
+ return stripAnsi(s).length
75
+ }
76
+
77
+ /** Pad a (possibly ANSI-coloured) string to `width` visible chars. */
78
+ export function pad(s: string, width: number): string {
79
+ const diff = width - visibleLength(s)
80
+ return diff > 0 ? s + " ".repeat(diff) : s
81
+ }
82
+
83
+ /** Truncate plain text with ".." suffix when it exceeds `width`. */
84
+ export function truncate(s: string, width: number): string {
85
+ if (s.length <= width) return s
86
+ if (width <= 2) return s.slice(0, width)
87
+ return s.slice(0, width - 2) + ".."
88
+ }
89
+
90
+ /** Replace $HOME prefix with ~ and truncate to maxWidth. */
91
+ export function shortenPath(fullPath: string, maxWidth: number): string {
92
+ const home = process.env.HOME ?? ""
93
+ let display = fullPath
94
+ if (home && display.startsWith(home)) {
95
+ display = "~" + display.slice(home.length)
96
+ }
97
+ return truncate(display, maxWidth)
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Key parsing
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export type Key =
105
+ | "up"
106
+ | "down"
107
+ | "left"
108
+ | "right"
109
+ | "enter"
110
+ | "space"
111
+ | "escape"
112
+ | "backspace"
113
+ | "ctrl-c"
114
+ | { char: string }
115
+
116
+ /** Parse raw stdin bytes into a Key value. */
117
+ export function parseKey(data: Buffer): Key {
118
+ if (data[0] === 0x1b && data[1] === 0x5b) {
119
+ switch (data[2]) {
120
+ case 0x41:
121
+ return "up"
122
+ case 0x42:
123
+ return "down"
124
+ case 0x43:
125
+ return "right"
126
+ case 0x44:
127
+ return "left"
128
+ }
129
+ }
130
+ switch (data[0]) {
131
+ case 0x03:
132
+ return "ctrl-c"
133
+ case 0x0d:
134
+ case 0x0a:
135
+ return "enter"
136
+ case 0x20:
137
+ return "space"
138
+ case 0x7f:
139
+ return "backspace"
140
+ case 0x1b:
141
+ return "escape"
142
+ }
143
+ return { char: data.toString("utf8") }
144
+ }
145
+
146
+ /** Extract the character value from a Key, or null for special keys. */
147
+ export function keyChar(key: Key): string | null {
148
+ if (typeof key === "object" && "char" in key) return key.char
149
+ return null
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // PR status formatting
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Render a PrStatus value to a coloured string for the worktree list. */
157
+ export function formatPrStatus(status: PrStatus, spinnerFrame: number): string {
158
+ switch (status.type) {
159
+ case "loading": {
160
+ const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length]
161
+ return c.dim(`${frame} fetching`)
162
+ }
163
+ case "found": {
164
+ const { pr } = status
165
+ const tag = hyperlink(`#${pr.number}`, pr.url)
166
+ switch (pr.state) {
167
+ case "MERGED":
168
+ return c.green(`${tag} merged`)
169
+ case "OPEN":
170
+ return c.yellow(`${tag} open`)
171
+ case "CLOSED":
172
+ return c.red(`${tag} closed`)
173
+ }
174
+ break
175
+ }
176
+ case "none":
177
+ return c.dim("no PR")
178
+ case "error":
179
+ return c.dim("fetch error")
180
+ case "skipped":
181
+ return c.dim("-")
182
+ }
183
+ return ""
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // File status formatting
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Render a file status sub-line for a worktree.
192
+ * Returns null if clean or still loading - only renders when dirty.
193
+ * Warning triangle prefix to draw attention to uncommitted/unpushed work.
194
+ */
195
+ export function formatFileStatus(result: FileStatusResult): string | null {
196
+ if (result.type !== "dirty") return null
197
+
198
+ const { staged, modified, untracked, ahead, behind } = result.status
199
+ const parts: string[] = []
200
+
201
+ if (staged > 0) parts.push(c.green(`${staged} staged`))
202
+ if (modified > 0) parts.push(c.yellow(`${modified} modified`))
203
+ if (untracked > 0) parts.push(c.dim(`${untracked} untracked`))
204
+ if (ahead > 0) parts.push(c.cyan(`${ahead} unpushed`))
205
+ if (behind > 0) parts.push(c.magenta(`${behind} behind`))
206
+
207
+ if (parts.length === 0) return null
208
+
209
+ return `${c.yellow("\u26A0")} ${parts.join(c.dim(" \u00B7 "))}`
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Help screen
214
+ // ---------------------------------------------------------------------------
215
+
216
+ export function renderHelpLines(): string[] {
217
+ return [
218
+ "",
219
+ ` ${c.bold("KEYBINDINGS")}`,
220
+ "",
221
+ ` ${c.cyan("up/down")} or ${c.cyan("k/j")} Navigate`,
222
+ ` ${c.cyan("space")} Toggle selection on focused item`,
223
+ ` ${c.cyan("a")} Select / deselect all`,
224
+ ` ${c.cyan("e")} Expand / collapse file list for focused worktree`,
225
+ ` ${c.cyan("o")} Open focused worktree in file manager`,
226
+ ` ${c.cyan("d")} Delete focused or selected worktree(s)`,
227
+ ` ${c.cyan("p")} Prune stale worktree references`,
228
+ ` ${c.cyan("r")} Refresh list + re-fetch PR statuses`,
229
+ ` ${c.cyan("?")} Toggle this help screen`,
230
+ ` ${c.cyan("q")} / ${c.cyan("ctrl+c")} Quit`,
231
+ "",
232
+ ` ${c.bold("PRUNE vs DELETE")}`,
233
+ "",
234
+ ` ${c.yellow("prune")} Cleans up ${c.italic("stale administrative references")}. When a worktree`,
235
+ ` directory has been manually deleted (rm -rf) but git still`,
236
+ ` tracks it, prune removes those orphaned references.`,
237
+ ` ${c.dim("Safe: only affects already-missing worktrees.")}`,
238
+ "",
239
+ ` ${c.yellow("delete")} ${c.italic("Properly removes")} a worktree: deletes the working directory`,
240
+ ` and cleans up git tracking. Optionally also force-deletes`,
241
+ ` the associated branch. Use for worktrees you no longer need.`,
242
+ ` ${c.dim("Destructive: removes files from disk.")}`,
243
+ "",
244
+ ` ${c.dim("press ? or escape to return")}`,
245
+ ]
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Non-interactive help (--help flag)
250
+ // ---------------------------------------------------------------------------
251
+
252
+ export function printCliHelp(): void {
253
+ console.log()
254
+ console.log(` ${c.dim("▐▘ █▌ ▐ ▐")} ${c.dim("Interactive git worktree manager")}`)
255
+ console.log(` ${c.dim("▜▘ ▙▖ ▐▖ ▐▖")}`)
256
+ console.log()
257
+ console.log(c.yellow(" USAGE"))
258
+ console.log()
259
+ console.log(` ${c.dim("$")} fell ${c.dim("Interactive mode (default)")}`)
260
+ console.log(` ${c.dim("$")} fell ${c.cyan("--list")} ${c.dim("Print worktrees and exit")}`)
261
+ console.log(` ${c.dim("$")} fell ${c.cyan("--help")} ${c.dim("Show this help")}`)
262
+ console.log()
263
+ console.log(c.yellow(" INTERACTIVE COMMANDS"))
264
+ console.log()
265
+ console.log(` ${c.cyan("up/down")} or ${c.cyan("k/j")} Navigate worktree list`)
266
+ console.log(` ${c.cyan("space")} Toggle selection`)
267
+ console.log(` ${c.cyan("a")} Select / deselect all`)
268
+ console.log(` ${c.cyan("e")} Expand / collapse file list`)
269
+ console.log(` ${c.cyan("o")} Open worktree in file manager`)
270
+ console.log(` ${c.cyan("d")} Delete worktree(s) + optionally branches`)
271
+ console.log(` ${c.cyan("p")} Prune stale references`)
272
+ console.log(` ${c.cyan("r")} Refresh list + PR statuses`)
273
+ console.log(` ${c.cyan("?")} In-app help (prune vs delete explained)`)
274
+ console.log(` ${c.cyan("q")} / ${c.cyan("ctrl+c")} Quit`)
275
+ console.log()
276
+ console.log(c.yellow(" PRUNE vs DELETE"))
277
+ console.log()
278
+ console.log(` ${c.bold("prune")} Removes stale git references to worktrees whose directories`)
279
+ console.log(` no longer exist. Equivalent to ${c.cyan("git worktree prune")}.`)
280
+ console.log(` Does ${c.underline("not")} touch any actual worktree directories.`)
281
+ console.log()
282
+ console.log(` ${c.bold("delete")} Removes the worktree directory from disk and cleans up git`)
283
+ console.log(` tracking. Equivalent to ${c.cyan("git worktree remove <path>")}.`)
284
+ console.log(` Optionally also deletes the associated branch.`)
285
+ console.log()
286
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@doccy/fell",
3
+ "version": "0.1.0",
4
+ "description": "Interactive git worktree manager. Navigate, inspect, delete, and prune worktrees with async PR status fetching.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "fell": "cli.ts"
8
+ },
9
+ "type": "module",
10
+ "files": [
11
+ "cli.ts",
12
+ "lib/"
13
+ ],
14
+ "keywords": [
15
+ "git",
16
+ "worktree",
17
+ "cli",
18
+ "tui",
19
+ "interactive",
20
+ "prune",
21
+ "bun"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/medlo-aus/fell.git"
26
+ },
27
+ "homepage": "https://github.com/medlo-aus/fell",
28
+ "bugs": {
29
+ "url": "https://github.com/medlo-aus/fell/issues"
30
+ },
31
+ "engines": {
32
+ "bun": ">=1.0.0"
33
+ }
34
+ }