pi-linear-worktree 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Karl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # pi-linear-worktree
2
+
3
+ A [pi](https://github.com/badlogic/pi-mono) extension that fetches Linear issues and creates git worktrees to solve them.
4
+
5
+ Type `/linear ENG-123` and the agent will:
6
+
7
+ 1. Fetch the full issue from Linear (title, description, comments, labels, relations)
8
+ 2. Create a git worktree with a branch named after the issue
9
+ 3. Send the issue context to the agent and start solving it
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pi install npm:pi-linear-worktree
15
+ ```
16
+
17
+ Or try it without installing:
18
+
19
+ ```bash
20
+ pi -e npm:pi-linear-worktree
21
+ ```
22
+
23
+ ## Setup
24
+
25
+ Set your Linear API key as an environment variable:
26
+
27
+ ```bash
28
+ export LINEAR_API_KEY=lin_api_xxxxxxxxxxxxx
29
+ ```
30
+
31
+ You can create a personal API key at [Linear Settings → API](https://linear.app/settings/api).
32
+
33
+ ## Usage
34
+
35
+ Inside a git repository, run:
36
+
37
+ ```
38
+ /linear <issue-id>
39
+ ```
40
+
41
+ For example:
42
+
43
+ ```
44
+ /linear ENG-123
45
+ ```
46
+
47
+ The extension will:
48
+
49
+ - Fetch the issue details from Linear
50
+ - Create a worktree at `../<branch-name>` relative to the repo root
51
+ - If the worktree/branch already exists, reuse it
52
+ - Name the pi session after the issue
53
+ - Send the full issue context to the agent to start working on it
54
+
55
+ ## What gets sent to the agent
56
+
57
+ - Issue title, description, state, priority, assignee
58
+ - Labels and parent issue
59
+ - Related issues
60
+ - All comments (chronological)
61
+ - Worktree path and branch name
62
+ - Instructions to work in the worktree and commit when done
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Linear + Git Worktree extension for pi
3
+ *
4
+ * Provides a /linear command that:
5
+ * 1. Fetches issue details from Linear (title, description, comments, labels, etc.)
6
+ * 2. Creates a git worktree with a branch named after the issue
7
+ * 3. Sends the issue context to the agent and starts solving it
8
+ *
9
+ * Requires:
10
+ * - LINEAR_API_KEY environment variable
11
+ * - Current directory must be inside a git repository
12
+ */
13
+
14
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
+ import { LinearClient } from "@linear/sdk";
16
+ import path from "node:path";
17
+
18
+ function slugify(text: string): string {
19
+ return text
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9]+/g, "-")
22
+ .replace(/^-|-$/g, "")
23
+ .slice(0, 50);
24
+ }
25
+
26
+ /** Sanitize a branch name for use as a directory name (replace / with -). */
27
+ function branchToDir(branchName: string): string {
28
+ return branchName.replace(/\//g, "-");
29
+ }
30
+
31
+ async function getLinearIssue(apiKey: string, issueId: string) {
32
+ const client = new LinearClient({ apiKey });
33
+ const issue = await client.issue(issueId);
34
+
35
+ const assignee = issue.assignee ? await issue.assignee : null;
36
+ const state = issue.state ? await issue.state : null;
37
+ const labels = await issue.labels();
38
+ const comments = await issue.comments();
39
+ const parent = issue.parent ? await issue.parent : null;
40
+ const relations = await issue.relations();
41
+
42
+ const relatedIssues: string[] = [];
43
+ for (const relation of relations.nodes) {
44
+ const related = await relation.relatedIssue;
45
+ if (related) {
46
+ relatedIssues.push(`- [${relation.type}] ${related.identifier}: ${related.title}`);
47
+ }
48
+ }
49
+
50
+ const commentTexts = comments.nodes
51
+ .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
52
+ .map((c) => {
53
+ const date = new Date(c.createdAt).toISOString().split("T")[0];
54
+ return `[${date}] ${c.body}`;
55
+ });
56
+
57
+ return {
58
+ identifier: issue.identifier,
59
+ title: issue.title,
60
+ description: issue.description || "(no description)",
61
+ priority: issue.priority,
62
+ priorityLabel: issue.priorityLabel,
63
+ state: state?.name || "Unknown",
64
+ assignee: assignee?.name || "Unassigned",
65
+ labels: labels.nodes.map((l) => l.name),
66
+ comments: commentTexts,
67
+ parent: parent ? `${parent.identifier}: ${parent.title}` : null,
68
+ relatedIssues,
69
+ url: issue.url,
70
+ branchName: issue.branchName || `${issue.identifier.toLowerCase()}-${slugify(issue.title)}`,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Find an existing worktree for the given branch by parsing `git worktree list --porcelain`.
76
+ * Returns the absolute worktree path if found, undefined otherwise.
77
+ */
78
+ function findWorktreeForBranch(porcelainOutput: string, branchName: string): string | undefined {
79
+ const blocks = porcelainOutput.split("\n\n");
80
+ for (const block of blocks) {
81
+ const lines = block.split("\n");
82
+ const worktreeLine = lines.find((l) => l.startsWith("worktree "));
83
+ const branchLine = lines.find((l) => l.startsWith("branch "));
84
+ if (worktreeLine && branchLine) {
85
+ // branch line is "branch refs/heads/<name>"
86
+ const ref = branchLine.replace("branch ", "");
87
+ if (ref === `refs/heads/${branchName}`) {
88
+ return worktreeLine.replace("worktree ", "");
89
+ }
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ export default function (pi: ExtensionAPI) {
96
+ pi.registerCommand("linear", {
97
+ description: "Fetch a Linear issue and create a git worktree to solve it. Usage: /linear <issue-id>",
98
+ handler: async (args, ctx) => {
99
+ const issueId = args?.trim();
100
+ if (!issueId) {
101
+ ctx.ui.notify("Usage: /linear <issue-id> (e.g. /linear ENG-123)", "error");
102
+ return;
103
+ }
104
+
105
+ const apiKey = process.env.LINEAR_API_KEY;
106
+ if (!apiKey) {
107
+ ctx.ui.notify("LINEAR_API_KEY environment variable is not set", "error");
108
+ return;
109
+ }
110
+
111
+ // Check we're in a git repo
112
+ const gitCheck = await pi.exec("git", ["rev-parse", "--git-dir"], { timeout: 5000 });
113
+ if (gitCheck.code !== 0) {
114
+ ctx.ui.notify("Not inside a git repository", "error");
115
+ return;
116
+ }
117
+
118
+ // Get the repo root (resolved to absolute path)
119
+ const repoRootResult = await pi.exec("git", ["rev-parse", "--show-toplevel"], { timeout: 5000 });
120
+ const repoRoot = repoRootResult.stdout.trim();
121
+
122
+ // Fetch issue from Linear
123
+ ctx.ui.setStatus("linear", "Fetching issue from Linear...");
124
+ let issue;
125
+ try {
126
+ issue = await getLinearIssue(apiKey, issueId);
127
+ } catch (err: any) {
128
+ ctx.ui.setStatus("linear", undefined);
129
+ ctx.ui.notify(`Failed to fetch issue: ${err.message}`, "error");
130
+ return;
131
+ }
132
+
133
+ const branchName = issue.branchName;
134
+ const dirName = branchToDir(branchName);
135
+ const worktreePath = path.resolve(repoRoot, "..", dirName);
136
+
137
+ ctx.ui.setStatus("linear", `Creating worktree for ${issue.identifier}...`);
138
+
139
+ // Fetch remote refs so we can detect remote branches
140
+ await pi.exec("git", ["fetch", "--quiet"], { timeout: 30000 });
141
+
142
+ // Check if a worktree for this branch already exists (match on branch ref, not path suffix)
143
+ const worktreeListResult = await pi.exec("git", ["worktree", "list", "--porcelain"], { timeout: 5000 });
144
+ const existingWorktree = findWorktreeForBranch(worktreeListResult.stdout, branchName);
145
+
146
+ let worktreeDir: string;
147
+
148
+ if (existingWorktree) {
149
+ worktreeDir = existingWorktree;
150
+ ctx.ui.notify(`Worktree already exists at ${worktreeDir}`, "info");
151
+ } else {
152
+ // Check if branch exists remotely or locally
153
+ const branchExistsLocal = await pi.exec("git", ["rev-parse", "--verify", branchName], { timeout: 5000 });
154
+ const branchExistsRemote = await pi.exec("git", ["rev-parse", "--verify", `origin/${branchName}`], {
155
+ timeout: 5000,
156
+ });
157
+
158
+ let createResult;
159
+ if (branchExistsLocal.code === 0) {
160
+ createResult = await pi.exec("git", ["worktree", "add", worktreePath, branchName], { timeout: 30000 });
161
+ } else if (branchExistsRemote.code === 0) {
162
+ createResult = await pi.exec(
163
+ "git",
164
+ ["worktree", "add", "--track", "-b", branchName, worktreePath, `origin/${branchName}`],
165
+ { timeout: 30000 },
166
+ );
167
+ } else {
168
+ createResult = await pi.exec("git", ["worktree", "add", "-b", branchName, worktreePath], {
169
+ timeout: 30000,
170
+ });
171
+ }
172
+
173
+ if (createResult.code !== 0) {
174
+ ctx.ui.setStatus("linear", undefined);
175
+ ctx.ui.notify(`Failed to create worktree: ${createResult.stderr}`, "error");
176
+ return;
177
+ }
178
+ worktreeDir = worktreePath;
179
+ }
180
+
181
+ ctx.ui.setStatus("linear", undefined);
182
+
183
+ // Name the session after the issue
184
+ pi.setSessionName(`${issue.identifier}: ${issue.title}`);
185
+
186
+ // Build context message for the agent
187
+ let context = `# Linear Issue: ${issue.identifier} — ${issue.title}\n\n`;
188
+ context += `**URL:** ${issue.url}\n`;
189
+ context += `**State:** ${issue.state}\n`;
190
+ context += `**Priority:** ${issue.priorityLabel}\n`;
191
+ context += `**Assignee:** ${issue.assignee}\n`;
192
+ if (issue.labels.length > 0) {
193
+ context += `**Labels:** ${issue.labels.join(", ")}\n`;
194
+ }
195
+ if (issue.parent) {
196
+ context += `**Parent Issue:** ${issue.parent}\n`;
197
+ }
198
+ context += `\n## Description\n\n${issue.description}\n`;
199
+
200
+ if (issue.relatedIssues.length > 0) {
201
+ context += `\n## Related Issues\n\n${issue.relatedIssues.join("\n")}\n`;
202
+ }
203
+
204
+ if (issue.comments.length > 0) {
205
+ context += `\n## Comments\n\n${issue.comments.join("\n\n")}\n`;
206
+ }
207
+
208
+ context += `\n---\n`;
209
+ context += `\n**Git worktree directory:** \`${worktreeDir}\`\n`;
210
+ context += `**Branch:** \`${branchName}\`\n`;
211
+ context += `\nIMPORTANT: All file operations (read, edit, write, bash) MUST target the worktree directory above, NOT the current working directory. Always use absolute paths under \`${worktreeDir}/\` or \`cd ${worktreeDir}\` before running commands.\n`;
212
+ context += `\nStart by understanding the codebase in the worktree, then implement the changes needed to resolve this issue. Commit your work to the branch when done.`;
213
+
214
+ // Send as user message to kick off the agent
215
+ pi.sendUserMessage(context);
216
+ },
217
+ });
218
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "pi-linear-worktree",
3
+ "version": "1.0.0",
4
+ "description": "Pi extension that fetches Linear issues and creates git worktrees to solve them",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "license": "MIT",
9
+ "author": "Karl",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/therapys/pi-linear-worktree"
13
+ },
14
+ "type": "module",
15
+ "files": [
16
+ "extensions/",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit",
22
+ "lint": "prettier --check .",
23
+ "format": "prettier --write ."
24
+ },
25
+ "pi": {
26
+ "extensions": [
27
+ "./extensions/index.ts"
28
+ ]
29
+ },
30
+ "dependencies": {
31
+ "@linear/sdk": "^76.0.0"
32
+ },
33
+ "peerDependencies": {
34
+ "@mariozechner/pi-coding-agent": "*"
35
+ },
36
+ "devDependencies": {
37
+ "@mariozechner/pi-coding-agent": "*",
38
+ "prettier": "^3.5.0",
39
+ "typescript": "^5.7.0"
40
+ }
41
+ }