@cvr/stacked 0.3.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
  );
@@ -2,12 +2,20 @@ import { 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 { success, warn, info } from "../ui.js";
5
6
 
6
- const dryRunFlag = Flag.boolean("dry-run");
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"));
7
11
 
8
- export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
12
+ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFlag }).pipe(
9
13
  Command.withDescription("Detect and register branch stacks from git history"),
10
- Command.withHandler(({ dryRun }) =>
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 }) =>
11
19
  Effect.gen(function* () {
12
20
  const git = yield* GitService;
13
21
  const stacks = yield* StackService;
@@ -21,7 +29,7 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
21
29
  const untracked = candidates.filter((b) => !alreadyTracked.has(b));
22
30
 
23
31
  if (untracked.length === 0) {
24
- yield* Console.log("No untracked branches found");
32
+ yield* Console.error("No untracked branches found");
25
33
  return;
26
34
  }
27
35
 
@@ -29,39 +37,41 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
29
37
  // A parent is the closest ancestor — i.e., an ancestor that is not an ancestor of another ancestor
30
38
  const childOf = new Map<string, string>();
31
39
 
32
- for (const branch of untracked) {
33
- const ancestors: string[] = [];
34
-
35
- // Check trunk
36
- const trunkIsAncestor = yield* git
37
- .isAncestor(trunk, branch)
38
- .pipe(Effect.catch(() => Effect.succeed(false)));
39
- if (trunkIsAncestor) ancestors.push(trunk);
40
-
41
- // Check other untracked branches
42
- for (const other of untracked) {
43
- if (other === branch) continue;
44
- const is = yield* git
45
- .isAncestor(other, branch)
46
- .pipe(Effect.catch(() => Effect.succeed(false)));
47
- if (is) ancestors.push(other);
48
- }
49
-
50
- if (ancestors.length === 0) continue;
51
-
52
- // Find the closest ancestor — the one that is a descendant of all others
53
- let closest = ancestors[0] ?? trunk;
54
- for (let i = 1; i < ancestors.length; i++) {
55
- const candidate = ancestors[i];
56
- if (candidate === undefined) continue;
57
- const candidateIsCloser = yield* git
58
- .isAncestor(closest, candidate)
59
- .pipe(Effect.catch(() => Effect.succeed(false)));
60
- if (candidateIsCloser) closest = candidate;
61
- }
62
-
63
- childOf.set(branch, closest);
64
- }
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
+ );
65
75
 
66
76
  // Build linear chains from trunk
67
77
  // Find branches whose parent is trunk (chain roots)
@@ -87,38 +97,54 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag }).pipe(
87
97
  chains.push(chain);
88
98
  }
89
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
+
90
117
  if (chains.length === 0) {
91
- yield* Console.log("No linear branch chains detected");
118
+ yield* info("No linear branch chains detected");
92
119
  return;
93
120
  }
94
121
 
95
122
  for (const chain of chains) {
96
123
  const name = chain[0];
97
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
+ }
98
130
  if (dryRun) {
99
- yield* Console.log(`Would create stack "${name}": ${chain.join(" → ")}`);
131
+ yield* Console.error(`Would create stack "${name}": ${chain.join(" → ")}`);
100
132
  } else {
101
133
  yield* stacks.createStack(name, chain);
102
- yield* Console.log(`Created stack "${name}": ${chain.join(" → ")}`);
134
+ yield* success(`Created stack "${name}": ${chain.join(" → ")}`);
103
135
  }
104
136
  }
105
137
 
106
138
  if (dryRun) {
107
- yield* Console.log(
139
+ yield* Console.error(
108
140
  `\n${chains.length} stack${chains.length === 1 ? "" : "s"} would be created`,
109
141
  );
110
142
  }
111
143
 
112
- // Report forks
113
- const forkPoints = untracked.filter((b) => {
114
- const children = untracked.filter((c) => childOf.get(c) === b);
115
- return children.length > 1;
116
- });
117
- if (forkPoints.length > 0) {
118
- yield* Console.log("\nNote: forked branches detected (not supported yet):");
119
- for (const branch of forkPoints) {
120
- const children = untracked.filter((c) => childOf.get(c) === branch);
121
- yield* Console.log(` ${branch} → ${children.join(", ")}`);
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(", ")}`);
122
148
  }
123
149
  }
124
150
  }),
@@ -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,6 +6,8 @@ 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";
@@ -13,9 +15,24 @@ import { adopt } from "./adopt.js";
13
15
  import { log } from "./log.js";
14
16
  import { clean } from "./clean.js";
15
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";
16
25
 
17
26
  const root = Command.make("stacked").pipe(
18
- 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
+ ]),
19
36
  );
20
37
 
21
38
  export const command = root.pipe(
@@ -27,6 +44,8 @@ export const command = root.pipe(
27
44
  checkout,
28
45
  top,
29
46
  bottom,
47
+ up,
48
+ down,
30
49
  sync,
31
50
  deleteCmd,
32
51
  submit,
@@ -34,5 +53,12 @@ export const command = root.pipe(
34
53
  log,
35
54
  clean,
36
55
  detect,
56
+ init,
57
+ status,
58
+ doctor,
59
+ rename,
60
+ reorder,
61
+ split,
62
+ amend,
37
63
  ]),
38
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
+ );