pi-release 1.0.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.
@@ -0,0 +1,955 @@
1
+ /**
2
+ * pi-release — Release Automation Extension
3
+ *
4
+ * Automates the full release flow: bump version → changelog → commit →
5
+ * tag → GitHub release → npm publish.
6
+ *
7
+ * Tools:
8
+ * 1. bump_version — Bump package.json version, create git tag
9
+ * 2. create_github_release — Create GitHub release from tag
10
+ * 3. publish_npm — Publish package to npm registry
11
+ *
12
+ * Commands:
13
+ * /release — Full orchestrated release flow
14
+ */
15
+
16
+ import { execSync } from "node:child_process";
17
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { Type } from "typebox";
21
+
22
+ // ── Types ───────────────────────────────────────────────────────────
23
+
24
+ type BumpType = "major" | "minor" | "patch";
25
+
26
+ interface ReleaseConfig {
27
+ /** npm registry URL (default: https://registry.npmjs.org/) */
28
+ registry: string;
29
+ /** GitHub owner/repo (auto-detected from git remote) */
30
+ githubRepo: string | null;
31
+ /** Default bump type (default: patch) */
32
+ defaultBump: BumpType;
33
+ /** Pre-release tag (e.g. "beta", "alpha") — empty string for stable */
34
+ preReleaseTag: string;
35
+ /** Whether to generate changelog before release */
36
+ generateChangelog: boolean;
37
+ /** Whether to create git commit for version bump */
38
+ commitBump: boolean;
39
+ /** Custom commit message template — {version} is replaced */
40
+ commitMessage: string;
41
+ /** Whether to create git tag */
42
+ createTag: boolean;
43
+ /** Custom tag prefix (default: "v") */
44
+ tagPrefix: string;
45
+ /** Whether to create GitHub release */
46
+ createGithubRelease: boolean;
47
+ /** Whether to publish to npm */
48
+ publishToNpm: boolean;
49
+ }
50
+
51
+ interface BumpResult {
52
+ previousVersion: string;
53
+ newVersion: string;
54
+ tag: string;
55
+ commitHash: string | null;
56
+ dryRun: boolean;
57
+ }
58
+
59
+ interface GithubReleaseResult {
60
+ tag: string;
61
+ url: string;
62
+ dryRun: boolean;
63
+ }
64
+
65
+ interface PublishResult {
66
+ package: string;
67
+ version: string;
68
+ registry: string;
69
+ dryRun: boolean;
70
+ }
71
+
72
+ // ── Default Config ──────────────────────────────────────────────────
73
+
74
+ const DEFAULT_CONFIG: ReleaseConfig = {
75
+ registry: "https://registry.npmjs.org/",
76
+ githubRepo: null,
77
+ defaultBump: "patch",
78
+ preReleaseTag: "",
79
+ generateChangelog: true,
80
+ commitBump: true,
81
+ commitMessage: "chore(release): {version}",
82
+ createTag: true,
83
+ tagPrefix: "v",
84
+ createGithubRelease: true,
85
+ publishToNpm: true,
86
+ };
87
+
88
+ // ── Config Loading ──────────────────────────────────────────────────
89
+
90
+ function loadConfig(cwd?: string): ReleaseConfig {
91
+ const base = cwd || process.cwd();
92
+ const configPaths = ["pi-release.json", ".pi-release.json"];
93
+
94
+ for (const configPath of configPaths) {
95
+ const fullPath = join(base, configPath);
96
+ if (existsSync(fullPath)) {
97
+ try {
98
+ const raw = readFileSync(fullPath, "utf-8");
99
+ const parsed = JSON.parse(raw) as Partial<ReleaseConfig>;
100
+ return { ...DEFAULT_CONFIG, ...parsed };
101
+ } catch {
102
+ // Fall back to defaults if config is malformed
103
+ }
104
+ }
105
+ }
106
+
107
+ return { ...DEFAULT_CONFIG };
108
+ }
109
+
110
+ // ── Shell Helpers ───────────────────────────────────────────────────
111
+
112
+ function exec(cmd: string, cwd?: string): string {
113
+ try {
114
+ return execSync(cmd, {
115
+ cwd: cwd || process.cwd(),
116
+ encoding: "utf-8",
117
+ timeout: 60_000,
118
+ stdio: ["pipe", "pipe", "pipe"],
119
+ }).trim();
120
+ } catch (err: unknown) {
121
+ const e = err as { stdout?: string; stderr?: string; message?: string };
122
+ if (e.stderr) throw new Error(e.stderr);
123
+ throw new Error(e.message || "Command failed");
124
+ }
125
+ }
126
+
127
+ // ── Package.json Helpers ────────────────────────────────────────────
128
+
129
+ function readPackageJson(cwd?: string): { name: string; version: string } {
130
+ const base = cwd || process.cwd();
131
+ const pkgPath = join(base, "package.json");
132
+ if (!existsSync(pkgPath)) {
133
+ throw new Error("No package.json found in current directory");
134
+ }
135
+ const raw = readFileSync(pkgPath, "utf-8");
136
+ const pkg = JSON.parse(raw) as { name?: string; version?: string };
137
+ if (!pkg.name) throw new Error("package.json missing 'name' field");
138
+ if (!pkg.version) throw new Error("package.json missing 'version' field");
139
+ return { name: pkg.name, version: pkg.version };
140
+ }
141
+
142
+ function writePackageVersion(version: string, cwd?: string): void {
143
+ const base = cwd || process.cwd();
144
+ const pkgPath = join(base, "package.json");
145
+ const raw = readFileSync(pkgPath, "utf-8");
146
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
147
+ pkg.version = version;
148
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
149
+ }
150
+
151
+ // ── Version Bumping ─────────────────────────────────────────────────
152
+
153
+ export function bumpSemver(version: string, type: BumpType): string {
154
+ const parts = version.split(".").map(Number);
155
+ if (parts.length !== 3 || parts.some((p) => Number.isNaN(p))) {
156
+ throw new Error(`Invalid semver version: ${version}`);
157
+ }
158
+ const [major = 0, minor = 0, patch = 0] = parts;
159
+
160
+ switch (type) {
161
+ case "major":
162
+ return `${major + 1}.0.0`;
163
+ case "minor":
164
+ return `${major}.${minor + 1}.0`;
165
+ case "patch":
166
+ return `${major}.${minor}.${patch + 1}`;
167
+ }
168
+ }
169
+
170
+ // ── Git Helpers ─────────────────────────────────────────────────────
171
+
172
+ function getGitRemote(cwd?: string): string | null {
173
+ try {
174
+ return exec("git remote get-url origin", cwd);
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ export function parseGithubRepo(remoteUrl: string): string | null {
181
+ // SSH: git@github.com:owner/repo.git
182
+ const sshMatch = /^git@github\.com:(.+?)\.git$/.exec(remoteUrl);
183
+ if (sshMatch) return sshMatch[1];
184
+
185
+ // HTTPS: https://github.com/owner/repo.git
186
+ const httpsMatch = /^https:\/\/github\.com\/(.+?)\.git$/.exec(remoteUrl);
187
+ if (httpsMatch) return httpsMatch[1];
188
+
189
+ // HTTPS without .git
190
+ const httpsNoGit = /^https:\/\/github\.com\/(.+?)$/.exec(remoteUrl);
191
+ if (httpsNoGit) return httpsNoGit[1];
192
+
193
+ return null;
194
+ }
195
+
196
+ function isGitClean(cwd?: string): boolean {
197
+ try {
198
+ const status = exec("git status --porcelain", cwd);
199
+ return status.length === 0;
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
204
+
205
+ function getLatestTag(cwd?: string): string | null {
206
+ try {
207
+ return exec("git describe --tags --abbrev=0", cwd);
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ function tagExists(tag: string, cwd?: string): boolean {
214
+ try {
215
+ exec(`git rev-parse ${tag}`, cwd);
216
+ return true;
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+
222
+ // ── Changelog Generation ────────────────────────────────────────────
223
+
224
+ const CONVENTIONAL_PATTERN = /^(\w+)(?:\(([^)]*)\))?(!)?:\s+(.+)$/;
225
+ const TYPE_LABELS: Record<string, string> = {
226
+ feat: "Features",
227
+ fix: "Bug Fixes",
228
+ docs: "Documentation",
229
+ style: "Styles",
230
+ refactor: "Code Refactoring",
231
+ perf: "Performance Improvements",
232
+ test: "Tests",
233
+ ci: "CI/CD",
234
+ build: "Build System",
235
+ chore: "Miscellaneous",
236
+ revert: "Reverts",
237
+ };
238
+ const TYPE_ORDER = [
239
+ "feat",
240
+ "fix",
241
+ "perf",
242
+ "refactor",
243
+ "docs",
244
+ "style",
245
+ "test",
246
+ "ci",
247
+ "build",
248
+ "chore",
249
+ "revert",
250
+ ];
251
+
252
+ interface SimpleCommit {
253
+ hash: string;
254
+ type: string;
255
+ scope: string | null;
256
+ subject: string;
257
+ breaking: boolean;
258
+ }
259
+
260
+ function parseSimpleCommit(line: string): SimpleCommit | null {
261
+ const parts = line.split("|");
262
+ if (parts.length < 2) return null;
263
+
264
+ const hash = parts[0]?.trim() || "";
265
+ const subject = parts.slice(1).join("|").trim();
266
+
267
+ const match = CONVENTIONAL_PATTERN.exec(subject);
268
+ if (!match) {
269
+ return { hash, type: "other", scope: null, subject, breaking: false };
270
+ }
271
+
272
+ const [, type, scope, bang, desc] = match;
273
+ return {
274
+ hash,
275
+ type: (type || "other").toLowerCase(),
276
+ scope: scope || null,
277
+ subject: desc?.trim() || subject,
278
+ breaking: bang === "!",
279
+ };
280
+ }
281
+
282
+ export function generateBasicChangelog(version: string, cwd?: string): string {
283
+ const base = cwd || process.cwd();
284
+ const latestTag = getLatestTag(base);
285
+ const range = latestTag ? `${latestTag}..HEAD` : "";
286
+ const cmd = `git log ${range} --format="%H|%s" --date=short`;
287
+
288
+ let logOutput: string;
289
+ try {
290
+ logOutput = exec(cmd, base);
291
+ } catch {
292
+ return `# Changelog\n\n## [${version}]\n\nNo commits found.\n`;
293
+ }
294
+
295
+ if (!logOutput) {
296
+ return `# Changelog\n\n## [${version}]\n\nNo commits found.\n`;
297
+ }
298
+
299
+ const lines = logOutput.split("\n");
300
+ const commits: SimpleCommit[] = [];
301
+ for (const line of lines) {
302
+ const commit = parseSimpleCommit(line);
303
+ if (commit) commits.push(commit);
304
+ }
305
+
306
+ // Group by type
307
+ const grouped = new Map<string, SimpleCommit[]>();
308
+ for (const commit of commits) {
309
+ const existing = grouped.get(commit.type) || [];
310
+ existing.push(commit);
311
+ grouped.set(commit.type, existing);
312
+ }
313
+
314
+ const date = new Date().toISOString().split("T")[0];
315
+ const mdLines: string[] = [
316
+ "# Changelog",
317
+ "",
318
+ `## [${version}] - ${date}`,
319
+ "",
320
+ ];
321
+
322
+ // Breaking changes first
323
+ const breaking = commits.filter((c) => c.breaking);
324
+ if (breaking.length > 0) {
325
+ mdLines.push("### ⚠ BREAKING CHANGES", "");
326
+ for (const c of breaking) {
327
+ const scope = c.scope ? `**${c.scope}:** ` : "";
328
+ mdLines.push(`- ${scope}${c.subject} (${c.hash.slice(0, 7)})`);
329
+ }
330
+ mdLines.push("");
331
+ }
332
+
333
+ // Grouped entries
334
+ for (const type of TYPE_ORDER) {
335
+ const typeCommits = grouped.get(type);
336
+ if (typeCommits && typeCommits.length > 0) {
337
+ mdLines.push(`### ${TYPE_LABELS[type] || type}`, "");
338
+ for (const c of typeCommits) {
339
+ const scope = c.scope ? `**${c.scope}:** ` : "";
340
+ mdLines.push(`- ${scope}${c.subject} (${c.hash.slice(0, 7)})`);
341
+ }
342
+ mdLines.push("");
343
+ }
344
+ }
345
+
346
+ // Other types not in TYPE_ORDER
347
+ for (const [type, typeCommits] of grouped) {
348
+ if (!TYPE_ORDER.includes(type) && typeCommits.length > 0) {
349
+ mdLines.push(`### ${TYPE_LABELS[type] || type}`, "");
350
+ for (const c of typeCommits) {
351
+ mdLines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
352
+ }
353
+ mdLines.push("");
354
+ }
355
+ }
356
+
357
+ return mdLines.join("\n");
358
+ }
359
+
360
+ // ── Tool Implementations ────────────────────────────────────────────
361
+
362
+ export function bumpVersionImpl(
363
+ bumpType: BumpType,
364
+ options: {
365
+ cwd?: string;
366
+ dryRun?: boolean;
367
+ preReleaseTag?: string;
368
+ commitMessage?: string;
369
+ tagPrefix?: string;
370
+ } = {},
371
+ ): BumpResult {
372
+ const cwd = options.cwd;
373
+ const config = loadConfig(cwd);
374
+ const dryRun = options.dryRun ?? false;
375
+ const tagPrefix = options.tagPrefix ?? config.tagPrefix;
376
+ const preRelease = options.preReleaseTag ?? config.preReleaseTag;
377
+
378
+ const pkg = readPackageJson(cwd);
379
+ const previousVersion = pkg.version;
380
+ let newVersion = bumpSemver(previousVersion, bumpType);
381
+
382
+ if (preRelease) {
383
+ newVersion = `${newVersion}-${preRelease}`;
384
+ }
385
+
386
+ const tag = `${tagPrefix}${newVersion}`;
387
+ const commitMsg = (options.commitMessage || config.commitMessage).replace(
388
+ "{version}",
389
+ newVersion,
390
+ );
391
+
392
+ if (dryRun) {
393
+ return {
394
+ previousVersion,
395
+ newVersion,
396
+ tag,
397
+ commitHash: null,
398
+ dryRun: true,
399
+ };
400
+ }
401
+
402
+ // Check git state
403
+ if (!isGitClean(cwd)) {
404
+ throw new Error(
405
+ "Working directory has uncommitted changes. Commit or stash them first.",
406
+ );
407
+ }
408
+
409
+ if (tagExists(tag, cwd)) {
410
+ throw new Error(`Tag ${tag} already exists`);
411
+ }
412
+
413
+ // Write new version to package.json
414
+ writePackageVersion(newVersion, cwd);
415
+
416
+ // Commit the version bump
417
+ let commitHash: string | null = null;
418
+ if (config.commitBump) {
419
+ exec("git add package.json", cwd);
420
+ exec(`git commit -m "${commitMsg}"`, cwd);
421
+ commitHash = exec("git rev-parse HEAD", cwd);
422
+ }
423
+
424
+ // Create tag
425
+ if (config.createTag) {
426
+ exec(`git tag ${tag}`, cwd);
427
+ }
428
+
429
+ return {
430
+ previousVersion,
431
+ newVersion,
432
+ tag,
433
+ commitHash,
434
+ dryRun: false,
435
+ };
436
+ }
437
+
438
+ export function createGithubReleaseImpl(
439
+ tag: string,
440
+ options: {
441
+ cwd?: string;
442
+ dryRun?: boolean;
443
+ title?: string;
444
+ notes?: string;
445
+ prerelease?: boolean;
446
+ } = {},
447
+ ): GithubReleaseResult {
448
+ const cwd = options.cwd;
449
+ const config = loadConfig(cwd);
450
+ const dryRun = options.dryRun ?? false;
451
+
452
+ // Detect GitHub repo
453
+ const remote = getGitRemote(cwd);
454
+ const repo = remote ? parseGithubRepo(remote) : config.githubRepo;
455
+ if (!repo) {
456
+ throw new Error(
457
+ "Cannot determine GitHub repo. Set githubRepo in pi-release.json or ensure git remote 'origin' points to GitHub.",
458
+ );
459
+ }
460
+
461
+ const title = options.title || `Release ${tag}`;
462
+ const url = `https://github.com/${repo}/releases/tag/${tag}`;
463
+
464
+ if (dryRun) {
465
+ return { tag, url, dryRun: true };
466
+ }
467
+
468
+ // Check gh CLI is available
469
+ try {
470
+ exec("gh --version", cwd);
471
+ } catch {
472
+ throw new Error(
473
+ "GitHub CLI (gh) is not installed. Install it from https://cli.github.com/",
474
+ );
475
+ }
476
+
477
+ // Check tag exists
478
+ if (!tagExists(tag, cwd)) {
479
+ throw new Error(`Tag ${tag} does not exist. Run bump_version first.`);
480
+ }
481
+
482
+ // Build gh release create command
483
+ const args = ["gh release create", tag];
484
+ args.push("--repo", repo);
485
+ args.push("--title", `"${title}"`);
486
+
487
+ if (options.notes) {
488
+ args.push("--notes", `"${options.notes}"`);
489
+ } else {
490
+ args.push("--generate-notes");
491
+ }
492
+
493
+ if (options.prerelease) {
494
+ args.push("--prerelease");
495
+ }
496
+
497
+ exec(args.join(" "), cwd);
498
+
499
+ return { tag, url, dryRun: false };
500
+ }
501
+
502
+ export function publishNpmImpl(
503
+ options: {
504
+ cwd?: string;
505
+ dryRun?: boolean;
506
+ otp?: string;
507
+ registry?: string;
508
+ tag?: string;
509
+ } = {},
510
+ ): PublishResult {
511
+ const cwd = options.cwd;
512
+ const config = loadConfig(cwd);
513
+ const dryRun = options.dryRun ?? false;
514
+ const registry = options.registry || config.registry;
515
+
516
+ const pkg = readPackageJson(cwd);
517
+
518
+ if (dryRun) {
519
+ return {
520
+ package: pkg.name,
521
+ version: pkg.version,
522
+ registry,
523
+ dryRun: true,
524
+ };
525
+ }
526
+
527
+ // Build npm publish command
528
+ const args = ["npm publish"];
529
+ if (registry !== "https://registry.npmjs.org/") {
530
+ args.push("--registry", registry);
531
+ }
532
+ if (options.otp) {
533
+ args.push("--otp", options.otp);
534
+ }
535
+ if (options.tag) {
536
+ args.push("--tag", options.tag);
537
+ }
538
+
539
+ exec(args.join(" "), cwd);
540
+
541
+ return {
542
+ package: pkg.name,
543
+ version: pkg.version,
544
+ registry,
545
+ dryRun: false,
546
+ };
547
+ }
548
+
549
+ // ── Formatting ──────────────────────────────────────────────────────
550
+
551
+ function formatBumpResult(result: BumpResult): string {
552
+ const lines: string[] = [];
553
+ const prefix = result.dryRun ? "[DRY RUN] " : "";
554
+
555
+ lines.push(`${prefix}📦 Version Bump`);
556
+ lines.push(` ${result.previousVersion} → ${result.newVersion}`);
557
+ lines.push(` Tag: ${result.tag}`);
558
+ if (result.commitHash) {
559
+ lines.push(` Commit: ${result.commitHash.slice(0, 8)}`);
560
+ }
561
+ if (result.dryRun) {
562
+ lines.push(" ⚠️ No changes made (dry run)");
563
+ }
564
+
565
+ return lines.join("\n");
566
+ }
567
+
568
+ function formatGithubReleaseResult(result: GithubReleaseResult): string {
569
+ const lines: string[] = [];
570
+ const prefix = result.dryRun ? "[DRY RUN] " : "";
571
+
572
+ lines.push(`${prefix}🚀 GitHub Release`);
573
+ lines.push(` Tag: ${result.tag}`);
574
+ lines.push(` URL: ${result.url}`);
575
+ if (result.dryRun) {
576
+ lines.push(" ⚠️ No release created (dry run)");
577
+ }
578
+
579
+ return lines.join("\n");
580
+ }
581
+
582
+ function formatPublishResult(result: PublishResult): string {
583
+ const lines: string[] = [];
584
+ const prefix = result.dryRun ? "[DRY RUN] " : "";
585
+
586
+ lines.push(`${prefix}📤 npm Publish`);
587
+ lines.push(` Package: ${result.package}@${result.version}`);
588
+ lines.push(` Registry: ${result.registry}`);
589
+ if (result.dryRun) {
590
+ lines.push(" ⚠️ Not published (dry run)");
591
+ }
592
+
593
+ return lines.join("\n");
594
+ }
595
+
596
+ function formatReleaseReport(
597
+ bump: BumpResult,
598
+ changelog: string,
599
+ ghRelease: GithubReleaseResult | null,
600
+ publish: PublishResult | null,
601
+ ): string {
602
+ const bar = "═".repeat(60);
603
+ const lines: string[] = [];
604
+
605
+ lines.push(`╔${bar}╗`);
606
+ lines.push("║ 🎉 RELEASE REPORT");
607
+ lines.push(`║ ${bump.newVersion}`);
608
+ if (bump.dryRun) lines.push("║ [DRY RUN — no changes made]");
609
+ lines.push(`╚${bar}╝`);
610
+ lines.push("");
611
+ lines.push(formatBumpResult(bump));
612
+ lines.push("");
613
+
614
+ if (changelog) {
615
+ lines.push("📝 Changelog:");
616
+ lines.push(changelog);
617
+ lines.push("");
618
+ }
619
+
620
+ if (ghRelease) {
621
+ lines.push(formatGithubReleaseResult(ghRelease));
622
+ lines.push("");
623
+ }
624
+
625
+ if (publish) {
626
+ lines.push(formatPublishResult(publish));
627
+ lines.push("");
628
+ }
629
+
630
+ if (bump.dryRun) {
631
+ lines.push("─".repeat(40));
632
+ lines.push("This was a dry run. Run without --dry-run to execute.");
633
+ } else {
634
+ lines.push("─".repeat(40));
635
+ lines.push("✅ Release complete!");
636
+ }
637
+
638
+ return lines.join("\n");
639
+ }
640
+
641
+ // ── Extension Registration ──────────────────────────────────────────
642
+
643
+ export default function (pi: ExtensionAPI) {
644
+ // ── Tool: bump_version ──────────────────────────────────────────
645
+ pi.registerTool({
646
+ name: "bump_version",
647
+ label: "Bump Version",
648
+ description:
649
+ "Bump the version in package.json (major/minor/patch), create a git commit and tag. Supports pre-release tags (beta, alpha, rc) and dry-run mode. Requires a clean git working directory.",
650
+ parameters: Type.Object({
651
+ bump: Type.Optional(
652
+ Type.Union(
653
+ [Type.Literal("major"), Type.Literal("minor"), Type.Literal("patch")],
654
+ {
655
+ description:
656
+ "Version bump type: major (1.0.0→2.0.0), minor (1.0.0→1.1.0), patch (1.0.0→1.0.1). Default: patch.",
657
+ default: "patch",
658
+ },
659
+ ),
660
+ ),
661
+ pre_release: Type.Optional(
662
+ Type.String({
663
+ description:
664
+ "Pre-release tag to append (e.g. 'beta', 'alpha', 'rc'). Result: 1.0.0-beta.",
665
+ }),
666
+ ),
667
+ dry_run: Type.Optional(
668
+ Type.Boolean({
669
+ description:
670
+ "Preview the version bump without making changes. Default: false.",
671
+ default: false,
672
+ }),
673
+ ),
674
+ message: Type.Optional(
675
+ Type.String({
676
+ description:
677
+ "Custom commit message. Use {version} as placeholder. Default: 'chore(release): {version}'.",
678
+ }),
679
+ ),
680
+ }),
681
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
682
+ try {
683
+ const bumpType = (params.bump || "patch") as BumpType;
684
+ const result = bumpVersionImpl(bumpType, {
685
+ cwd: ctx.cwd,
686
+ dryRun: params.dry_run,
687
+ preReleaseTag: params.pre_release,
688
+ commitMessage: params.message,
689
+ });
690
+
691
+ return {
692
+ content: [{ type: "text", text: formatBumpResult(result) }],
693
+ details: result,
694
+ };
695
+ } catch (err: unknown) {
696
+ const msg = err instanceof Error ? err.message : "Unknown error";
697
+ return {
698
+ content: [{ type: "text", text: `❌ Version bump failed: ${msg}` }],
699
+ details: { error: msg },
700
+ };
701
+ }
702
+ },
703
+ });
704
+
705
+ // ── Tool: create_github_release ─────────────────────────────────
706
+ pi.registerTool({
707
+ name: "create_github_release",
708
+ label: "Create GitHub Release",
709
+ description:
710
+ "Create a GitHub release from a git tag using 'gh release create'. Auto-generates release notes unless custom notes are provided. Requires GitHub CLI (gh) to be installed and authenticated.",
711
+ parameters: Type.Object({
712
+ tag: Type.String({
713
+ description:
714
+ "Git tag to create the release from (e.g. 'v1.0.0'). Tag must already exist.",
715
+ }),
716
+ title: Type.Optional(
717
+ Type.String({
718
+ description: "Release title. Defaults to 'Release {tag}'.",
719
+ }),
720
+ ),
721
+ notes: Type.Optional(
722
+ Type.String({
723
+ description:
724
+ "Custom release notes. If omitted, GitHub auto-generates notes from commits since last release.",
725
+ }),
726
+ ),
727
+ prerelease: Type.Optional(
728
+ Type.Boolean({
729
+ description: "Mark as a pre-release. Default: false.",
730
+ default: false,
731
+ }),
732
+ ),
733
+ dry_run: Type.Optional(
734
+ Type.Boolean({
735
+ description:
736
+ "Preview the release without creating it. Default: false.",
737
+ default: false,
738
+ }),
739
+ ),
740
+ }),
741
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
742
+ try {
743
+ const result = createGithubReleaseImpl(params.tag, {
744
+ cwd: ctx.cwd,
745
+ dryRun: params.dry_run,
746
+ title: params.title,
747
+ notes: params.notes,
748
+ prerelease: params.prerelease,
749
+ });
750
+
751
+ return {
752
+ content: [{ type: "text", text: formatGithubReleaseResult(result) }],
753
+ details: result,
754
+ };
755
+ } catch (err: unknown) {
756
+ const msg = err instanceof Error ? err.message : "Unknown error";
757
+ return {
758
+ content: [
759
+ {
760
+ type: "text",
761
+ text: `❌ GitHub release creation failed: ${msg}`,
762
+ },
763
+ ],
764
+ details: { error: msg },
765
+ };
766
+ }
767
+ },
768
+ });
769
+
770
+ // ── Tool: publish_npm ──────────────────────────────────────────
771
+ pi.registerTool({
772
+ name: "publish_npm",
773
+ label: "Publish to npm",
774
+ description:
775
+ "Publish the current package to npm. Supports custom registries, OTP for 2FA, dist-tags, and dry-run mode.",
776
+ parameters: Type.Object({
777
+ otp: Type.Optional(
778
+ Type.String({
779
+ description: "One-time password for npm 2FA authentication.",
780
+ }),
781
+ ),
782
+ registry: Type.Optional(
783
+ Type.String({
784
+ description:
785
+ "npm registry URL. Defaults to https://registry.npmjs.org/ or value in pi-release.json.",
786
+ }),
787
+ ),
788
+ tag: Type.Optional(
789
+ Type.String({
790
+ description:
791
+ "npm dist-tag (e.g. 'beta', 'next'). Defaults to 'latest'.",
792
+ }),
793
+ ),
794
+ dry_run: Type.Optional(
795
+ Type.Boolean({
796
+ description: "Preview the publish without executing. Default: false.",
797
+ default: false,
798
+ }),
799
+ ),
800
+ }),
801
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
802
+ try {
803
+ const result = publishNpmImpl({
804
+ cwd: ctx.cwd,
805
+ dryRun: params.dry_run,
806
+ otp: params.otp,
807
+ registry: params.registry,
808
+ tag: params.tag,
809
+ });
810
+
811
+ return {
812
+ content: [{ type: "text", text: formatPublishResult(result) }],
813
+ details: result,
814
+ };
815
+ } catch (err: unknown) {
816
+ const msg = err instanceof Error ? err.message : "Unknown error";
817
+ return {
818
+ content: [{ type: "text", text: `❌ npm publish failed: ${msg}` }],
819
+ details: { error: msg },
820
+ };
821
+ }
822
+ },
823
+ });
824
+
825
+ // ── Command: /release ──────────────────────────────────────────
826
+ pi.registerCommand("release", {
827
+ description:
828
+ "Full release flow: bump version → generate changelog → commit → tag → GitHub release → npm publish. Usage: /release [major|minor|patch] [--dry-run]",
829
+ handler: async (args, ctx) => {
830
+ const config = loadConfig(ctx.cwd);
831
+ const argStr = args?.trim() || "";
832
+
833
+ // Parse arguments
834
+ const isDryRun = argStr.includes("--dry-run");
835
+ const cleanArgs = argStr.replace("--dry-run", "").trim();
836
+ const bumpType = (cleanArgs || config.defaultBump) as BumpType;
837
+
838
+ if (!["major", "minor", "patch"].includes(bumpType)) {
839
+ ctx.ui.notify(
840
+ `❌ Invalid bump type '${bumpType}'. Use: major, minor, or patch.`,
841
+ "error",
842
+ );
843
+ return;
844
+ }
845
+
846
+ const dryLabel = isDryRun ? " [DRY RUN]" : "";
847
+ ctx.ui.notify(`🚀 Starting ${bumpType} release${dryLabel}...`, "info");
848
+
849
+ try {
850
+ // Step 1: Bump version
851
+ ctx.ui.notify("📦 Step 1/4: Bumping version...", "info");
852
+ const bump = bumpVersionImpl(bumpType, {
853
+ cwd: ctx.cwd,
854
+ dryRun: isDryRun,
855
+ preReleaseTag: config.preReleaseTag,
856
+ });
857
+ ctx.ui.notify(formatBumpResult(bump), "info");
858
+
859
+ // Step 2: Generate changelog
860
+ let changelog = "";
861
+ if (config.generateChangelog) {
862
+ ctx.ui.notify("📝 Step 2/4: Generating changelog...", "info");
863
+ changelog = generateBasicChangelog(bump.newVersion, ctx.cwd);
864
+
865
+ if (!isDryRun) {
866
+ // Append to CHANGELOG.md
867
+ const changelogPath = join(
868
+ ctx.cwd || process.cwd(),
869
+ "CHANGELOG.md",
870
+ );
871
+ let existing = "";
872
+ if (existsSync(changelogPath)) {
873
+ existing = readFileSync(changelogPath, "utf-8");
874
+ }
875
+ const newContent = changelog.replace("# Changelog\n", "");
876
+ const fullChangelog = existing
877
+ ? `# Changelog\n\n${newContent}\n${existing.replace("# Changelog\n", "").trim()}\n`
878
+ : changelog;
879
+ writeFileSync(changelogPath, fullChangelog, "utf-8");
880
+
881
+ // Commit changelog
882
+ exec("git add CHANGELOG.md", ctx.cwd);
883
+ exec(
884
+ `git commit -m "docs(release): changelog for ${bump.newVersion}"`,
885
+ ctx.cwd,
886
+ );
887
+ }
888
+ ctx.ui.notify(" ✅ Changelog generated", "info");
889
+ } else {
890
+ ctx.ui.notify(
891
+ "📝 Step 2/4: Changelog generation skipped (disabled in config)",
892
+ "info",
893
+ );
894
+ }
895
+
896
+ // Step 3: GitHub release
897
+ let ghRelease: GithubReleaseResult | null = null;
898
+ if (config.createGithubRelease) {
899
+ ctx.ui.notify("🚀 Step 3/4: Creating GitHub release...", "info");
900
+ try {
901
+ ghRelease = createGithubReleaseImpl(bump.tag, {
902
+ cwd: ctx.cwd,
903
+ dryRun: isDryRun,
904
+ });
905
+ ctx.ui.notify(formatGithubReleaseResult(ghRelease), "info");
906
+ } catch (err) {
907
+ const msg = err instanceof Error ? err.message : "Unknown error";
908
+ ctx.ui.notify(`⚠️ GitHub release skipped: ${msg}`, "warning");
909
+ }
910
+ } else {
911
+ ctx.ui.notify(
912
+ "🚀 Step 3/4: GitHub release skipped (disabled in config)",
913
+ "info",
914
+ );
915
+ }
916
+
917
+ // Step 4: npm publish
918
+ let publish: PublishResult | null = null;
919
+ if (config.publishToNpm) {
920
+ ctx.ui.notify("📤 Step 4/4: Publishing to npm...", "info");
921
+ try {
922
+ publish = publishNpmImpl({
923
+ cwd: ctx.cwd,
924
+ dryRun: isDryRun,
925
+ });
926
+ ctx.ui.notify(formatPublishResult(publish), "info");
927
+ } catch (err) {
928
+ const msg = err instanceof Error ? err.message : "Unknown error";
929
+ ctx.ui.notify(`⚠️ npm publish skipped: ${msg}`, "warning");
930
+ }
931
+ } else {
932
+ ctx.ui.notify(
933
+ "📤 Step 4/4: npm publish skipped (disabled in config)",
934
+ "info",
935
+ );
936
+ }
937
+
938
+ // Final report
939
+ const report = formatReleaseReport(bump, changelog, ghRelease, publish);
940
+ ctx.ui.notify(report, "info");
941
+ } catch (err: unknown) {
942
+ const msg = err instanceof Error ? err.message : "Unknown error";
943
+ ctx.ui.notify(`❌ Release failed: ${msg}`, "error");
944
+ }
945
+ },
946
+ });
947
+
948
+ // ── Event: session_start ────────────────────────────────────────
949
+ pi.on("session_start", async (_event, ctx) => {
950
+ ctx.ui.notify(
951
+ "🚀 pi-release loaded — run /release to start a release",
952
+ "info",
953
+ );
954
+ });
955
+ }