Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
<h1 align="center">Skipper</h1>

<p align="center">
Local repo workspace CLI for Git worktrees + tmux.
Local repo workspace CLI for Git worktrees, Docker sandboxes, and tmux.
</p>

Skipper is a local-first CLI for working across GitHub repositories and branch workspaces without juggling paths by hand. It clones repositories into a shared local root, creates per-branch Git worktrees, opens or switches into matching tmux sessions, and runs shell commands in the right checkout.
Skipper is a local-first CLI for working across GitHub repositories and branch workspaces without juggling paths by hand. It clones repositories into a shared local root, creates per-branch Git worktrees or Docker sandboxes, opens or switches into matching sessions, and runs shell commands in the right checkout.

By default, Skipper keeps:

Expand All @@ -20,6 +20,7 @@ If you omit `--repository` or `--branch` in an interactive terminal, Skipper let
## Requirements

- `bun`
- `docker` (optional, for `--sandbox docker`)
- `git`
- `gh`
- `opencode`
Expand All @@ -45,9 +46,15 @@ bunx @skippercorp/skipper-cli --help
# Clone into ~/.local/share/github/<repo>
sk clone git@github.com:owner/repo.git

# Clone and create a Docker-backed main workspace container
sk clone git@github.com:owner/repo.git --sandbox docker

# Create a workspace for a feature branch
sk workspace create --repository repo --branch feature/my-change

# Create the same workspace in Docker
sk workspace create --repository repo --branch feature/my-change --sandbox docker

# Jump into a tmux session for that workspace
sk workspace attach --repository repo --branch feature/my-change

Expand All @@ -58,7 +65,7 @@ sk workspace run --repository repo --branch feature/my-change --command "bun tes
sk workspace prompt --repository repo --branch feature/my-change "Explain this codebase"
```

`main` is treated specially: it uses the repository checkout directly, while other branches use dedicated worktrees.
`main` is treated specially: it uses the repository checkout directly for the default worktree backend, while other branches use dedicated worktrees. Docker support is additive behind `--sandbox docker`, with worktree remaining the default backend.

Before first `workspace prompt` use, configure OpenCode auth with `opencode auth login`.

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { cloneCommand } from "./command/clone.command.ts";
import { workspaceCommand } from "./command/workspace/index.ts";
import { sessionCommand } from "./command/session/index.ts";
import { taskCommand } from "./command/task/index.ts";
import { ConsoleLayer, DryRun, DryRunLayer, Silent } from "./common/global-flags.ts";
import { ConsoleLayer, DryRun, DryRunLayer, Sandbox, Silent } from "./common/global-flags.ts";

export const rootCommand = Command.make("skipper").pipe(
Command.withSubcommands([cloneCommand, workspaceCommand, sessionCommand, taskCommand]),
Command.provide(() => ConsoleLayer),
Command.provide(() => DryRunLayer),
Command.withGlobalFlags([Silent, DryRun]),
Command.withGlobalFlags([Silent, DryRun, Sandbox]),
);
27 changes: 27 additions & 0 deletions packages/cli/src/common/global-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { Flag, GlobalFlag } from "effect/unstable/cli";
import { ChildProcessSpawner } from "effect/unstable/process";
import { noopConsole } from "@skippercorp/core/common/adapter/noop-console";

const sandboxChoices = ["worktree", "docker"] as const;
export type SandboxBackend = (typeof sandboxChoices)[number];

export const Silent = GlobalFlag.setting("silent")({
flag: Flag.boolean("silent").pipe(
Flag.withDefault(false),
Expand Down Expand Up @@ -57,3 +60,27 @@ export const DryRunLayer = Layer.effect(
);
}),
);

export const Sandbox = GlobalFlag.setting("sandbox")({
flag: Flag.choice("sandbox", sandboxChoices).pipe(
Flag.withDefault("worktree"),
Flag.withDescription("Sandbox backend to use"),
),
});

export const resolveSandboxFromArgv = (argv: ReadonlyArray<string>): SandboxBackend => {
for (let index = 0; index < argv.length; index++) {
const value = argv[index];

if (value === "--sandbox") {
const next = argv[index + 1];
return next === "docker" ? "docker" : "worktree";
}

if (value?.startsWith("--sandbox=")) {
return value.slice("--sandbox=".length) === "docker" ? "docker" : "worktree";
}
}

return "worktree";
};
7 changes: 6 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import packageJson from "../package.json";
import { Effect } from "effect";
import { BunRuntime } from "@effect/platform-bun";
import { dockerLayer } from "@skippercorp/core/runtime/docker.runtime";
import { localWorkTreeLayer } from "@skippercorp/core/runtime/local-work-tree.runtime";
import { Command } from "effect/unstable/cli";
import { rootCommand } from "./command.ts";
import { resolveSandboxFromArgv } from "./common/global-flags.ts";

const runtimeLayer =
resolveSandboxFromArgv(process.argv.slice(2)) === "docker" ? dockerLayer : localWorkTreeLayer;

Command.run(rootCommand, {
version: packageJson.version,
}).pipe(
// @effect-diagnostics-next-line strictEffectProvide:off
Effect.provide(localWorkTreeLayer),
Effect.provide(runtimeLayer),
Effect.scoped,
Effect.catchTag("ShowHelp", () => Effect.void),
BunRuntime.runMain,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/clone.command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe("clone command", () => {
initInput = input;
}),
destroy: () => Effect.die("unused"),
execute: () => () => Effect.die("unused"),
execute: () => Effect.die("unused"),
attach: () => Effect.die("unused"),
detach: () => Effect.die("unused"),
}),
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/test/list-branch-project.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/** @effect-diagnostics strictEffectProvide:off */
import { listBranchProject, WorkTreeFileSystemServiceLayer } from "@skippercorp/core/workspace";
import {
listBranchProject,
WorkTreeFileSystemServiceLayer,
WorkTreeWorkspaceRegistryServiceLayer,
} from "@skippercorp/core/workspace";
import { describe, expect, it } from "@effect/vitest";
import { Effect, FileSystem, Path } from "effect";
import { homedir } from "node:os";
Expand All @@ -12,6 +16,7 @@ describe("listBranchProject", () => {
const created: Array<{ path: string; recursive: boolean | undefined }> = [];

const result = yield* listBranchProject("chronops").pipe(
Effect.provide(WorkTreeWorkspaceRegistryServiceLayer),
Effect.provide(WorkTreeFileSystemServiceLayer),
Effect.provide(Path.layer),
Effect.provide(
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/test/remove-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe("workspace remove", () => {
calls.destroyInputs.push(input);
calls.sandboxDestroyed++;
}),
execute: () => () => Effect.die("unused"),
execute: () => Effect.die("unused"),
attach: () => Effect.void,
detach: (project) => Effect.sync(() => void calls.detached.push(project)),
}),
Expand Down Expand Up @@ -100,7 +100,7 @@ describe("workspace remove", () => {
calls.destroyInputs.push(input);
calls.sandboxDestroyed++;
}),
execute: () => () => Effect.die("unused"),
execute: () => Effect.die("unused"),
attach: () => Effect.void,
detach: () => Effect.sync(() => void calls.detached++),
}),
Expand Down Expand Up @@ -157,7 +157,7 @@ describe("workspace remove", () => {
calls.destroyInputs.push(input);
calls.sandboxDestroyed++;
}),
execute: () => () => Effect.die("unused"),
execute: () => Effect.die("unused"),
attach: () => Effect.void,
detach: (project) => Effect.sync(() => void calls.detached.push(project)),
}),
Expand Down Expand Up @@ -216,7 +216,7 @@ describe("workspace remove", () => {
Effect.sync(() => {
calls.destroyInputs.push(input);
}),
execute: () => () => Effect.die("unused"),
execute: () => Effect.die("unused"),
attach: () => Effect.void,
detach: () => Effect.void,
}),
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/test/sandbox-flag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "@effect/vitest";
import { resolveSandboxFromArgv } from "../src/common/global-flags.ts";

describe("sandbox flag", () => {
it("defaults to worktree", () => {
expect(resolveSandboxFromArgv([])).toBe("worktree");
});

it("detects explicit docker flag", () => {
expect(resolveSandboxFromArgv(["workspace", "run", "--sandbox", "docker"])).toBe("docker");
expect(resolveSandboxFromArgv(["--sandbox=docker", "workspace", "run"])).toBe("docker");
});

it("falls back to worktree for invalid values", () => {
expect(resolveSandboxFromArgv(["--sandbox", "invalid"])).toBe("worktree");
});
});
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"exports": {
".": "./src/index.ts",
"./common/sql": "./src/common/sql.ts",
"./runtime/docker.runtime": "./src/runtime/docker.runtime.ts",
"./runtime/local-work-tree.runtime": "./src/runtime/local-work-tree.runtime.ts",
"./common/adapter/*": "./src/common/adapter/*.ts",
"./*": "./src/*/index.ts"
Expand Down
Loading