@skippercorp/skipper 1.0.1

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.
@@ -0,0 +1,67 @@
1
+ import type { Command } from "commander";
2
+
3
+ /**
4
+ * Register clone command.
5
+ *
6
+ * @since 1.0.0
7
+ * @category Worktree
8
+ */
9
+ export function registerCloneCommand(program: Command): void {
10
+ program
11
+ .command("clone")
12
+ .description("Clone a GitHub repository using SSH")
13
+ .argument("<url>", "GitHub repository URL or owner/repo")
14
+ .action(runClone);
15
+ }
16
+
17
+ /**
18
+ * Execute clone command.
19
+ *
20
+ * @since 1.0.0
21
+ * @category Worktree
22
+ */
23
+ async function runClone(url: string): Promise<void> {
24
+ const repo = normalizeRepoInput(url);
25
+ const targetDir = buildTargetDir(repo);
26
+ await Bun.$`mkdir -p ${targetDir}`;
27
+ await Bun.$`gh repo clone ${repo} ${targetDir} -- --recursive`;
28
+ }
29
+
30
+ /**
31
+ * Normalize clone input to owner/repo.
32
+ *
33
+ * @since 1.0.0
34
+ * @category Worktree
35
+ */
36
+ function normalizeRepoInput(url: string): string {
37
+ if (url.startsWith("https://github.com/")) {
38
+ return parseRepo(url, /github\.com\/([^\/]+\/[^\/]+?)(?:\.git)?$/);
39
+ }
40
+ if (url.startsWith("git@github.com:")) {
41
+ return parseRepo(url, /github\.com:([^\/]+\/[^\/]+?)(?:\.git)?$/);
42
+ }
43
+ return url;
44
+ }
45
+
46
+ /**
47
+ * Parse repo with regex fallback.
48
+ *
49
+ * @since 1.0.0
50
+ * @category Worktree
51
+ */
52
+ function parseRepo(url: string, matcher: RegExp): string {
53
+ const match = url.match(matcher);
54
+ return match?.[1] ?? url;
55
+ }
56
+
57
+ /**
58
+ * Build clone target directory path.
59
+ *
60
+ * @since 1.0.0
61
+ * @category Worktree
62
+ */
63
+ function buildTargetDir(repo: string): string {
64
+ const baseDir = `${process.env.HOME}/.local/share/github`;
65
+ const repoName = repo.split("/")[1] ?? repo;
66
+ return `${baseDir}/${repoName}`;
67
+ }
@@ -0,0 +1,126 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ assertNonEmpty,
4
+ listDirectory,
5
+ selectWithFzf,
6
+ } from "../shared/command/interactive.js";
7
+
8
+ type WorktreeRef = {
9
+ repo: string;
10
+ worktree: string;
11
+ path: string;
12
+ };
13
+
14
+ /**
15
+ * Register remove worktree command.
16
+ *
17
+ * @since 1.0.0
18
+ * @category Worktree
19
+ */
20
+ export function registerRemoveCommand(program: Command): void {
21
+ program
22
+ .command("rm")
23
+ .description("Remove a worktree (repo+worktree)")
24
+ .action(runRemoveCommand);
25
+ }
26
+
27
+ /**
28
+ * Execute remove command flow.
29
+ *
30
+ * @since 1.0.0
31
+ * @category Worktree
32
+ */
33
+ async function runRemoveCommand(): Promise<void> {
34
+ const worktreeBaseDir = `${process.env.HOME}/.local/share/skipper/worktree`;
35
+ const allWorktrees = await collectWorktrees(worktreeBaseDir);
36
+ assertNonEmpty(allWorktrees, "No worktrees found");
37
+ const selected = await selectWorktree(allWorktrees);
38
+ if (!selected) {
39
+ console.log("No worktree selected");
40
+ process.exit(0);
41
+ }
42
+ await removeWorktree(selected);
43
+ }
44
+
45
+ /**
46
+ * Collect all worktrees under base path.
47
+ *
48
+ * @since 1.0.0
49
+ * @category Worktree
50
+ */
51
+ async function collectWorktrees(worktreeBaseDir: string): Promise<WorktreeRef[]> {
52
+ const repos = await listDirectory(worktreeBaseDir);
53
+ if (repos.length === 0) {
54
+ console.error("No worktrees found in ~/.local/share/skipper/worktree");
55
+ process.exit(1);
56
+ }
57
+ const allWorktrees: WorktreeRef[] = [];
58
+ for (const repo of repos) {
59
+ const repoDir = `${worktreeBaseDir}/${repo}`;
60
+ const worktrees = await listDirectory(repoDir);
61
+ for (const worktree of worktrees) {
62
+ allWorktrees.push({ repo, worktree, path: `${repoDir}/${worktree}` });
63
+ }
64
+ }
65
+ return allWorktrees;
66
+ }
67
+
68
+ /**
69
+ * Select worktree with fzf.
70
+ *
71
+ * @since 1.0.0
72
+ * @category Worktree
73
+ */
74
+ async function selectWorktree(worktrees: WorktreeRef[]): Promise<WorktreeRef | undefined> {
75
+ const labels = worktrees.map((item) => `${item.repo}/${item.worktree}`);
76
+ const selected = await selectWithFzf(labels, "Select worktree to remove: ");
77
+ const trimmed = selected?.trim();
78
+ if (!trimmed) return undefined;
79
+ return worktrees.find((item) => `${item.repo}/${item.worktree}` === trimmed);
80
+ }
81
+
82
+ /**
83
+ * Remove worktree and tmux session.
84
+ *
85
+ * @since 1.0.0
86
+ * @category Worktree
87
+ */
88
+ async function removeWorktree(target: WorktreeRef): Promise<void> {
89
+ const githubDir = `${process.env.HOME}/.local/share/github`;
90
+ const repoPath = `${githubDir}/${target.repo}`;
91
+ const sessionName = `${target.repo}-${target.worktree}`;
92
+ const name = `${target.repo}/${target.worktree}`;
93
+ console.log(`Removing worktree: ${name}`);
94
+ await removeGitWorktree(repoPath, target.path);
95
+ await Bun.$`rm -rf ${target.path}`;
96
+ if (await tmuxSessionExists(sessionName)) {
97
+ await Bun.$`tmux kill-session -t ${sessionName}`;
98
+ console.log(`Killed tmux session: ${sessionName}`);
99
+ }
100
+ console.log(`Removed worktree: ${name}`);
101
+ }
102
+
103
+ /**
104
+ * Remove git worktree from repository.
105
+ *
106
+ * @since 1.0.0
107
+ * @category Worktree
108
+ */
109
+ async function removeGitWorktree(repoPath: string, worktreePath: string): Promise<void> {
110
+ const result = await Bun.$`git -C ${repoPath} worktree remove ${worktreePath}`.nothrow();
111
+ if (result.exitCode === 0) return;
112
+ const stderr = result.stderr.toString().trim();
113
+ const fallback = `git worktree remove failed with code ${result.exitCode}`;
114
+ throw new Error(stderr || fallback);
115
+ }
116
+
117
+ /**
118
+ * Check tmux session existence.
119
+ *
120
+ * @since 1.0.0
121
+ * @category Worktree
122
+ */
123
+ async function tmuxSessionExists(sessionName: string): Promise<boolean> {
124
+ const output = await Bun.$`tmux has-session -t ${sessionName} 2>/dev/null && echo "yes" || echo "no"`.text();
125
+ return output.trim() === "yes";
126
+ }
@@ -0,0 +1,43 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ assertNonEmpty,
4
+ listDirectory,
5
+ selectWithFzf,
6
+ } from "../shared/command/interactive.js";
7
+
8
+ /**
9
+ * Register run command.
10
+ *
11
+ * @since 1.0.0
12
+ * @category Worktree
13
+ */
14
+ export function registerRunCommand(program: Command): void {
15
+ program
16
+ .command("run")
17
+ .description("Pull changes and run opencode with a prompt")
18
+ .argument("<prompt>", "The prompt to pass to opencode")
19
+ .action(runCommand);
20
+ }
21
+
22
+ /**
23
+ * Execute run command flow.
24
+ *
25
+ * @since 1.0.0
26
+ * @category Worktree
27
+ */
28
+ async function runCommand(prompt: string): Promise<void> {
29
+ const baseDir = `${process.env.HOME}/.local/share/github`;
30
+ const repoList = await listDirectory(baseDir);
31
+ assertNonEmpty(repoList, "No repositories found in ~/.local/share/github");
32
+ const repo = await selectWithFzf(repoList, "Select repository: ");
33
+ if (!repo) {
34
+ console.log("No repository selected");
35
+ process.exit(0);
36
+ }
37
+ const repoPath = `${baseDir}/${repo}`;
38
+ console.log(`Selected: ${repo}`);
39
+ console.log("Pulling latest changes...");
40
+ await Bun.$`git -C ${repoPath} pull`;
41
+ console.log("Running opencode...");
42
+ await Bun.$`opencode run ${prompt}`.cwd(repoPath);
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bun
2
+ import { runCli } from "./app/cli.js";
3
+
4
+ /**
5
+ * Process entrypoint.
6
+ *
7
+ * @since 1.0.0
8
+ * @category CLI
9
+ */
10
+ try {
11
+ await runCli();
12
+ } catch (error) {
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ console.error(message);
15
+ process.exit(1);
16
+ }
@@ -0,0 +1,120 @@
1
+ import { Glob } from "bun";
2
+ import type { $ } from "bun";
3
+
4
+ const DIRECTORY_GLOB = new Glob("*");
5
+ const FZF_NO_MATCH_EXIT_CODE = 1;
6
+ const FZF_CANCELLED_EXIT_CODES = new Set([130]);
7
+
8
+ /**
9
+ * List direct directory entries.
10
+ *
11
+ * @since 1.0.0
12
+ * @category Shared.Command
13
+ */
14
+ export async function listDirectory(path: string): Promise<string[]> {
15
+ try {
16
+ const entries = await Array.fromAsync(
17
+ DIRECTORY_GLOB.scan({ cwd: path, onlyFiles: false }),
18
+ );
19
+ return entries
20
+ .filter((entry) => entry.trim().length > 0)
21
+ .sort((a, b) => a.localeCompare(b));
22
+ } catch (error) {
23
+ if (isMissingDirectoryError(error)) {
24
+ return [];
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Exit when values list is empty.
32
+ *
33
+ * @since 1.0.0
34
+ * @category Shared.Command
35
+ */
36
+ export function assertNonEmpty<T>(values: T[], message: string): void {
37
+ if (values.length > 0) return;
38
+ console.error(message);
39
+ process.exit(1);
40
+ }
41
+
42
+ /**
43
+ * Select one value with fzf.
44
+ *
45
+ * @since 1.0.0
46
+ * @category Shared.Command
47
+ */
48
+ export async function selectWithFzf(
49
+ values: string[],
50
+ prompt: string,
51
+ ): Promise<string | undefined> {
52
+ const output = await runFzf(values, ["--prompt", prompt]);
53
+ const trimmed = output?.trim();
54
+ if (!trimmed) return undefined;
55
+ return trimmed;
56
+ }
57
+
58
+ /**
59
+ * Select one value or return typed query with fzf.
60
+ *
61
+ * @since 1.0.0
62
+ * @category Shared.Command
63
+ */
64
+ export async function selectOrQueryWithFzf(
65
+ values: string[],
66
+ prompt: string,
67
+ ): Promise<string | undefined> {
68
+ const output = await runFzf(values, ["--prompt", prompt, "--print-query"]);
69
+ if (!output) return undefined;
70
+ return output.trim();
71
+ }
72
+
73
+ /**
74
+ * Run fzf with values as stdin.
75
+ *
76
+ * @since 1.0.0
77
+ * @category Shared.Command
78
+ */
79
+ async function runFzf(values: string[], args: string[]): Promise<string | undefined> {
80
+ const input = values.join("\n");
81
+ const result = await Bun.$`echo ${input} | fzf ${args}`.nothrow();
82
+ const output = result.stdout.toString();
83
+ if (result.exitCode === 0) {
84
+ return output;
85
+ }
86
+ if (result.exitCode === FZF_NO_MATCH_EXIT_CODE) {
87
+ if (output.trim().length > 0) return output;
88
+ return undefined;
89
+ }
90
+ if (FZF_CANCELLED_EXIT_CODES.has(result.exitCode)) {
91
+ return undefined;
92
+ }
93
+ throw new Error(`fzf failed: ${formatShellFailure(result)}`);
94
+ }
95
+
96
+ /**
97
+ * Format shell output for error reporting.
98
+ *
99
+ * @since 1.0.0
100
+ * @category Shared.Command
101
+ */
102
+ function formatShellFailure(result: $.ShellOutput): string {
103
+ const stderr = result.stderr.toString().trim();
104
+ if (stderr.length > 0) return stderr;
105
+ const stdout = result.text().trim();
106
+ if (stdout.length > 0) return stdout;
107
+ return `exit code ${result.exitCode}`;
108
+ }
109
+
110
+ /**
111
+ * Check if directory is missing.
112
+ *
113
+ * @since 1.0.0
114
+ * @category Shared.Command
115
+ */
116
+ function isMissingDirectoryError(error: unknown): boolean {
117
+ if (!error || typeof error !== "object") return false;
118
+ const maybe = error as { code?: string };
119
+ return maybe.code === "ENOENT" || maybe.code === "ENOTDIR";
120
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Runtime validator function.
3
+ *
4
+ * @since 1.0.0
5
+ * @category Shared
6
+ */
7
+ export type Validator<T> = (value: unknown) => value is T;
8
+
9
+ /**
10
+ * Parse JSON and validate with type guard.
11
+ *
12
+ * @since 1.0.0
13
+ * @category Shared
14
+ */
15
+ export function parseJson<T>(raw: string, validate: Validator<T>, ctx: string): T {
16
+ const parsed = parseUnknownJson(raw, ctx);
17
+ if (!validate(parsed)) {
18
+ throw new Error(`invalid ${ctx}`);
19
+ }
20
+ return parsed;
21
+ }
22
+
23
+ /**
24
+ * Parse unknown JSON safely.
25
+ *
26
+ * @since 1.0.0
27
+ * @category Shared
28
+ */
29
+ export function parseUnknownJson(raw: string, ctx: string): unknown {
30
+ try {
31
+ return JSON.parse(raw);
32
+ } catch {
33
+ throw new Error(`invalid JSON for ${ctx}`);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Check plain object shape.
39
+ *
40
+ * @since 1.0.0
41
+ * @category Shared
42
+ */
43
+ export function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === "object" && value !== null;
45
+ }
46
+
47
+ /**
48
+ * Read string field from object.
49
+ *
50
+ * @since 1.0.0
51
+ * @category Shared
52
+ */
53
+ export function readOptionalString(
54
+ value: Record<string, unknown>,
55
+ key: string,
56
+ ): string | undefined {
57
+ const found = value[key];
58
+ return typeof found === "string" ? found : undefined;
59
+ }
@@ -0,0 +1,54 @@
1
+ export const WORKER_SCHEMA_VERSION = "1";
2
+ export const WORKER_CHUNK_COUNT = 12;
3
+ export const WORKER_CHUNK_SIZE = 3500;
4
+
5
+ export const WORKERS_ENCODING_PARAM = "WorkersEncoding";
6
+ export const WORKERS_SHA256_PARAM = "WorkersSha256";
7
+ export const WORKERS_SCHEMA_VERSION_PARAM = "WorkersSchemaVersion";
8
+ export const WORKERS_CHUNK_COUNT_PARAM = "WorkersChunkCount";
9
+ export const WORKERS_ENCODING = "gzip-base64-v1";
10
+
11
+ /**
12
+ * Build worker chunk parameter key from index.
13
+ *
14
+ * @since 1.0.0
15
+ * @category Shared
16
+ */
17
+ export function getWorkerChunkParameterKey(index: number): string {
18
+ return `WorkersChunk${index.toString().padStart(2, "0")}`;
19
+ }
20
+
21
+ /**
22
+ * List worker chunk parameter keys.
23
+ *
24
+ * @since 1.0.0
25
+ * @category Shared
26
+ */
27
+ export function listWorkerChunkParameterKeys(count = WORKER_CHUNK_COUNT): string[] {
28
+ const keys: string[] = [];
29
+ for (let index = 0; index < count; index += 1) {
30
+ keys.push(getWorkerChunkParameterKey(index));
31
+ }
32
+ return keys;
33
+ }
34
+
35
+ /**
36
+ * Build empty/default worker parameter values.
37
+ *
38
+ * @since 1.0.0
39
+ * @category Shared
40
+ */
41
+ export function createDefaultWorkerParameterValues(
42
+ count = WORKER_CHUNK_COUNT,
43
+ ): Record<string, string> {
44
+ const values: Record<string, string> = {
45
+ [WORKERS_ENCODING_PARAM]: "",
46
+ [WORKERS_SHA256_PARAM]: "",
47
+ [WORKERS_SCHEMA_VERSION_PARAM]: WORKER_SCHEMA_VERSION,
48
+ [WORKERS_CHUNK_COUNT_PARAM]: "0",
49
+ };
50
+ for (const key of listWorkerChunkParameterKeys(count)) {
51
+ values[key] = "";
52
+ }
53
+ return values;
54
+ }