@cvr/stacked 0.2.0 → 0.4.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.
@@ -2,37 +2,50 @@ import { Argument, Command, Flag } from "effect/unstable/cli";
2
2
  import { Console, Effect } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
- import { StackError } from "../errors/index.js";
5
+ import { ErrorCode, StackError } from "../errors/index.js";
6
+ import { confirm } from "../ui.js";
6
7
 
7
- const nameArg = Argument.string("name");
8
- const forceFlag = Flag.boolean("force").pipe(Flag.withAlias("f"));
8
+ const nameArg = Argument.string("name").pipe(Argument.withDescription("Branch name to delete"));
9
+ const forceFlag = Flag.boolean("force").pipe(
10
+ Flag.withAlias("f"),
11
+ Flag.withDescription("Delete even if branch has children in the stack"),
12
+ );
13
+ const keepRemoteFlag = Flag.boolean("keep-remote").pipe(
14
+ Flag.withDescription("Don't delete the remote branch"),
15
+ );
16
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
17
+ const dryRunFlag = Flag.boolean("dry-run").pipe(
18
+ Flag.withDescription("Show what would happen without making changes"),
19
+ );
9
20
 
10
- export const deleteCmd = Command.make("delete", { name: nameArg, force: forceFlag }).pipe(
21
+ export const deleteCmd = Command.make("delete", {
22
+ name: nameArg,
23
+ force: forceFlag,
24
+ keepRemote: keepRemoteFlag,
25
+ json: jsonFlag,
26
+ dryRun: dryRunFlag,
27
+ }).pipe(
11
28
  Command.withDescription("Remove branch from stack and delete git branch"),
12
- Command.withHandler(({ name, force }) =>
29
+ Command.withExamples([
30
+ { command: "stacked delete feat-old", description: "Delete a leaf branch" },
31
+ { command: "stacked delete feat-mid --force", description: "Force delete a mid-stack branch" },
32
+ ]),
33
+ Command.withHandler(({ name, force, keepRemote, json, dryRun }) =>
13
34
  Effect.gen(function* () {
14
35
  const git = yield* GitService;
15
36
  const stacks = yield* StackService;
16
37
 
17
38
  const currentBranch = yield* git.currentBranch();
18
- const data = yield* stacks.load();
19
-
20
- let stackName: string | null = null;
21
- for (const [sName, stack] of Object.entries(data.stacks)) {
22
- if (stack.branches.includes(name)) {
23
- stackName = sName;
24
- break;
25
- }
26
- }
39
+ const trunk = yield* stacks.getTrunk();
27
40
 
28
- if (stackName === null) {
29
- return yield* new StackError({ message: `Branch "${name}" not found in any stack` });
30
- }
31
-
32
- const stack = data.stacks[stackName];
33
- if (stack === undefined) {
34
- return yield* new StackError({ message: `Stack "${stackName}" not found` });
41
+ const result = yield* stacks.findBranchStack(name);
42
+ if (result === null) {
43
+ return yield* new StackError({
44
+ code: ErrorCode.BRANCH_NOT_FOUND,
45
+ message: `Branch "${name}" not found in any stack`,
46
+ });
35
47
  }
48
+ const { name: stackName, stack } = result;
36
49
  const idx = stack.branches.indexOf(name);
37
50
 
38
51
  if (idx < stack.branches.length - 1 && !force) {
@@ -41,15 +54,70 @@ export const deleteCmd = Command.make("delete", { name: nameArg, force: forceFla
41
54
  });
42
55
  }
43
56
 
57
+ const hadChildren = idx < stack.branches.length - 1;
58
+ const willDeleteRemote = !keepRemote;
59
+
60
+ if (dryRun) {
61
+ if (json) {
62
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
63
+ yield* Console.log(
64
+ JSON.stringify(
65
+ { branch: name, stack: stackName, hadChildren, deleteRemote: willDeleteRemote },
66
+ null,
67
+ 2,
68
+ ),
69
+ );
70
+ } else {
71
+ yield* Console.error(`Would delete branch "${name}" from stack "${stackName}"`);
72
+ if (hadChildren) {
73
+ yield* Console.error("Warning: branch has children that would be orphaned");
74
+ }
75
+ if (willDeleteRemote) {
76
+ yield* Console.error("Would also delete remote branch");
77
+ }
78
+ }
79
+ return;
80
+ }
81
+
82
+ const confirmed = yield* confirm(
83
+ `Delete branch "${name}"${keepRemote ? "" : " (local + remote)"}?`,
84
+ );
85
+ if (!confirmed) {
86
+ yield* Console.error("Aborted");
87
+ return;
88
+ }
89
+
44
90
  if (currentBranch === name) {
45
- const parent = idx === 0 ? data.trunk : (stack.branches[idx - 1] ?? data.trunk);
91
+ const clean = yield* git.isClean();
92
+ if (!clean) {
93
+ return yield* new StackError({
94
+ code: ErrorCode.DIRTY_WORKTREE,
95
+ message:
96
+ "Working tree has uncommitted changes. Commit or stash before deleting the current branch.",
97
+ });
98
+ }
99
+ const parent = idx === 0 ? trunk : (stack.branches[idx - 1] ?? trunk);
46
100
  yield* git.checkout(parent);
47
101
  }
48
102
 
49
- yield* stacks.removeBranch(stackName, name);
50
103
  yield* git.deleteBranch(name, force);
104
+ yield* stacks.removeBranch(stackName, name);
105
+
106
+ if (willDeleteRemote) {
107
+ yield* git.deleteRemoteBranch(name).pipe(Effect.catchTag("GitError", () => Effect.void));
108
+ }
51
109
 
52
- yield* Console.log(`Deleted ${name}`);
110
+ if (json) {
111
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
112
+ yield* Console.log(JSON.stringify({ deleted: name, hadChildren }, null, 2));
113
+ } else {
114
+ yield* Console.error(`Deleted ${name}`);
115
+ if (hadChildren) {
116
+ yield* Console.error(
117
+ "Warning: branch had children — commits unique to this branch may be lost if children don't include them. Run 'stacked sync' to rebase them onto the new parent.",
118
+ );
119
+ }
120
+ }
53
121
  }),
54
122
  ),
55
123
  );
@@ -0,0 +1,152 @@
1
+ import { Command, Flag } from "effect/unstable/cli";
2
+ import { Console, Effect } from "effect";
3
+ import { GitService } from "../services/Git.js";
4
+ import { StackService } from "../services/Stack.js";
5
+ import { success, warn, info } from "../ui.js";
6
+
7
+ const dryRunFlag = Flag.boolean("dry-run").pipe(
8
+ Flag.withDescription("Show what would be detected without making changes"),
9
+ );
10
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
11
+
12
+ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFlag }).pipe(
13
+ Command.withDescription("Detect and register branch stacks from git history"),
14
+ Command.withExamples([
15
+ { command: "stacked detect", description: "Auto-discover and register stacks" },
16
+ { command: "stacked detect --dry-run", description: "Preview what would be detected" },
17
+ ]),
18
+ Command.withHandler(({ dryRun, json }) =>
19
+ Effect.gen(function* () {
20
+ const git = yield* GitService;
21
+ const stacks = yield* StackService;
22
+
23
+ const trunk = yield* stacks.getTrunk();
24
+ const allBranches = yield* git.listBranches();
25
+ const candidates = allBranches.filter((b) => b !== trunk);
26
+
27
+ const data = yield* stacks.load();
28
+ const alreadyTracked = new Set(Object.values(data.stacks).flatMap((s) => [...s.branches]));
29
+ const untracked = candidates.filter((b) => !alreadyTracked.has(b));
30
+
31
+ if (untracked.length === 0) {
32
+ yield* Console.error("No untracked branches found");
33
+ return;
34
+ }
35
+
36
+ // Build parent map: for each branch, find its direct parent among other branches
37
+ // A parent is the closest ancestor — i.e., an ancestor that is not an ancestor of another ancestor
38
+ const childOf = new Map<string, string>();
39
+
40
+ yield* Effect.forEach(
41
+ untracked,
42
+ (branch) =>
43
+ Effect.gen(function* () {
44
+ // Check all potential ancestors (trunk + other untracked) in parallel
45
+ const potentialAncestors = [trunk, ...untracked.filter((b) => b !== branch)];
46
+ const ancestryResults = yield* Effect.forEach(
47
+ potentialAncestors,
48
+ (other) =>
49
+ git.isAncestor(other, branch).pipe(
50
+ Effect.catchTag("GitError", () => Effect.succeed(false)),
51
+ Effect.map((is) => [other, is] as const),
52
+ ),
53
+ { concurrency: 5 },
54
+ );
55
+
56
+ const ancestors = ancestryResults.filter(([_, is]) => is).map(([name]) => name);
57
+
58
+ if (ancestors.length === 0) return;
59
+
60
+ // Find the closest ancestor — the one that is a descendant of all others
61
+ let closest = ancestors[0] ?? trunk;
62
+ for (let i = 1; i < ancestors.length; i++) {
63
+ const candidate = ancestors[i];
64
+ if (candidate === undefined) continue;
65
+ const candidateIsCloser = yield* git
66
+ .isAncestor(closest, candidate)
67
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
68
+ if (candidateIsCloser) closest = candidate;
69
+ }
70
+
71
+ childOf.set(branch, closest);
72
+ }),
73
+ { concurrency: 5 },
74
+ );
75
+
76
+ // Build linear chains from trunk
77
+ // Find branches whose parent is trunk (chain roots)
78
+ const chains: string[][] = [];
79
+ const roots = untracked.filter((b) => childOf.get(b) === trunk);
80
+
81
+ for (const root of roots) {
82
+ const chain = [root];
83
+ let current = root;
84
+
85
+ while (true) {
86
+ const children = untracked.filter((b) => childOf.get(b) === current);
87
+ const child = children[0];
88
+ if (children.length === 1 && child !== undefined) {
89
+ chain.push(child);
90
+ current = child;
91
+ } else {
92
+ // 0 children = end of chain, 2+ children = fork (skip)
93
+ break;
94
+ }
95
+ }
96
+
97
+ chains.push(chain);
98
+ }
99
+
100
+ // Report forks
101
+ const forkPoints = untracked.filter((b) => {
102
+ const children = untracked.filter((c) => childOf.get(c) === b);
103
+ return children.length > 1;
104
+ });
105
+ const forks = forkPoints.map((b) => ({
106
+ branch: b,
107
+ children: untracked.filter((c) => childOf.get(c) === b),
108
+ }));
109
+
110
+ if (json) {
111
+ const stacksData = chains.map((chain) => ({ name: chain[0] ?? "", branches: chain }));
112
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
113
+ yield* Console.log(JSON.stringify({ stacks: stacksData, forks }, null, 2));
114
+ return;
115
+ }
116
+
117
+ if (chains.length === 0) {
118
+ yield* info("No linear branch chains detected");
119
+ return;
120
+ }
121
+
122
+ for (const chain of chains) {
123
+ const name = chain[0];
124
+ if (name === undefined) continue;
125
+ const currentData = yield* stacks.load();
126
+ if (currentData.stacks[name] !== undefined) {
127
+ yield* warn(`Stack "${name}" already exists, skipping: ${chain.join(" → ")}`);
128
+ continue;
129
+ }
130
+ if (dryRun) {
131
+ yield* Console.error(`Would create stack "${name}": ${chain.join(" → ")}`);
132
+ } else {
133
+ yield* stacks.createStack(name, chain);
134
+ yield* success(`Created stack "${name}": ${chain.join(" → ")}`);
135
+ }
136
+ }
137
+
138
+ if (dryRun) {
139
+ yield* Console.error(
140
+ `\n${chains.length} stack${chains.length === 1 ? "" : "s"} would be created`,
141
+ );
142
+ }
143
+
144
+ if (forks.length > 0) {
145
+ yield* warn("Forked branches detected (not supported yet):");
146
+ for (const fork of forks) {
147
+ yield* Console.error(` ${fork.branch} → ${fork.children.join(", ")}`);
148
+ }
149
+ }
150
+ }),
151
+ ),
152
+ );
@@ -0,0 +1,124 @@
1
+ import { Command, Flag } from "effect/unstable/cli";
2
+ import { Console, Effect } from "effect";
3
+ import { GitService } from "../services/Git.js";
4
+ import { StackService } from "../services/Stack.js";
5
+ import { success, warn } from "../ui.js";
6
+
7
+ const fixFlag = Flag.boolean("fix").pipe(Flag.withDescription("Auto-fix issues where possible"));
8
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
9
+
10
+ interface Finding {
11
+ type: "stale_branch" | "missing_trunk" | "duplicate_branch" | "parse_error";
12
+ message: string;
13
+ fixed: boolean;
14
+ }
15
+
16
+ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).pipe(
17
+ Command.withDescription("Check stack metadata for issues and optionally fix them"),
18
+ Command.withExamples([
19
+ { command: "stacked doctor", description: "Check for metadata drift" },
20
+ { command: "stacked doctor --fix", description: "Auto-fix detected issues" },
21
+ ]),
22
+ Command.withHandler(({ fix, json }) =>
23
+ Effect.gen(function* () {
24
+ const git = yield* GitService;
25
+ const stacks = yield* StackService;
26
+
27
+ const data = yield* stacks.load();
28
+ const findings: Finding[] = [];
29
+
30
+ // Check 1: trunk branch exists
31
+ const trunkExists = yield* git
32
+ .branchExists(data.trunk)
33
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
34
+ if (!trunkExists) {
35
+ if (fix) {
36
+ // Auto-detect a trunk
37
+ for (const candidate of ["main", "master", "develop"]) {
38
+ const exists = yield* git
39
+ .branchExists(candidate)
40
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
41
+ if (exists) {
42
+ yield* stacks.setTrunk(candidate);
43
+ findings.push({
44
+ type: "missing_trunk",
45
+ message: `Trunk "${data.trunk}" not found, set to "${candidate}"`,
46
+ fixed: true,
47
+ });
48
+ break;
49
+ }
50
+ }
51
+ } else {
52
+ findings.push({
53
+ type: "missing_trunk",
54
+ message: `Trunk branch "${data.trunk}" does not exist`,
55
+ fixed: false,
56
+ });
57
+ }
58
+ }
59
+
60
+ // Check 2: all tracked branches exist in git
61
+ for (const [stackName, stack] of Object.entries(data.stacks)) {
62
+ for (const branch of stack.branches) {
63
+ const exists = yield* git
64
+ .branchExists(branch)
65
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
66
+ if (!exists) {
67
+ if (fix) {
68
+ yield* stacks.removeBranch(stackName, branch);
69
+ findings.push({
70
+ type: "stale_branch",
71
+ message: `Removed stale branch "${branch}" from stack "${stackName}"`,
72
+ fixed: true,
73
+ });
74
+ } else {
75
+ findings.push({
76
+ type: "stale_branch",
77
+ message: `Branch "${branch}" in stack "${stackName}" does not exist in git`,
78
+ fixed: false,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ // Check 3: no branches in multiple stacks
86
+ const branchToStacks = new Map<string, string[]>();
87
+ for (const [stackName, stack] of Object.entries(data.stacks)) {
88
+ for (const branch of stack.branches) {
89
+ const existing = branchToStacks.get(branch) ?? [];
90
+ existing.push(stackName);
91
+ branchToStacks.set(branch, existing);
92
+ }
93
+ }
94
+ for (const [branch, stackNames] of branchToStacks) {
95
+ if (stackNames.length > 1) {
96
+ findings.push({
97
+ type: "duplicate_branch",
98
+ message: `Branch "${branch}" appears in multiple stacks: ${stackNames.join(", ")}`,
99
+ fixed: false,
100
+ });
101
+ }
102
+ }
103
+
104
+ if (json) {
105
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
106
+ yield* Console.log(JSON.stringify({ findings }, null, 2));
107
+ } else if (findings.length === 0) {
108
+ yield* success("No issues found");
109
+ } else {
110
+ for (const f of findings) {
111
+ if (f.fixed) {
112
+ yield* success(f.message);
113
+ } else {
114
+ yield* warn(f.message);
115
+ }
116
+ }
117
+ const fixable = findings.filter((f) => !f.fixed).length;
118
+ if (fixable > 0 && !fix) {
119
+ yield* Console.error(`\nRun 'stacked doctor --fix' to auto-fix ${fixable} issue(s)`);
120
+ }
121
+ }
122
+ }),
123
+ ),
124
+ );
@@ -0,0 +1,62 @@
1
+ import { Command, Flag } from "effect/unstable/cli";
2
+ import { Console, Effect } from "effect";
3
+ import { GitService } from "../services/Git.js";
4
+ import { StackService } from "../services/Stack.js";
5
+ import { ErrorCode, StackError } from "../errors/index.js";
6
+ import { success } from "../ui.js";
7
+
8
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Output as JSON"));
9
+
10
+ export const down = Command.make("down", { json: jsonFlag }).pipe(
11
+ Command.withDescription("Move down one branch in the stack"),
12
+ Command.withExamples([{ command: "stacked down", description: "Move to the next branch below" }]),
13
+ Command.withHandler(({ json }) =>
14
+ Effect.gen(function* () {
15
+ const git = yield* GitService;
16
+ const stacks = yield* StackService;
17
+
18
+ const currentBranch = yield* git.currentBranch();
19
+ const result = yield* stacks.currentStack();
20
+ if (result === null) {
21
+ return yield* new StackError({
22
+ code: ErrorCode.NOT_IN_STACK,
23
+ message:
24
+ "Not on a stacked branch. Run 'stacked list' to see your stacks, or 'stacked create <name>' to start one.",
25
+ });
26
+ }
27
+
28
+ const { branches } = result.stack;
29
+ const idx = branches.indexOf(currentBranch);
30
+ if (idx === -1) {
31
+ return yield* new StackError({
32
+ code: ErrorCode.NOT_IN_STACK,
33
+ message: "Current branch not found in stack",
34
+ });
35
+ }
36
+
37
+ if (idx === 0) {
38
+ return yield* new StackError({
39
+ code: ErrorCode.ALREADY_AT_BOTTOM,
40
+ message: "Already at the bottom of the stack",
41
+ });
42
+ }
43
+
44
+ const prev = branches[idx - 1];
45
+ if (prev === undefined) {
46
+ return yield* new StackError({
47
+ code: ErrorCode.ALREADY_AT_BOTTOM,
48
+ message: "Already at the bottom of the stack",
49
+ });
50
+ }
51
+
52
+ yield* git.checkout(prev);
53
+
54
+ if (json) {
55
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
56
+ yield* Console.log(JSON.stringify({ branch: prev, from: currentBranch }, null, 2));
57
+ } else {
58
+ yield* success(`Switched to ${prev}`);
59
+ }
60
+ }),
61
+ ),
62
+ );
@@ -0,0 +1,61 @@
1
+ import { Effect } from "effect";
2
+ import { ErrorCode, StackError } from "../../errors/index.js";
3
+
4
+ const BRANCH_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/;
5
+
6
+ export const validateBranchName = Effect.fn("validateBranchName")(function* (name: string) {
7
+ if (name === "") {
8
+ return yield* new StackError({
9
+ code: ErrorCode.INVALID_BRANCH_NAME,
10
+ message: "Branch name cannot be empty",
11
+ });
12
+ }
13
+ if (name.startsWith("-")) {
14
+ return yield* new StackError({
15
+ code: ErrorCode.INVALID_BRANCH_NAME,
16
+ message: `Invalid branch name "${name}": cannot start with "-"`,
17
+ });
18
+ }
19
+ if (name.includes("..")) {
20
+ return yield* new StackError({
21
+ code: ErrorCode.INVALID_BRANCH_NAME,
22
+ message: `Invalid branch name "${name}": cannot contain ".."`,
23
+ });
24
+ }
25
+ if (name.includes(" ")) {
26
+ return yield* new StackError({
27
+ code: ErrorCode.INVALID_BRANCH_NAME,
28
+ message: `Invalid branch name "${name}": cannot contain spaces`,
29
+ });
30
+ }
31
+ if (name.endsWith(".lock")) {
32
+ return yield* new StackError({
33
+ code: ErrorCode.INVALID_BRANCH_NAME,
34
+ message: `Invalid branch name "${name}": cannot end with ".lock"`,
35
+ });
36
+ }
37
+ if (name.endsWith(".")) {
38
+ return yield* new StackError({
39
+ code: ErrorCode.INVALID_BRANCH_NAME,
40
+ message: `Invalid branch name "${name}": cannot end with "."`,
41
+ });
42
+ }
43
+ if (name.endsWith("/")) {
44
+ return yield* new StackError({
45
+ code: ErrorCode.INVALID_BRANCH_NAME,
46
+ message: `Invalid branch name "${name}": cannot end with "/"`,
47
+ });
48
+ }
49
+ if (name === "@") {
50
+ return yield* new StackError({
51
+ code: ErrorCode.INVALID_BRANCH_NAME,
52
+ message: `Invalid branch name "${name}": "@" alone is not a valid branch name`,
53
+ });
54
+ }
55
+ if (!BRANCH_NAME_PATTERN.test(name)) {
56
+ return yield* new StackError({
57
+ code: ErrorCode.INVALID_BRANCH_NAME,
58
+ message: `Invalid branch name "${name}": must start with alphanumeric and contain only alphanumerics, dots, hyphens, underscores, or slashes`,
59
+ });
60
+ }
61
+ });
@@ -6,15 +6,33 @@ import { stacks } from "./stacks.js";
6
6
  import { checkout } from "./checkout.js";
7
7
  import { top } from "./top.js";
8
8
  import { bottom } from "./bottom.js";
9
+ import { up } from "./up.js";
10
+ import { down } from "./down.js";
9
11
  import { sync } from "./sync.js";
10
12
  import { deleteCmd } from "./delete.js";
11
13
  import { submit } from "./submit.js";
12
14
  import { adopt } from "./adopt.js";
13
15
  import { log } from "./log.js";
14
16
  import { clean } from "./clean.js";
17
+ import { detect } from "./detect.js";
18
+ import { init } from "./init.js";
19
+ import { status } from "./status.js";
20
+ import { doctor } from "./doctor.js";
21
+ import { rename } from "./rename.js";
22
+ import { reorder } from "./reorder.js";
23
+ import { split } from "./split.js";
24
+ import { amend } from "./amend.js";
15
25
 
16
26
  const root = Command.make("stacked").pipe(
17
- Command.withDescription("Branch-based stacked PR manager"),
27
+ Command.withDescription(
28
+ "Branch-based stacked PR manager\n\nGlobal flags:\n --verbose Show detailed output\n --quiet, -q Suppress non-essential output\n --no-color Disable color output\n --yes, -y Skip confirmation prompts",
29
+ ),
30
+ Command.withExamples([
31
+ { command: "stacked create feat-auth", description: "Create a new branch in the stack" },
32
+ { command: "stacked list", description: "Show branches in the current stack" },
33
+ { command: "stacked sync", description: "Rebase all branches in order" },
34
+ { command: "stacked submit", description: "Push and create/update PRs" },
35
+ ]),
18
36
  );
19
37
 
20
38
  export const command = root.pipe(
@@ -26,11 +44,21 @@ export const command = root.pipe(
26
44
  checkout,
27
45
  top,
28
46
  bottom,
47
+ up,
48
+ down,
29
49
  sync,
30
50
  deleteCmd,
31
51
  submit,
32
52
  adopt,
33
53
  log,
34
54
  clean,
55
+ detect,
56
+ init,
57
+ status,
58
+ doctor,
59
+ rename,
60
+ reorder,
61
+ split,
62
+ amend,
35
63
  ]),
36
64
  );
@@ -0,0 +1,40 @@
1
+ import { Command } from "effect/unstable/cli";
2
+ import { Console, Effect } from "effect";
3
+ import { StackError } from "../errors/index.js";
4
+ import { mkdirSync, writeFileSync } from "fs";
5
+ import { join } from "path";
6
+ import { homedir } from "os";
7
+
8
+ const skillContent = typeof __SKILL_CONTENT__ !== "undefined" ? __SKILL_CONTENT__ : null;
9
+
10
+ export const init = Command.make("init").pipe(
11
+ Command.withDescription("Install the stacked Claude skill to ~/.claude/skills"),
12
+ Command.withExamples([{ command: "stacked init", description: "Install the Claude skill" }]),
13
+ Command.withHandler(() =>
14
+ Effect.gen(function* () {
15
+ if (skillContent === null) {
16
+ return yield* new StackError({
17
+ message: "Skill content not available. This command only works with the compiled binary.",
18
+ });
19
+ }
20
+
21
+ const skillsDir = process.env["STACKED_SKILLS_DIR"] ?? join(homedir(), ".claude", "skills");
22
+ const targetDir = join(skillsDir, "stacked");
23
+ const targetPath = join(targetDir, "SKILL.md");
24
+
25
+ yield* Console.error(`Writing skill to ${targetPath}...`);
26
+ yield* Effect.try({
27
+ try: () => {
28
+ mkdirSync(targetDir, { recursive: true });
29
+ writeFileSync(targetPath, skillContent);
30
+ },
31
+ catch: (e) => new StackError({ message: `Failed to write skill: ${e}` }),
32
+ });
33
+
34
+ yield* Console.error(`Installed stacked skill to ${targetPath}`);
35
+ yield* Console.error("\nNext steps:");
36
+ yield* Console.error(" stacked trunk # verify/set your trunk branch");
37
+ yield* Console.error(" stacked create <name> # start your first stack");
38
+ }),
39
+ ),
40
+ );